# Kommunikation-Synchronisation: Analyse EspoCRM ↔ Advoware **Erstellt**: 8. Februar 2026 **Status**: ✅ API vollständig getestet **Basis**: Advoware API v1, EspoCRM Custom Entity --- ## 📋 Inhaltsverzeichnis 1. [Executive Summary](#executive-summary) 2. [Advoware API Analyse](#advoware-api-analyse) 3. [EspoCRM Konzept](#espocrm-konzept) 4. [Feld-Mapping](#feld-mapping) 5. [Sync-Strategie](#sync-strategie) 6. [Implementierungsplan](#implementierungsplan) --- ## 1. Executive Summary ### ✅ Was funktioniert | Operation | Status | Felder | |-----------|--------|--------| | **POST** (Create) | ✅ Vollständig | Alle 4 Felder | | **GET** (Read) | ✅ Vollständig | Enthalten in Beteiligte-Response | | **PUT** (Update) | ⚠️ Teilweise | 3 von 4 Feldern | | **DELETE** | ❌ 403 Forbidden | Nicht verfügbar | ### ⚠️ Kritische Einschränkungen 1. **kommKz ist READ-ONLY bei PUT**: Kommunikationstyp kann nach Erstellung nicht geändert werden 2. **Kein DELETE**: Manuelle Intervention via Notification erforderlich 3. **kommArt vs. kommKz**: `kommArt` wird automatisch von `kommKz` abgeleitet --- ## 2. Advoware API Analyse ### 2.1 Endpoints ``` POST /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen PUT /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen/{kommunikationId} GET /api/v1/advonet/Beteiligte/{beteiligterId} (enthält kommunikation array) DELETE /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen/{kommunikationId} ❌ 403 ``` ### 2.2 Datenmodell #### POST/PUT Request Body ```json { "tlf": "string (nullable)", "bemerkung": "string (nullable)", "kommKz": "integer (enum 1-12)", "online": "boolean" } ``` #### Response (GET/POST/PUT) ```json { "id": 88002, "betNr": 104860, "rowId": "FBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA", "kommArt": 0, "kommKz": 1, "tlf": "0511/12345-60", "bemerkung": null, "online": false } ``` ### 2.3 KommKz Enum (Kommunikationskennzeichen) | Wert | Name | Beschreibung | |------|------|--------------| | 1 | TelGesch | Geschäftstelefon | | 2 | FaxGesch | Geschäftsfax | | 3 | Mobil | Mobiltelefon | | 4 | MailGesch | Geschäfts-Email | | 5 | Internet | Website/URL | | 6 | TelPrivat | Privattelefon | | 7 | FaxPrivat | Privatfax | | 8 | MailPrivat | Private Email | | 9 | AutoTelefon | Autotelefon | | 10 | Sonstige | Sonstige Kommunikation | | 11 | EPost | E-Post (DE-Mail) | | 12 | Bea | BeA (Besonderes elektronisches Anwaltspostfach) | ### 2.4 Feld-Analyse (✅ API-verifiziert) #### ⚠️ KRITISCHER BUG: kommKz in GET immer 0 **Entdeckung**: Bei allen Tests zeigt der GET-Endpoint für **ALLE** Kommunikationen: ```json { "kommKz": 0, "kommArt": 0 } ``` **Beobachtungen**: 1. ✅ POST Response: kommKz wird korrekt zurückgegeben (z.B. 3 für Mobil) 2. ✅ PUT Response: kommKz wird zurückgegeben (aber oft ignoriert bei Änderungsversuch) 3. ❌ GET Response: kommKz ist IMMER 0 (für alle Kommunikationen!) **Test-Beispiel**: ```bash POST mit kommKz=3 (Mobil) → POST Response: kommKz=3 ✓ GET nach POST → GET Response: kommKz=0 ✗ PUT mit kommKz=7 (Versuch zu ändern) → PUT Response: kommKz=3 (ignoriert!) → GET Response: kommKz=0 ✗ ``` **Alle 11 getesteten Kommunikationen zeigen in GET: kommKz=0, kommArt=0** **Mögliche Ursachen**: 1. Fehlende Berechtigung zum Lesen dieser Felder (Role-basiert) 2. Bug in Advoware GET-Serialisierung 3. kommKz wird nur intern gespeichert, nicht im Hauptdatensatz **Implikationen für Sync**: - ⚠️ kommKz kann NICHT via GET verifiziert werden - ⚠️ Keine Möglichkeit den aktuellen Typ zu lesen - ⚠️ Sync-Strategie muss angepasst werden: EspoCRM ist "Source of Truth" #### POST (CREATE) - Alle Felder ``` ✅ tlf - string, nullable - Telefonnummer/Email/URL ✅ bemerkung - string, nullable - Notiz/Beschreibung ✅ kommKz - integer 1-12 - Kommunikationstyp ✅ online - boolean - Online-Kommunikation? (Email/Internet) ``` **Test-Ergebnis**: Alle 4 Felder können bei POST gesetzt werden. #### PUT (UPDATE) - 3 von 4 Feldern ``` ✅ tlf - WRITABLE - Kann geändert werden ✅ bemerkung - WRITABLE - Kann geändert werden ❌ kommKz - READ-ONLY - Kann NICHT geändert werden (bleibt beim Ursprungswert!) ✅ online - WRITABLE - Kann geändert werden ``` **Test-Ergebnis**: - `kommKz` wird bei PUT akzeptiert, aber ignoriert! - Response enthält oft den ursprünglichen `kommKz`-Wert (aber nicht zuverlässig) - **WICHTIG**: GET zeigt IMMER kommKz=0 (nicht nutzbar für Verifizierung!) - `rowId` ändert sich bei jedem erfolgreichen PUT #### Response-Only Felder (automatisch generiert) ``` 🔒 id - integer - Kommunikations-ID (PK) 🔒 betNr - integer - Beteiligten-ID (FK) 🔒 rowId - string - Änderungserkennung (Base64, ~40 Zeichen) 🔒 kommArt - integer - Wird von kommKz abgeleitet ``` **Wichtig**: `kommArt` ist ein internes Feld, das Advoware automatisch aus `kommKz` berechnet. ### 2.5 Test-Ergebnisse #### Test 1: POST - Neue Kommunikation erstellen ✅ ```bash POST /api/v1/advonet/Beteiligte/104860/Kommunikationen Request: { "kommKz": 1, "tlf": "+49 511 123456-10", "bemerkung": "TEST: Hauptnummer", "online": false } Response: 201 Created [{ "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOEAFAAAA", "id": 149331, "betNr": 104860, "kommArt": 1, "tlf": "+49 511 123456-10", "bemerkung": "TEST: Hauptnummer", "kommKz": 1, "online": false }] ``` **Status**: ✅ Erfolgreich #### Test 2: PUT - tlf ändern ✅ ```bash PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331 Request: { "kommKz": 1, "tlf": "+49 511 999999-99", "bemerkung": "TEST: Hauptnummer", "online": false } Response: 200 OK { "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOFABAAAA", # GEÄNDERT! "id": 149331, "betNr": 104860, "kommArt": 1, "tlf": "+49 511 999999-99", # GEÄNDERT! "bemerkung": "TEST: Hauptnummer", "kommKz": 1, "online": false } ``` **Status**: ✅ tlf erfolgreich geändert, rowId aktualisiert #### Test 3: PUT - kommKz ändern ❌ ```bash PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331 Request: { "kommKz": 6, # Versuche zu ändern: TelGesch → TelPrivat "tlf": "+49 511 999999-99", "bemerkung": "TEST: Geändert", "online": false } Response: 200 OK { "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOFABAAAA", # GEÄNDERT "id": 149331, "betNr": 104860, "kommArt": 1, "tlf": "+49 511 999999-99", "bemerkung": "TEST: Geändert", "kommKz": 1, # NICHT GEÄNDERT! Bleibt bei 1 "online": false } ``` **Status**: ❌ kommKz wird IGNORIERT (bleibt bei 1), aber rowId ändert sich trotzdem #### Test 4: DELETE ❌ ```bash DELETE /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331 Response: 403 Forbidden ``` **Status**: ❌ DELETE nicht verfügbar (wie bei Adressen/Bankverbindungen) --- ## 3. EspoCRM Konzept ### 3.1 Aktuelle Situation (✅ API-verifiziert) **Status**: ❌ **CKommunikation Entity existiert NICHT** EspoCRM hat **KEINE** separate Kommunikations-Entity. Stattdessen: #### Standard EspoCRM Felder in CBeteiligte ```json { "id": "68e4af00172be7924", "name": "Max Mustermann", // Primäre Kommunikation (einfache Felder) "emailAddress": "max@example.com", "phoneNumber": "+49 511 12345", // Erweiterte Kommunikation (Arrays) "emailAddressData": [ { "emailAddress": "max@example.com", "lower": "max@example.com", "primary": true, "optOut": false, "invalid": false }, { "emailAddress": "max.private@gmail.com", "lower": "max.private@gmail.com", "primary": false, "optOut": false, "invalid": false } ], "phoneNumberData": [ { "phoneNumber": "+49 511 12345", "primary": true, "type": "Office", "optOut": false, "invalid": false } ] } ``` **Wichtige Erkenntnisse**: - ❌ **Keine IDs** in emailAddressData/phoneNumberData - ❌ **Kein Typ-Feld** (keine kommKz-Unterscheidung möglich) - ✅ **primary Flag** für Haupt-Kommunikation - ✅ **Arrays** unterstützen mehrere Einträge - ⚠️ **NUR Email und Phone** - keine Fax, BeA, etc. ### 3.2 Zwei Sync-Strategien #### Option A: Integration in Beteiligte-Sync (Einfach, eingeschränkt) **Vorteile**: - ✅ Keine neuen Entities erforderlich - ✅ Nutzt vorhandene EspoCRM-Struktur - ✅ Einfache Implementierung **Nachteile**: - ❌ Nur Email und Telefon (keine Fax, BeA, etc.) - ❌ Kein Typ-Mapping (alle Emails sind "MailGesch", alle Phones sind "TelGesch") - ❌ Kein Matching via ID möglich (nur via Wert) - ❌ Schwierig zu synchronisieren (Array-Manipulation in Beteiligte-Update) **Umsetzung**: ```python # In beteiligte_sync.py nach Stammdaten-Update await sync_kommunikation_from_espocrm_data( espo_entity['emailAddressData'], espo_entity['phoneNumberData'], betnr ) ``` #### Option B: Custom CKommunikation Entity (Empfohlen) **Vorteile**: - ✅ Vollständige Unterstützung aller 12 Advoware-Typen - ✅ Separate Entity mit eigener ID (für Matching) - ✅ Typ-Feld für kommKz-Mapping - ✅ Saubere Trennung (separater Sync-Service) - ✅ Flexibel erweiterbar **Nachteile**: - ⚠️ Custom Entity muss in EspoCRM angelegt werden - ⚠️ Zusätzlicher Sync-Service erforderlich **Entity-Design**: ```json { "id": "string", "name": "string (auto-generiert)", "deleted": false, // Kommunikationsdaten "kommunikationstyp": "enum (kommKz)", "originalKommunikationstyp": "enum (IMMUTABLE nach CREATE)", "wert": "string (tlf)", "bemerkung": "text", "isOnline": "bool", "isPrimary": "bool", // Beziehung "beteiligteId": "string (FK zu CBeteiligte)", "beteiligteName": "string (Link-Name)", // Advoware Sync "advowareId": "int", "advowareRowId": "varchar(50)", "syncStatus": "enum (clean|dirty|failed)", "advowareLastSync": "datetime", "syncErrorMessage": "text" } ``` **Wichtig**: `originalKommunikationstyp` speichert den Typ bei Erstellung und ist IMMUTABLE. Dies wird benötigt weil: 1. kommKz in Advoware GET nicht lesbar ist (Bug: immer 0) 2. kommKz in Advoware nicht änderbar ist (READ-ONLY) 3. EspoCRM muss als "Source of Truth" für den Typ dienen ### 3.3 Empfehlung **➡️ Option B (Custom Entity) wird DRINGEND EMPFOHLEN** weil: 1. **Vollständigkeit**: Alle 12 Advoware-Typen unterstützt (nicht nur Email/Phone) 2. **Matching**: Entity-ID ermöglicht stabiles Matching 3. **Wartbarkeit**: Saubere Trennung von Stammdaten und Kommunikation 4. **Konsistenz**: Gleicher Ansatz wie Adressen und Bankverbindungen (separate Entities) **Migration von Standard zu Custom**: ```python # Einmaliger Import der bestehenden Daten async def migrate_standard_to_custom(): for bet in all_beteiligte: # Importiere Emails for email in bet['emailAddressData']: await espo.create_entity('CKommunikation', { 'beteiligteId': bet['id'], 'kommunikationstyp': 'MailGesch', 'wert': email['emailAddress'], 'isPrimary': email['primary'], 'isOnline': True }) # Importiere Phones for phone in bet['phoneNumberData']: await espo.create_entity('CKommunikation', { 'beteiligteId': bet['id'], 'kommunikationstyp': 'TelGesch', 'wert': phone['phoneNumber'], 'isPrimary': phone['primary'], 'isOnline': False }) ``` ### 3.2 Kommunikationstyp Enum ```javascript { "TelGesch": "Geschäftstelefon", "FaxGesch": "Geschäftsfax", "Mobil": "Mobiltelefon", "MailGesch": "Geschäfts-Email", "Internet": "Website", "TelPrivat": "Privattelefon", "FaxPrivat": "Privatfax", "MailPrivat": "Private Email", "AutoTelefon": "Autotelefon", "Sonstige": "Sonstige", "EPost": "E-Post", "Bea": "BeA" } ``` ### 3.3 Matching-Strategie **Problem**: Keine stabile ID für Matching zwischen Systemen **Lösungsansätze**: 1. **advowareId speichern** (bevorzugt) - Bei CREATE: Speichere `id` von Advoware Response - Bei SYNC: Matche via `advowareId` - ✅ Stabil, zuverlässig 2. **Kombination tlf + kommKz** (Fallback) - Matche via tlf-Wert UND Typ - ⚠️ Funktioniert nicht wenn tlf geändert wird - ⚠️ Duplikate möglich **Empfehlung**: Variante 1 (advowareId) wie bei Adressen --- ## 4. Feld-Mapping ### 4.1 Kommunikationstypen-Mapping (Advoware → EspoCRM) Da EspoCRM **keine separate CKommunikation Entity** hat, nutzen wir die Standard-Arrays: | kommKz | Advoware Typ | EspoCRM Ziel | phoneNumberData.type | Notiz | |--------|--------------|--------------|----------------------|-------| | 1 | TelGesch | phoneNumberData | Office | ✅ | | 2 | FaxGesch | phoneNumberData | Fax | ✅ | | 3 | Mobil | phoneNumberData | Mobile | ✅ | | 4 | MailGesch | emailAddressData | - | ✅ | | 5 | Internet | ❌ **NICHT UNTERSTÜTZT** | - | URL-Feld fehlt | | 6 | TelPrivat | phoneNumberData | Home | ✅ | | 7 | FaxPrivat | phoneNumberData | Fax | ✅ | | 8 | MailPrivat | emailAddressData | - | ✅ | | 9 | AutoTelefon | phoneNumberData | Mobile | ✅ | | 10 | Sonstige | phoneNumberData | Other | ✅ | | 11 | EPost | emailAddressData | - | ✅ | | 12 | Bea | emailAddressData | - | ✅ | **11 von 12 Typen werden unterstützt** (nur "Internet" fehlt) ### 4.2 Advoware → EspoCRM Mapping #### Email-Kommunikation (kommKz: 4, 8, 11, 12) ```python # Advoware Kommunikation { "id": 149331, "rowId": "eXqf+gAAAAAAAAA=", "kommKz": 4, # MailGesch "kommArt": 1, # Email "tlf": "max@example.com", "bemerkung": "Geschäftlich", "online": true } # → EspoCRM emailAddressData Element { "emailAddress": "max@example.com", "lower": "max@example.com", "primary": true, # Von Advoware (geschützt) "optOut": false, "invalid": false } ``` **Mapping-Logik**: - `tlf` → `emailAddress` und `lower` - `online` → `primary` (Advoware-Einträge sind immer primary=true) - `bemerkung` → ❌ Geht verloren (kein Feld in EspoCRM) #### Phone-Kommunikation (kommKz: 1, 2, 3, 6, 7, 9, 10) ```python # Advoware Kommunikation { "id": 149332, "rowId": "eXqf+gAAAAAAAAB=", "kommKz": 3, # Mobil "kommArt": 0, # Telefon "tlf": "+49 170 1234567", "bemerkung": "Privat", "online": false } # → EspoCRM phoneNumberData Element { "phoneNumber": "+49 170 1234567", "type": "Mobile", # Von kommKz abgeleitet "primary": false, # online=false "optOut": false, "invalid": false } ``` **Typ-Mapping**: ```python KOMMKZ_TO_PHONE_TYPE = { 1: 'Office', # TelGesch 2: 'Fax', # FaxGesch 3: 'Mobile', # Mobil 6: 'Home', # TelPrivat 7: 'Fax', # FaxPrivat 9: 'Mobile', # AutoTelefon 10: 'Other' # Sonstige } ``` ### 4.3 Matching-Strategie: bemerkung-Marker System ✅ IMPLEMENTIERT **Ausgangslage**: - ❌ Separate CKommunikation Entity: Unpraktikabel - ❌ PhoneNumber/EmailAddress Relationships: 403 Forbidden - ❌ `id` Feld in emailAddressData: Wird ignoriert/entfernt - ❌ kommKz/kommArt in GET: Beide immer 0 (Bug) - ✅ Advoware hat eindeutige `id` pro Kommunikation - ✅ Top-Level Felder (telGesch, emailGesch, etc.) für EINEN Eintrag pro Typ **LÖSUNG: Marker in Advoware bemerkung-Feld** #### Marker-Format: ``` [ESPOCRM:base64_value:kommKz] Optionale User-Bemerkung [ESPOCRM-SLOT:kommKz] (bei gelöschten Einträgen) ``` **Base64-Encoding**: Der Wert wird URL-safe Base64-kodiert gespeichert. Beispiele: - `[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Hauptadresse` - Email von EspoCRM (max@example.com) - `[ESPOCRM:KzQ5IDE3MCAxMjM0NTY3:1] Zentrale` - Telefon (+49 170 1234567) - `[ESPOCRM-SLOT:3]` - Leerer Slot für Mobil (nach Löschung) - `Wichtig: Nur vormittags` - Von Advoware (kein Marker) **Warum Base64 statt Hash?** ```python # Hash-Problem: Nicht rückrechenbar old_hash = hash("old@example.com") # abc123 new_value = "new@example.com" # Kann old_hash nicht zu EspoCRM matchen! # Base64-Lösung: Bidirektional encoded = base64("old@example.com") # b2xkQGV4YW1wbGUuY29t decoded = decode(encoded) # "old@example.com" ✅ # Kann dekodieren → Match in EspoCRM finden! ``` #### Typ-Erkennung (Priorität): 1. **Aus bemerkung-Marker** (wenn vorhanden) → Genau 2. **Aus Top-Level Feldern** (telGesch, emailGesch, etc.) → Genau für einen Eintrag 3. **Aus Wert** (Email='@', Phone=Rest) → Grob 4. **Default** (4=MailGesch, 1=TelGesch) → Fallback ```python def detect_kommkz(wert: str, beteiligte: dict, bemerkung: str = None) -> int: """Erkenne kommKz mit mehrstufiger Strategie""" # 1. Aus Marker if bemerkung and '[ESPOCRM:' in bemerkung: match = re.search(r'\[ESPOCRM(?:-SLOT)?:[^:]+:(\d+)\]', bemerkung) if match: return int(match.group(1)) # 2. Aus Top-Level Feldern (für EINEN Eintrag genau) type_map = { 'telGesch': 1, 'faxGesch': 2, 'mobil': 3, 'emailGesch': 4, 'internet': 5, 'telPrivat': 6, 'faxPrivat': 7, 'email': 4, 'autotelefon': 9, 'ePost': 11, 'bea': 12 } for field, kommkz in type_map.items(): if beteiligte.get(field) == wert: return kommkz # 3. Aus Wert (Email vs. Phone) if '@' in wert: return 4 # MailGesch elif wert.strip(): return 1 # TelGesch return 0 ``` #### Bidirektionaler Sync - 4 Szenarien: **Var1: Löschen in EspoCRM** ```python # EspoCRM: max@example.com gelöscht # Advoware: Eintrag mit "[ESPOCRM:abc:4] Geschäftlich" # Sync erkennt: In Advoware aber nicht in EspoCRM # → Leere Slot (Wert löschen, Typ behalten) await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={ 'tlf': '', 'bemerkung': '[ESPOCRM-SLOT:4]', # Slot-Marker 'kommKz': 4, # Bleibt 'online': False }) ``` **Var2: Ändern in EspoCRM** ```python # EspoCRM: max@old.com → max@new.com # Advoware: "[ESPOCRM:old-hash:4]" # Sync findet Eintrag via alten Hash # → UPDATE mit neuem Wert await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={ 'tlf': 'max@new.com', 'bemerkung': '[ESPOCRM:new-hash:4]', 'kommKz': 4, 'online': True }) ``` **Var3: Neu in EspoCRM** ```python # EspoCRM: Neue Email hinzugefügt # Sync sucht leeren Slot mit kommKz=4 empty_slots = [k for k in advo_komm if '[ESPOCRM-SLOT:4]' in (k.get('bemerkung') or '')] if empty_slots: # UPDATE leeren Slot await advoware.api_call(f'.../Kommunikationen/{slot_id}', 'PUT', ...) else: # CREATE neue Kommunikation await advoware.api_call(f'.../Beteiligte/{betnr}/Kommunikationen', 'POST', ...) ``` **Var4: Neu in Advoware** ```python # Advoware: Neue Kommunikation (keine Marker) # Sync erkennt: Kein Marker in bemerkung # → Neue Kommunikation von Advoware # Typ-Erkennung: kommkz = detect_kommkz(wert, beteiligte, bemerkung) # Mit Top-Level # Zu EspoCRM synchen + Marker setzen await espo.update_entity('CBeteiligte', bet_id, { 'emailAddressData': [...], # Neue Email }) # Marker in Advoware setzen await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={ 'tlf': wert, 'bemerkung': f'[ESPOCRM:{hash}:{kommkz}] {original_bemerkung}', 'kommKz': kommkz, 'online': online }) ``` #### Vorteile: | Vorteil | Beschreibung | |---------|--------------| | ✅ Bidirektional | CREATE/UPDATE/DELETE in beide Richtungen | | ✅ Stabiles Matching | Via Hash in Marker | | ✅ Typ-Erhaltung | kommKz wird gespeichert und wiederverwendet | | ✅ Slot-Wiederverwendung | Gelöschte Einträge werden recycelt | | ✅ Keine EspoCRM-Anpassung | Nutzt Standard emailAddressData/phoneNumberData | | ✅ User-Bemerkung | Bleibt erhalten nach Marker | | ✅ Minimaler Typ-Verlust | Top-Level Felder verbessern Typ-Erkennung | #### Einschränkungen: | Einschränkung | Impact | Mitigation | |---------------|--------|-----------| | ⚠️ Typ-Info teilweise verloren | Mehrere Telefone → alle TelGesch | Top-Level Matching minimiert Problem | | ⚠️ bemerkung wird modifiziert | Marker im Feld sichtbar | Am Ende anfügen, prefix erkennbar | | ⚠️ Leere Slots | Sammeln sich an | Periodischer Cleanup-Job | | ⚠️ Hash-Kollisionen | Theoretisch möglich | SHA256[:8] = 1:16 Millionen | --- ## Option A: One-Way Sync (Advoware → EspoCRM) ⭐ EINFACHSTE LÖSUNG **Prinzip**: Advoware ist Master, EspoCRM ist Read-Only Viewer **Implementierung**: ```python async def sync_kommunikation_one_way(betnr: int, bet_id: str): """ Komplett-Überschreibung: Alle Kommunikationen von Advoware → EspoCRM Keine Change Detection, kein Matching - einfach überschreiben """ # 1. Hole Advoware Kommunikationen advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] advo_komm = advo_data.get('kommunikation', []) # 2. Konvertiere ALLE zu EspoCRM Format emails = [] phones = [] for k in advo_komm: kommkz = k.get('kommKz', 0) wert = k.get('tlf', '').strip() if not wert: continue if kommkz in [4, 8, 11, 12]: # Email-Typen emails.append({ 'emailAddress': wert, 'lower': wert.lower(), 'primary': k.get('online', False), 'optOut': False, 'invalid': False }) elif kommkz in [1, 2, 3, 6, 7, 9, 10]: # Phone-Typen type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'} phones.append({ 'phoneNumber': wert, 'type': type_map.get(kommkz, 'Other'), 'primary': k.get('online', False), 'optOut': False, 'invalid': False }) # 3. KOMPLETT ÜBERSCHREIBEN (kein Merge!) await espo.update_entity('CBeteiligte', bet_id, { 'emailAddressData': emails, 'phoneNumberData': phones }) context.logger.info(f"One-Way Sync: {len(emails)} emails, {len(phones)} phones") ``` **Vorteile**: - ✅ Sehr einfach (50 Zeilen Code) - ✅ Kein Matching nötig - ✅ Keine Inkonsistenzen möglich - ✅ Change Detection via Advoware rowId reicht **Nachteile**: - ❌ EspoCRM-Änderungen gehen verloren - ❌ Nicht bidirektional - ❌ User kann in EspoCRM nichts bearbeiten **Geeignet wenn**: - Advoware ist primäres System - EspoCRM nur als Ansicht genutzt wird - Keine Bearbeitung in EspoCRM gewünscht --- ## Option B: Wert-basiertes Matching mit Smart-Merge ⭐ BESTE BALANCE **Prinzip**: Matching via emailAddress/phoneNumber + intelligentes Merging **Implementierung**: ```python async def sync_kommunikation_value_based(betnr: int, bet_id: str): """ Wert-basiertes Matching mit Smart-Merge - Advoware-Einträge werden gematched und aktualisiert - EspoCRM-eigene Einträge bleiben erhalten - Bei Duplikaten: Advoware gewinnt """ # 1. Hole beide Seiten advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] advo_komm = advo_data.get('kommunikation', []) espo_entity = await espo.get_entity('CBeteiligte', bet_id) espo_emails_current = espo_entity.get('emailAddressData', []) espo_phones_current = espo_entity.get('phoneNumberData', []) # 2. Konvertiere Advoware advo_emails = {} # {emailAddress: data} advo_phones = {} # {phoneNumber: data} for k in advo_komm: kommkz = k.get('kommKz', 0) wert = k.get('tlf', '').strip() if not wert: continue if kommkz in [4, 8, 11, 12]: advo_emails[wert] = { 'emailAddress': wert, 'lower': wert.lower(), 'primary': k.get('online', False), 'optOut': False, 'invalid': False } elif kommkz in [1, 2, 3, 6, 7, 9, 10]: type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'} advo_phones[wert] = { 'phoneNumber': wert, 'type': type_map.get(kommkz, 'Other'), 'primary': k.get('online', False), 'optOut': False, 'invalid': False } # 3. Smart-Merge: Advoware + nur nicht-existierende EspoCRM-Einträge merged_emails = list(advo_emails.values()) merged_phones = list(advo_phones.values()) # Füge EspoCRM-Einträge hinzu die NICHT in Advoware sind for espo_email in espo_emails_current: if espo_email['emailAddress'] not in advo_emails: merged_emails.append(espo_email) for espo_phone in espo_phones_current: if espo_phone['phoneNumber'] not in advo_phones: merged_phones.append(espo_phone) # 4. Update await espo.update_entity('CBeteiligte', bet_id, { 'emailAddressData': merged_emails, 'phoneNumberData': merged_phones }) context.logger.info( f"Smart-Merge: {len(advo_emails)} Advoware emails, " f"{len(merged_emails) - len(advo_emails)} EspoCRM-only emails retained" ) ``` **Vorteile**: - ✅ Einfach zu implementieren (80 Zeilen) - ✅ EspoCRM-eigene Einträge bleiben erhalten - ✅ Teilweise bidirektional (neue Einträge von EspoCRM bleiben) - ✅ Change Detection via rowId **Nachteile**: - ⚠️ EspoCRM-Änderungen an Advoware-Einträgen gehen verloren - ⚠️ Bei Wert-Änderung in Advoware: Duplikat entsteht - ⚠️ Kein echter bidirektionaler Sync **Geeignet wenn**: - Advoware ist primär, aber EspoCRM kann ergänzen - User können in EspoCRM zusätzliche Kontakte hinzufügen - Advoware-Einträge sollen nicht in EspoCRM geändert werden --- ## Option C: Array-Level Change Detection ⭐ FÜR KOMPLEXERE LOGIK **Prinzip**: Speichere Hash des kompletten Arrays, bei Änderung: Analyse **Implementierung**: ```python import hashlib import json def calculate_array_hash(data: list) -> str: """Berechnet Hash für emailAddressData/phoneNumberData""" # Sortiere und normalisiere für stabilen Hash normalized = sorted([ {k: v for k, v in item.items() if k != 'lower'} # 'lower' ist redundant for item in data ], key=lambda x: x.get('emailAddress') or x.get('phoneNumber')) return hashlib.sha256( json.dumps(normalized, sort_keys=True).encode() ).hexdigest()[:16] async def detect_kommunikation_changes(bet_id: str): """Erkennt ob emailAddressData/phoneNumberData geändert wurden""" # Hole aktuelle Daten entity = await espo.get_entity('CBeteiligte', bet_id) current_emails = entity.get('emailAddressData', []) current_phones = entity.get('phoneNumberData', []) # Berechne Hashes current_email_hash = calculate_array_hash(current_emails) current_phone_hash = calculate_array_hash(current_phones) # Hole gespeicherte Hashes aus Redis/DB stored_hashes = await get_kommunikation_hashes(bet_id) changes = { 'emails_changed': current_email_hash != stored_hashes.get('email_hash'), 'phones_changed': current_phone_hash != stored_hashes.get('phone_hash'), 'current_email_hash': current_email_hash, 'current_phone_hash': current_phone_hash } if changes['emails_changed'] or changes['phones_changed']: context.logger.info(f"Kommunikation changed for {bet_id}") # Analysiere WAS geändert wurde changes['added_emails'] = find_added_items( stored_hashes.get('emails', []), current_emails, 'emailAddress' ) changes['removed_emails'] = find_removed_items( stored_hashes.get('emails', []), current_emails, 'emailAddress' ) # Speichere neue Hashes await store_kommunikation_hashes(bet_id, { 'email_hash': current_email_hash, 'phone_hash': current_phone_hash, 'emails': current_emails, 'phones': current_phones }) return changes def find_added_items(old_list: list, new_list: list, key: str) -> list: """Findet hinzugefügte Einträge""" old_values = {item[key] for item in old_list} return [item for item in new_list if item[key] not in old_values] def find_removed_items(old_list: list, new_list: list, key: str) -> list: """Findet entfernte Einträge""" new_values = {item[key] for item in new_list} return [item for item in old_list if item[key] not in new_values] ``` **Vorteile**: - ✅ Erkennt granulare Änderungen (added/removed/modified) - ✅ Kann intelligente Sync-Entscheidungen treffen - ✅ Ermöglicht Konflikt-Handling **Nachteile**: - ⚠️ Komplexer (150+ Zeilen) - ⚠️ Speichert Kopie der Daten (für Diff) - ⚠️ Immer noch wert-basiertes Matching **Geeignet wenn**: - Granulare Change Detection gewünscht - Konflikt-Handling wichtig - Bereit für höhere Komplexität --- ## Empfehlung **Für schnelle Implementation**: ✅ **Option A** (One-Way Sync) - 50 Zeilen Code - In 1 Stunde implementiert - Deckt 80% der Use-Cases ab **Für Produktiv-Einsatz**: ✅ **Option B** (Smart-Merge) - 80 Zeilen Code - Beste Balance zwischen Einfachheit und Flexibilität - EspoCRM-User können ergänzen #### Struktur des Custom Fields ```json { "kommunikationMapping": { "emails": [ { "emailAddress": "max@example.com", "advowareId": 149331, "advowareRowId": "eXqf+gAAAAAAAAA=", "lastSync": "2026-02-08T10:30:00Z" }, { "emailAddress": "info@company.com", "advowareId": 149332, "advowareRowId": "eXqf+gAAAAAAAAB=", "lastSync": "2026-02-08T10:30:00Z" } ], "phones": [ { "phoneNumber": "+49 511 12345", "advowareId": 149333, "advowareRowId": "eXqf+gAAAAAAAAC=", "lastSync": "2026-02-08T10:30:00Z" } ] } } ``` #### EspoCRM Custom Field Konfiguration **Feld-Definition** (in EspoCRM Admin → Entity Manager → CBeteiligte → Fields): - **Name**: `kommunikationMapping` - **Type**: `Text` (oder `Wysiwyg` falls UI wichtig) - **Label**: `Kommunikation Sync Mapping` (wird nicht im UI angezeigt) - **Tooltip**: `Mapping von Advoware Kommunikations-IDs (automatisch verwaltet)` - **Read-Only**: ✅ Yes (User soll nicht editieren) - **Hidden in Detail**: ✅ Yes (nicht sichtbar) #### Matching-Algorithmus ```python async def match_email_with_advoware(email_address: str, bet_id: str) -> Optional[dict]: """ Findet Advoware-Kommunikation für eine Email-Adresse Returns: {"advowareId": 123, "advowareRowId": "ABC"} oder None """ # Hole Mapping aus EspoCRM entity = await espo.get_entity('CBeteiligte', bet_id) mapping_json = entity.get('kommunikationMapping') if not mapping_json: return None mapping = json.loads(mapping_json) if isinstance(mapping_json, str) else mapping_json # Suche Email for email_entry in mapping.get('emails', []): if email_entry['emailAddress'] == email_address: return { 'advowareId': email_entry['advowareId'], 'advowareRowId': email_entry['advowareRowId'] } return None async def update_kommunikation_mapping(bet_id: str, betnr: int): """ Aktualisiert das Mapping basierend auf aktuellen Advoware-Daten Wird aufgerufen: - Nach jedem Advoware → EspoCRM Sync - Bei Beteiligte-Webhook """ # Hole Advoware Kommunikationen advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] advo_komm = advo_data.get('kommunikation', []) # Baue Mapping mapping = { 'emails': [], 'phones': [] } for k in advo_komm: kommkz = k.get('kommKz', 0) wert = k.get('tlf', '').strip() if not wert: continue entry = { 'advowareId': k.get('id'), 'advowareRowId': k.get('rowId'), 'lastSync': datetime.now().isoformat() } # Email-Typen if kommkz in [4, 8, 11, 12]: entry['emailAddress'] = wert mapping['emails'].append(entry) # Phone-Typen elif kommkz in [1, 2, 3, 6, 7, 9, 10]: entry['phoneNumber'] = wert mapping['phones'].append(entry) # Speichere Mapping await espo.update_entity('CBeteiligte', bet_id, { 'kommunikationMapping': json.dumps(mapping, ensure_ascii=False) }) ``` #### Sync-Ablauf mit Mapping **Advoware → EspoCRM** (Webhook-getriggert): ```python async def sync_kommunikation_from_advoware(betnr: int, bet_id: str): """Vollständiger Sync mit Mapping-Update""" # 1. Hole Advoware Daten advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] advo_komm = advo_data.get('kommunikation', []) # 2. Konvertiere zu EspoCRM Format emails = [] phones = [] mapping = {'emails': [], 'phones': []} for k in advo_komm: kommkz = k.get('kommKz', 0) wert = k.get('tlf', '').strip() if not wert: continue # Email if kommkz in [4, 8, 11, 12]: emails.append({ 'emailAddress': wert, 'lower': wert.lower(), 'primary': k.get('online', False), 'optOut': False, 'invalid': False }) mapping['emails'].append({ 'emailAddress': wert, 'advowareId': k.get('id'), 'advowareRowId': k.get('rowId'), 'lastSync': datetime.now().isoformat() }) # Phone elif kommkz in [1, 2, 3, 6, 7, 9, 10]: type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'} phones.append({ 'phoneNumber': wert, 'type': type_map.get(kommkz, 'Other'), 'primary': k.get('online', False), 'optOut': False, 'invalid': False }) mapping['phones'].append({ 'phoneNumber': wert, 'advowareId': k.get('id'), 'advowareRowId': k.get('rowId'), 'lastSync': datetime.now().isoformat() }) # 3. Update EspoCRM (Daten + Mapping) await espo.update_entity('CBeteiligte', bet_id, { 'emailAddressData': emails, 'phoneNumberData': phones, 'kommunikationMapping': json.dumps(mapping, ensure_ascii=False) }) ``` **EspoCRM → Advoware** (Change Detection): ```python async def sync_kommunikation_to_advoware(bet_id: str, betnr: int): """ Synchronisiert Änderungen von EspoCRM zu Advoware Wird aufgerufen bei: - EspoCRM-Webhook (CBeteiligte UPDATE) - Change Detection erkennt emailAddressData/phoneNumberData Änderung """ # Hole EspoCRM Daten entity = await espo.get_entity('CBeteiligte', bet_id) current_emails = entity.get('emailAddressData', []) current_phones = entity.get('phoneNumberData', []) # Hole Mapping mapping_json = entity.get('kommunikationMapping', '{}') mapping = json.loads(mapping_json) if isinstance(mapping_json, str) else mapping_json # Verarbeite Emails for email in current_emails: email_addr = email['emailAddress'] # Finde im Mapping advo_info = next((e for e in mapping.get('emails', []) if e['emailAddress'] == email_addr), None) if advo_info: # UPDATE in Advoware advo_id = advo_info['advowareId'] await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advo_id}', method='PUT', data={ 'kommKz': 4, # Von gespeichertem Typ (via separate Logik) 'tlf': email_addr, 'bemerkung': '', 'online': email.get('primary', False) } ) else: # CREATE in Advoware (neue Email) result = await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen', method='POST', data={ 'kommKz': 4, # MailGesch 'tlf': email_addr, 'bemerkung': 'Von EspoCRM erstellt', 'online': email.get('primary', False) } ) # Update Mapping created = result[0] if isinstance(result, list) else result mapping.setdefault('emails', []).append({ 'emailAddress': email_addr, 'advowareId': created['id'], 'advowareRowId': created['rowId'], 'lastSync': datetime.now().isoformat() }) # Erkenne GELÖSCHTE Emails (in Mapping aber nicht in current_emails) current_email_addrs = {e['emailAddress'] for e in current_emails} for mapped_email in mapping.get('emails', []): if mapped_email['emailAddress'] not in current_email_addrs: # Email wurde in EspoCRM gelöscht → DELETE in Advoware advo_id = mapped_email['advowareId'] try: await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advo_id}', method='DELETE' ) except Exception as e: if '403' in str(e): # DELETE nicht erlaubt → Notification await notification_manager.notify_manual_action_required( entity_type='CBeteiligte', entity_id=bet_id, action_type='delete_not_supported', details={ 'message': 'Kommunikation kann nicht gelöscht werden', 'advoware_id': advo_id, 'email': mapped_email['emailAddress'] } ) # Update Mapping await espo.update_entity('CBeteiligte', bet_id, { 'kommunikationMapping': json.dumps(mapping, ensure_ascii=False) }) ``` #### Vorteile der Custom Field Lösung | Aspekt | Lösung | |--------|---------| | **Stabiles Matching** | ✅ Via advowareId (nicht abhängig vom Wert) | | **Change Detection** | ✅ Via advowareRowId | | **Bidirektional** | ✅ Vollständig (CREATE/UPDATE/DELETE) | | **Wert-Änderungen** | ✅ Kein Problem (Matching via ID) | | **DELETE Detection** | ✅ Möglich (Vergleich Mapping vs. current) | | **Typ-Tracking** | ✅ Via separates Feld oder Ableitung | | **Implementation** | ⚠️ Erfordert Custom Field in EspoCRM | #### Nachteile & Mitigations | Nachteil | Mitigation | |----------|-----------| | Custom Field nötig | Einmaliges Setup in EspoCRM Admin | | Daten-Duplikation | Akzeptabel (Mapping ist klein) | | Inkonsistenz möglich | Auto-Rebuild bei jedem Advoware-Sync | | User könnte löschen | Field als readOnly + hidden markieren | ### 4.4 Alternative: Wert-basiertes Matching (Fallback) Falls Custom Field NICHT gewünscht, gibt es einen einfacheren Ansatz ohne IDs: **Hybrid-Strategie ohne Mapping**: - Matching via `emailAddress`/`phoneNumber` Wert - Bei Wert-Änderung: DELETE + CREATE (kein UPDATE) - Keine DELETE-Detection möglich - Nur One-Way: Advoware → EspoCRM Siehe [Abschnitt 5.1](#51-advoware--espocrm-webhook-getriggert) für Details. #### 1. Advoware → EspoCRM (primary=true) Alle Advoware-Kommunikationen werden mit **primary=true** markiert (geschützt): ```python # Sync-Ablauf advoware_emails = get_advoware_kommunikation(betnr, types=[4, 8, 11, 12]) espocrm_emails_current = get_espocrm_entity(bet_id)['emailAddressData'] # Trenne primary (Advoware) von non-primary (EspoCRM-only) espocrm_secondary = [e for e in espocrm_emails_current if not e.get('primary')] # Konvertiere Advoware zu EspoCRM Format advoware_as_espocrm = [ { 'emailAddress': k['tlf'], 'lower': k['tlf'].lower(), 'primary': True, # IMMER true für Advoware 'optOut': False, 'invalid': False } for k in advoware_emails ] # Merge: Advoware (primary) + EspoCRM (secondary) merged = advoware_as_espocrm + espocrm_secondary # Update CBeteiligte await espo.update_entity('CBeteiligte', bet_id, { 'emailAddressData': merged }) ``` **Vorteile**: - ✅ Advoware behält vollständige Kontrolle - ✅ EspoCRM kann eigene Einträge ergänzen (primary=false) - ✅ Kein Datenverlust - ✅ Nutzt Standard-EspoCRM-Felder #### 2. EspoCRM → Advoware (NUR primary=false) Nur EspoCRM-eigene Einträge (primary=false) werden **NICHT** zu Advoware synchronisiert: ```python # Bei EspoCRM-Webhook: Prüfe primary-Flag for email in espocrm_entity['emailAddressData']: if email.get('primary'): # Von Advoware → IGNORIEREN (wird via Advoware-Webhook synchronisiert) continue else: # EspoCRM-eigener Eintrag → Behalten (nur in EspoCRM) pass ``` #### 3. Change Detection - **Advoware**: Via `rowId` (wie bei Adressen/Bankverbindungen) - **EspoCRM**: Keine Change Detection für primary=false Einträge - **Advoware ist Master** für alle primary=true Einträge #### 4. Wert-Änderungen (Edge Case) **Szenario**: Email/Phone ändert in Advoware ``` Vorher: max@old.com (Advoware ID=123, rowId=ABC) Nachher: max@new.com (Advoware ID=123, rowId=XYZ) # rowId ändert! ``` **Problem**: Matching via Wert findet `max@old.com` nicht mehr **Verhalten**: 1. Sync erkennt rowId-Änderung von Advoware-Eintrag 123 2. Sucht `max@new.com` in EspoCRM → nicht gefunden 3. Fügt `max@new.com` mit primary=true hinzu 4. `max@old.com` bleibt mit primary=false erhalten (!) **Ergebnis**: Temporäres Duplikat **Cleanup**: - Option A: User löscht manuell in EspoCRM - Option B: Automatisches Cleanup von verwaisten primary=false Einträgen mit alten Advoware-Pattern ### 4.4 Akzeptierte Einschränkungen | Einschränkung | Impact | Mitigation | |---------------|--------|-----------| | ❌ Kein ID-Feld | Matching via Wert fragil | primary-Flag trennt Advoware/EspoCRM | | ❌ Wert-Änderung → Duplikat | User sieht alte+neue Adresse | Manueller Cleanup oder Auto-Cleanup-Job | | ❌ bemerkung geht verloren | Notizen nicht in EspoCRM | Akzeptiert (EspoCRM hat kein Feld) | | ❌ kommKz unlesbar (Bug) | Typ-Info verloren | Irrelevant (Typ ergibt sich aus Array) | | ❌ Internet-Typ fehlt | URLs nicht sync-bar | Akzeptiert (11/12 Typen OK) | ### 4.5 EspoCRM → Advoware Mapping (Optional) ```python { 'kommKz': map_enum(espo['kommunikationstyp']), # 1-12 'tlf': espo['wert'], # "+49 511..." 'bemerkung': espo['bemerkung'], # Notiz 'online': espo['isOnline'] # Boolean } ``` **Enum-Mapping**: ```python ESPO_TO_ADVO_KOMMKZ = { 'TelGesch': 1, 'FaxGesch': 2, 'Mobil': 3, 'MailGesch': 4, 'Internet': 5, 'TelPrivat': 6, 'FaxPrivat': 7, 'MailPrivat': 8, 'AutoTelefon': 9, 'Sonstige': 10, 'EPost': 11, 'Bea': 12 } ``` ### 4.2 EspoCRM → Advoware (UPDATE) ```python { # kommKz NICHT ÄNDERBAR! 'kommKz': current_advo['kommKz'], # Verwende aktuellen Wert 'tlf': espo['wert'], # ÄNDERBAR 'bemerkung': espo['bemerkung'], # ÄNDERBAR 'online': espo['isOnline'] # ÄNDERBAR } ``` **Wichtig**: - `kommKz` MUSS im Request enthalten sein (API-Validierung) - Aber Wert wird ignoriert - immer aktuellen Wert verwenden! ### 4.3 Advoware → EspoCRM ```python { 'name': f"{map_enum_reverse(advo['kommKz'])}: {advo['tlf'][:30]}", 'kommunikationstyp': map_enum_reverse(advo['kommKz']), 'wert': advo['tlf'], 'bemerkung': advo['bemerkung'], 'isOnline': advo['online'], 'advowareId': advo['id'], 'advowareRowId': advo['rowId'] } ``` **Enum-Mapping (Reverse)**: ```python ADVO_TO_ESPO_KOMMKZ = { 1: 'TelGesch', 2: 'FaxGesch', 3: 'Mobil', 4: 'MailGesch', 5: 'Internet', 6: 'TelPrivat', 7: 'FaxPrivat', 8: 'MailPrivat', 9: 'AutoTelefon', 10: 'Sonstige', 11: 'EPost', 12: 'Bea' } ``` ### 4.4 READ-ONLY Felder Detection ```python def detect_readonly_changes(espo_entity, advo_entity): """Prüft ob READ-ONLY Felder geändert wurden""" espo_kommkz = ESPO_TO_ADVO_KOMMKZ.get(espo_entity['kommunikationstyp']) advo_kommkz = advo_entity['kommKz'] if espo_kommkz != advo_kommkz: return { 'readonly_fields': ['kommunikationstyp'], 'espo_value': espo_entity['kommunikationstyp'], 'advo_value': ADVO_TO_ESPO_KOMMKZ[advo_kommkz] } return None ``` --- ## 5. Sync-Strategie **Entscheidung**: Integration in Beteiligte-Sync (kein separates CKommunikation Entity) ### 5.1 Advoware → EspoCRM (Webhook-getriggert) ```python async def sync_kommunikation_to_espocrm(betnr: int, bet_id: str): """ Synchronisiert Advoware Kommunikationen zu EspoCRM als Teil von CBeteiligte Wird getriggert von: - Beteiligte-Webhook (wenn rowId von Kommunikationen ändert) - Kann auch manuell aufgerufen werden """ # 1. Hole Advoware Beteiligte (inkl. Kommunikationen) advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] # API gibt Liste zurück advo_komm = advo_data.get('kommunikation', []) # 2. Hole aktuelle EspoCRM emailAddressData/phoneNumberData espo_entity = await espocrm.get_entity('CBeteiligte', bet_id) espo_emails_current = espo_entity.get('emailAddressData', []) espo_phones_current = espo_entity.get('phoneNumberData', []) # 3. Konvertiere Advoware zu EspoCRM Format advo_as_emails = [] advo_as_phones = [] for k in advo_komm: kommkz = k.get('kommKz', 0) wert = k.get('tlf', '').strip() if not wert: continue # Skip leere Einträge # Email-Typen: 4=MailGesch, 8=MailPrivat, 11=EPost, 12=Bea if kommkz in [4, 8, 11, 12]: advo_as_emails.append({ 'emailAddress': wert, 'lower': wert.lower(), 'primary': True, # Markiere als Advoware-Eintrag 'optOut': False, 'invalid': False }) # Phone-Typen: 1,2,3,6,7,9,10 (alle außer 4,5,8,11,12) elif kommkz in [1, 2, 3, 6, 7, 9, 10]: type_map = { 1: 'Office', # TelGesch 2: 'Fax', # FaxGesch 3: 'Mobile', # Mobil 6: 'Home', # TelPrivat 7: 'Fax', # FaxPrivat 9: 'Mobile', # AutoTelefon 10: 'Other' # Sonstige } advo_as_phones.append({ 'phoneNumber': wert, 'type': type_map.get(kommkz, 'Other'), 'primary': True, # Markiere als Advoware-Eintrag 'optOut': False, 'invalid': False }) # kommKz=5 (Internet) wird übersprungen (nicht unterstützt) # 4. Behalte EspoCRM-eigene Einträge (primary=false) espo_secondary_emails = [e for e in espo_emails_current if not e.get('primary', False)] espo_secondary_phones = [p for p in espo_phones_current if not p.get('primary', False)] # 5. Merge: Advoware (primary) + EspoCRM (secondary) merged_emails = advo_as_emails + espo_secondary_emails merged_phones = advo_as_phones + espo_secondary_phones # 6. Update CBeteiligte update_data = { 'emailAddressData': merged_emails, 'phoneNumberData': merged_phones } await espocrm.update_entity('CBeteiligte', bet_id, update_data) context.logger.info( f"Kommunikation synced: {len(advo_as_emails)} emails, " f"{len(advo_as_phones)} phones from Advoware + " f"{len(espo_secondary_emails)} EspoCRM emails, " f"{len(espo_secondary_phones)} EspoCRM phones" ) ``` **Wichtig**: - Alle Advoware-Einträge haben `primary=true` - EspoCRM-eigene Einträge haben `primary=false` und bleiben erhalten - Bei jedem Sync werden Advoware-Einträge komplett überschrieben ### 5.2 Change Detection ```python async def handle_beteiligte_webhook(webhook_data): """ Webhook von Advoware bei Beteiligte-Änderung Prüft ob Kommunikationen geändert wurden via rowId """ betnr = webhook_data['beteiligterId'] # Hole aktuelle Advoware-Daten advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}') advo_data = advo_entity[0] advo_komm = advo_data.get('kommunikation', []) # Hole gespeicherte rowIds aus Redis/DB stored_row_ids = await get_stored_kommunikation_rowids(betnr) current_row_ids = [k.get('rowId') for k in advo_komm if k.get('rowId')] # Vergleiche if set(current_row_ids) != set(stored_row_ids): context.logger.info(f"Kommunikation changed for BetNr {betnr}") # Sync zu EspoCRM bet_id = await get_espocrm_id_for_betnr(betnr) await sync_kommunikation_to_espocrm(betnr, bet_id) # Update gespeicherte rowIds await store_kommunikation_rowids(betnr, current_row_ids) else: context.logger.debug(f"No kommunikation changes for BetNr {betnr}") ``` ### 5.3 EspoCRM → Advoware (Optional, nicht empfohlen) **Entscheidung**: EspoCRM-eigene Einträge (primary=false) werden **NICHT** zu Advoware synchronisiert. **Begründung**: - EspoCRM kann keine Advoware-IDs speichern (kein custom field in Arrays) - Matching via Wert ist fragil (bei Änderung) - Konflikt-Handling komplex - User-Story: EspoCRM als "Viewer" mit optionalen Ergänzungen **Alternative** (falls gewünscht): One-Shot-Import ```python async def import_espocrm_kommunikation_to_advoware(bet_id: str, betnr: int): """ Einmaliger Import von EspoCRM → Advoware NUR für primary=false Einträge (EspoCRM-eigene) User muss manuell triggern """ espo_entity = await espocrm.get_entity('CBeteiligte', bet_id) # Nur non-primary Einträge to_import_emails = [e for e in espo_entity.get('emailAddressData', []) if not e.get('primary', False)] to_import_phones = [p for p in espo_entity.get('phoneNumberData', []) if not p.get('primary', False)] for email in to_import_emails: # Erstelle in Advoware await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen', method='POST', data={ 'kommKz': 4, # MailGesch 'tlf': email['emailAddress'], 'bemerkung': 'Importiert aus EspoCRM', 'online': email.get('primary', False) } ) # Danach: Setze primary=true (jetzt von Advoware kontrolliert) await resync_kommunikation_to_espocrm(betnr, bet_id) ``` ```python async def create_kommunikation(espo_entity, betnr): """Erstellt neue Kommunikation in Advoware""" # 1. Mappe ALLE Felder advo_data = { 'kommKz': ESPO_TO_ADVO_KOMMKZ[espo_entity['kommunikationstyp']], 'tlf': espo_entity['wert'], 'bemerkung': espo_entity['bemerkung'], 'online': espo_entity['isOnline'] } # 2. POST zu Advoware result = await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen', method='POST', data=advo_data ) # 3. Extrahiere ID und rowId created = result[0] if isinstance(result, list) else result # 4. Update EspoCRM mit Advoware-IDs await espocrm.update_entity('CKommunikation', espo_entity['id'], { 'advowareId': created['id'], 'advowareRowId': created['rowId'], 'syncStatus': 'clean', 'advowareLastSync': datetime.now() }) ``` ### 5.2 UPDATE (EspoCRM → Advoware) ```python async def update_kommunikation(espo_entity, betnr): """Update Kommunikation (nur R/W Felder)""" advoware_id = espo_entity['advowareId'] # WICHTIG: kommKz kann NICHT via GET gelesen werden (Bug: immer 0) # → Verwende gespeicherten Wert aus EspoCRM stored_kommkz = ESPO_TO_ADVO_KOMMKZ.get(espo_entity['kommunikationstyp']) # 1. Check ob kommKz in EspoCRM geändert wurde if stored_kommkz != espo_entity.get('originalKommKz'): # Typ wurde in EspoCRM geändert → Notification await notification_manager.notify_manual_action_required( entity_type='CKommunikation', entity_id=espo_entity['id'], action_type='readonly_field_conflict', details={ 'readonly_fields': ['kommunikationstyp'], 'message': 'Kommunikationstyp kann nicht geändert werden', 'description': ( f"Der Kommunikationstyp (kommKz) ist READ-ONLY in Advoware.\n\n" f"**Aktuelle Situation:**\n" f"- Ursprungstyp: {espo_entity.get('originalKommKz')}\n" f"- Neuer Typ: {espo_entity['kommunikationstyp']}\n\n" f"**Workaround:**\n" f"1. Löschen Sie die Kommunikation in EspoCRM\n" f"2. Erstellen Sie sie neu mit dem gewünschten Typ\n" f"3. Die neue Kommunikation wird automatisch nach Advoware synchronisiert" ), 'advoware_id': advoware_id, 'betnr': betnr }, create_task=True ) return # 2. Update nur R/W Felder advo_data = { 'kommKz': stored_kommkz, # WICHTIG: Verwende gespeicherten Wert! 'tlf': espo_entity['wert'], 'bemerkung': espo_entity['bemerkung'], 'online': espo_entity['isOnline'] } # 3. PUT zu Advoware result = await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advoware_id}', method='PUT', data=advo_data ) # 4. Update rowId in EspoCRM await espocrm.update_entity('CKommunikation', espo_entity['id'], { 'advowareRowId': result['rowId'], 'syncStatus': 'clean', 'advowareLastSync': datetime.now() }) ``` **Wichtige Änderungen gegenüber Standard-Pattern**: - ⚠️ **Kein GET vor PUT**: kommKz ist in GET nicht lesbar (Bug: immer 0) - ✅ **EspoCRM als Source of Truth**: Verwende gespeicherten kommKz-Wert - ✅ **originalKommKz Feld**: Speichere ursprünglichen Typ für Änderungserkennung ### 5.3 DELETE - Notification Strategy ```python async def handle_kommunikation_deletion(espo_entity, betnr): """DELETE nicht möglich - Notification für manuelle Löschung""" advoware_id = espo_entity['advowareId'] await notification_manager.notify_manual_action_required( entity_type='CKommunikation', entity_id=espo_entity['id'], action_type='delete_not_supported', details={ 'message': f'DELETE erforderlich für Kommunikation: {espo_entity["name"]}', 'description': ( f"Die Advoware API unterstützt keine Löschungen für Kommunikationen.\n\n" f"**Bitte manuell in Advoware löschen:**\n" f"- Typ: {espo_entity['kommunikationstyp']}\n" f"- Wert: {espo_entity['wert']}\n" f"- Beteiligter betNr: {betnr}\n" f"- Advoware ID: {advoware_id}\n\n" f"Die Kommunikation wurde in EspoCRM gelöscht, bleibt aber in Advoware " f"bestehen bis zur manuellen Löschung." ), 'betnr': betnr, 'advoware_id': advoware_id, 'kommunikationstyp': espo_entity['kommunikationstyp'], 'wert': espo_entity['wert'] }, create_task=True ) ``` ### 5.4 SYNC from Advoware ```python async def sync_from_advoware(betnr): """Sync Kommunikationen Advoware → EspoCRM""" # 1. Hole alle Kommunikationen vom Beteiligten beteiligte = await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}', method='GET' ) if isinstance(beteiligte, list): beteiligte = beteiligte[0] advo_kommunikationen = beteiligte.get('kommunikation', []) # 2. Hole CBeteiligte aus EspoCRM espo_beteiligte = await espocrm.list_entities( 'CBeteiligte', filters={'betnr': betnr} ) if not espo_beteiligte: logger.warning(f"Beteiligter {betnr} nicht in EspoCRM gefunden") return beteiligte_id = espo_beteiligte[0]['id'] # 3. Hole bestehende CKommunikation Entities espo_kommunikationen = await espocrm.list_entities( 'CKommunikation', filters={'beteiligteId': beteiligte_id} ) # 4. Matche via advowareId espo_by_advo_id = { k['advowareId']: k for k in espo_kommunikationen if k.get('advowareId') } # 5. Sync jede Advoware-Kommunikation for advo_komm in advo_kommunikationen: advo_id = advo_komm['id'] if advo_id in espo_by_advo_id: # UPDATE bestehende espo_komm = espo_by_advo_id[advo_id] # Check rowId für Änderungen if espo_komm.get('advowareRowId') != advo_komm['rowId']: # Advoware wurde geändert await update_from_advoware(espo_komm, advo_komm) else: # CREATE neue await create_from_advoware(beteiligte_id, advo_komm) ``` --- ## 6. Implementierungsplan ### Phase 1: EspoCRM Entity Setup 1. **Entity erstellen**: `CKommunikation` 2. **Felder definieren**: - `kommunikationstyp` (enum) - `wert` (string) - `bemerkung` (text) - `isOnline` (bool) - `isPrimary` (bool) - `beteiligteId` (link zu CBeteiligte) - Sync-Felder (advowareId, rowId, syncStatus, etc.) 3. **Relationship**: Many-to-One zu CBeteiligte ### Phase 2: Mapper Implementierung 1. **kommunikation_mapper.py**: - `map_ckommunikation_to_advoware_create()` - Alle Felder - `map_ckommunikation_to_advoware_update()` - Nur R/W Felder - `map_advoware_to_ckommunikation()` - Reverse mapping - `detect_readonly_changes()` - kommKz Detection 2. **Enum-Mappings**: - `ESPO_TO_ADVO_KOMMKZ` - `ADVO_TO_ESPO_KOMMKZ` ### Phase 3: Sync Service 1. **kommunikation_sync.py**: - `create_kommunikation()` - POST zu Advoware - `update_kommunikation()` - PUT (nur R/W) - `handle_kommunikation_deletion()` - Notification - `sync_from_advoware()` - Import - `_find_kommunikation_by_advoware_id()` - Matching 2. **NotificationManager Integration**: - `readonly_field_conflict` - kommKz geändert - `delete_not_supported` - Manuelle Löschung ### Phase 4: Webhook Integration 1. **Webhook Endpoints**: - `kommunikation_create_api_step.py` - `kommunikation_update_api_step.py` - `kommunikation_delete_api_step.py` 2. **Event Handler**: - `kommunikation_sync_event_step.py` - Subscribe: `vmh.kommunikation.{create|update|delete}` ### Phase 5: Testing 1. **Unit Tests**: - Mapper-Funktionen - Enum-Conversions - Readonly-Detection 2. **Integration Tests**: - CREATE mit allen kommKz-Typen - UPDATE R/W Felder - UPDATE kommKz → Notification - DELETE → Notification - SYNC from Advoware 3. **End-to-End Tests**: - Webhook → Sync → Advoware - Advoware Änderung → Import - Konfliktauflösung --- ## 📊 Zusammenfassung ### ✅ Erfolgreich getestet - ✅ POST: Alle 4 Felder funktionieren - ✅ GET: Über Beteiligte-Endpoint verfügbar - ✅ PUT: 3 von 4 Feldern änderbar (tlf, bemerkung, online) - ✅ rowId: Ändert sich bei jedem UPDATE (perfekt für Change Detection) ### ❌ Einschränkungen - ❌ kommKz: READ-ONLY bei PUT (Typ kann nicht geändert werden) - ❌ DELETE: 403 Forbidden (wie bei Adressen/Bankverbindungen) ### 💡 Empfohlene Sync-Strategie 1. **CREATE**: Automatisch (alle Felder) 2. **UPDATE**: Automatisch (tlf, bemerkung, online) + Notification bei kommKz-Änderung 3. **DELETE**: Notification für manuelle Löschung 4. **SYNC**: Via advowareId + rowId (wie bei Adressen) ### 🔗 Ähnlichkeiten zu Adressen-Sync - Gleiche Limitationen (kein DELETE) - Teilweise READ-ONLY Felder bei PUT - rowId-basierte Change Detection - advowareId für Matching - NotificationManager für manuelle Interventionen **Die Implementierung kann stark an adressen_sync.py angelehnt werden!** --- ## 5. Implementation Details ### 5.1 Implementierte Module Die Kommunikation-Sync besteht aus 3 Hauptmodulen: #### **services/kommunikation_mapper.py** **Zweck**: Datentyp-Mapping und Marker-Verwaltung **Hauptfunktionen**: - `calculate_hash(value)`: SHA256[:8] für Matching - `parse_marker(bemerkung)`: Extrahiert Marker aus bemerkung - `create_marker(value, kommKz, user_text)`: Erstellt `[ESPOCRM:hash:kommKz]` - `create_slot_marker(kommKz)`: Erstellt `[ESPOCRM-SLOT:kommKz]` - `detect_kommkz(value, beteiligte, bemerkung)`: **4-Stufen Typ-Erkennung** 1. Aus Marker (höchste Priorität) 2. Aus Top-Level Feldern (telGesch, emailGesch, etc.) 3. Aus Wert-Pattern (@ = Email, sonst Phone) 4. Default (MailGesch=4, TelGesch=1) - `advoware_to_espocrm_email()`: Mapping Advoware → EspoCRM Email - `advoware_to_espocrm_phone()`: Mapping Advoware → EspoCRM Phone - `find_matching_advoware()`: Hash-basierte Suche in Advoware - `find_empty_slot()`: Findet wiederverwendbare leere Slots - `should_sync_to_espocrm()`: Filtert leere Slots und ungültige Einträge **Konstanten**: ```python KOMMKZ_TEL_GESCH = 1 KOMMKZ_FAX_GESCH = 2 KOMMKZ_MOBIL = 3 KOMMKZ_MAIL_GESCH = 4 # ... etc (1-12) EMAIL_KOMMKZ = [4, 8, 11, 12] # Mail, MailPrivat, EPost, Bea PHONE_KOMMKZ = [1, 2, 3, 6, 7, 9, 10] # Alle Telefon-Typen KOMMKZ_TO_PHONE_TYPE = { 1: 'Office', # TelGesch 2: 'Fax', # FaxGesch 3: 'Mobile', # Mobil 6: 'Home', # TelPrivat # ... } ``` #### **services/advoware_service.py** **Zweck**: Advoware API-Wrapper für Kommunikation-Operations ```python class AdvowareService: def get_beteiligter(betnr: int) -> Dict: """Lädt Beteiligte mit kommunikation[] array""" def create_kommunikation(betnr: int, data: dict) -> Dict: """POST /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen Required: tlf, kommKz Optional: bemerkung, online """ def update_kommunikation(betnr: int, komm_id: int, data: dict) -> bool: """PUT /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id} Writable: tlf, bemerkung, online READ-ONLY: kommKz """ def delete_kommunikation(betnr: int, komm_id: int) -> bool: """DELETE (aktuell 403 Forbidden) Nicht verwendbar - nutze Empty Slots stattdessen """ ``` #### **services/kommunikation_sync_utils.py** **Zweck**: Bidirektionale Synchronisationslogik ```python class KommunikationSyncManager: def __init__(self, advoware: AdvowareService, espocrm: EspoCrmService): pass # ========== BIDIRECTIONAL ========== def sync_bidirectional(beteiligte_id: str, betnr: int, direction: str): """direction: 'both', 'to_espocrm', 'to_advoware' Returns: Combined results from both directions """ # ========== ADVOWARE → ESPOCRM ========== def sync_advoware_to_espocrm(beteiligte_id: str, betnr: int): """ Lädt Advoware Kommunikationen → Schreibt zu EspoCRM Arrays Schritte: 1. Lade Advoware Beteiligte mit kommunikation[] 2. Filtere: should_sync_to_espocrm() (keine leeren Slots) 3. Erkenne Typ: detect_kommkz() 4. Konvertiere: advoware_to_espocrm_email/phone() 5. Update EspoCRM: emailAddressData[] und phoneNumberData[] Returns: {'emails_synced': int, 'phones_synced': int, 'errors': []} """ # ========== ESPOCRM → ADVOWARE ========== def sync_espocrm_to_advoware(beteiligte_id: str, betnr: int): """ Lädt EspoCRM Arrays → Schreibt zu Advoware Kommunikationen Schritte: 1. Lade beide Seiten 2. Baue Hash-Maps: EspoCRM values ↔ Advoware entries 3. Erkenne Szenarien: - Deleted: In Advoware aber nicht in EspoCRM → Empty Slot - Changed: Hash match, Wert geändert → UPDATE - New: In EspoCRM aber nicht in Advoware → CREATE/REUSE Returns: {'created': int, 'updated': int, 'deleted': int, 'errors': []} """ ``` **Change Detection**: ```python def detect_kommunikation_changes(old_bet: dict, new_bet: dict) -> bool: """ Für Advoware Webhooks Vergleicht rowId arrays: - Anzahl geändert? - rowId Set geändert? """ def detect_espocrm_kommunikation_changes(old_data: dict, new_data: dict) -> bool: """ Für EspoCRM Webhooks Vergleicht Arrays: - emailAddressData count/values - phoneNumberData count/values """ ``` ### 5.2 Integration in Webhook-System Die Kommunikation-Sync wird in den bestehenden Beteiligte-Webhooks integriert: **Advoware Webhook** (bei rowId-Änderung): ```python # In beteiligte_sync_event_handler from services.advoware_service import AdvowareService from services.espocrm import EspoCrmService from services.kommunikation_sync_utils import ( KommunikationSyncManager, detect_kommunikation_changes ) advo_service = AdvowareService() espo_service = EspoCrmService() komm_sync = KommunikationSyncManager(advo_service, espo_service) # Bei Beteiligte-Update old_data = previous_beteiligte_data new_data = current_beteiligte_data if detect_kommunikation_changes(old_data, new_data): logger.info(f"[KOMM] Änderung erkannt für betnr={betnr}") result = komm_sync.sync_bidirectional(bet_id, betnr, direction='to_espocrm') logger.info(f"[KOMM] Sync-Result: {result}") ``` **EspoCRM Webhook** (bei Array-Änderung): ```python # In espocrm_webhook_handler from services.kommunikation_sync_utils import detect_espocrm_kommunikation_changes # Bei CBeteiligte-Update old_data = previous_cbeteiligte_data new_data = current_cbeteiligte_data if detect_espocrm_kommunikation_changes(old_data, new_data): logger.info(f"[KOMM] EspoCRM Änderung erkannt für bet_id={bet_id}") result = komm_sync.sync_bidirectional(bet_id, betnr, direction='to_advoware') logger.info(f"[KOMM] Sync-Result: {result}") ``` ### 5.3 Testing **Test-Scripts** (bereits im Repo): - `scripts/test_kommunikation_api.py`: Vollständige API-Tests (POST/PUT/GET/DELETE) - `scripts/test_kommunikation_kommkz_deep.py`: kommKz-Bug Analyse - `scripts/test_kommart_values.py`: kommArt-Bug Verifikation - `scripts/analyze_beteiligte_endpoint.py`: Top-Level Felder Analyse - `scripts/test_espocrm_kommunikation.py`: EspoCRM Struktur-Tests **Manuelle Tests**: 1. **Szenario 1** - Löschen in EspoCRM: - Lösche Email in EspoCRM - Trigger Webhook → Sync - Verify: Advoware hat Empty Slot `[ESPOCRM-SLOT:4]` 2. **Szenario 2** - Ändern in EspoCRM: - Ändere Email-Wert in EspoCRM - Trigger Webhook → Sync - Verify: Advoware hat neuen Wert + neuen Hash-Marker 3. **Szenario 3** - Neu in EspoCRM: - Füge Email in EspoCRM hinzu - Trigger Webhook → Sync - Verify: Advoware hat neue Kommunikation ODER reused Slot 4. **Szenario 4** - Neu in Advoware: - Erstelle Kommunikation in Advoware - Trigger Webhook → Sync - Verify: EspoCRM hat neue Email + Advoware hat Marker --- ## 6. Base64-Implementation (Ersetzt Hash-Strategie) ✅ ### 6.1 Problem: Hash ist nicht rückrechenbar **Kritisches Problem der Hash-Strategie**: ```python # Szenario: User ändert Wert in Advoware old_value = "old@example.com" old_hash = calculate_hash(old_value) # abc12345 # Marker in Advoware bemerkung: [ESPOCRM:abc12345:4] # EspoCRM hat: old@example.com (mit Hash abc12345) # USER ÄNDERT in Advoware: new_value = "new@example.com" new_hash = calculate_hash(new_value) # xyz78901 # Sync-Problem: # - Advoware Marker: [ESPOCRM:abc12345:4] (alter Hash!) # - EspoCRM sucht: xyz78901 (neuer Hash) # - Result: ❌ KEIN MATCH! Kann old@example.com nicht finden ``` **Konsequenz**: Hash-basiertes Matching funktioniert nur **einseitig** (EspoCRM → Advoware). ### 6.2 Lösung: Base64-Encoding ✅ **Brillante Idee**: Speichere den **tatsächlichen Wert** (Base64-kodiert) statt Hash! ```python # Base64-Strategie old_value = "old@example.com" encoded = encode_value(old_value) # b2xkQGV4YW1wbGUuY29t # Marker in Advoware: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4] # USER ÄNDERT in Advoware: new_value = "new@example.com" # Sync-Erfolg: # - Marker enthält: b2xkQGV4YW1wbGUuY29t # - Dekodiert zu: "old@example.com" ✅ # - Findet Match in EspoCRM! # - Updated EspoCRM + Marker mit neuem Base64-Wert ``` **Vorteile**: - ✅ **Bidirektional**: Matching in beide Richtungen - ✅ **Selbstheilend**: Automatische Marker-Updates bei Wert-Änderungen - ✅ **Escaping**: Base64 löst `:` und `]` Probleme - ✅ **Kompakt**: URL-safe Base64 ist kurz genug für bemerkung-Feld ### 6.3 Implementation **Encoding/Decoding**: ```python import base64 def encode_value(value: str) -> str: """Base64 URL-safe encoding""" if not value: return '' return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=') def decode_value(encoded: str) -> str: """Base64 decoding mit padding""" if not encoded: return '' padding = 4 - (len(encoded) % 4) if padding and padding != 4: encoded += '=' * padding return base64.urlsafe_b64decode(encoded).decode('utf-8') ``` **Marker-Functions**: ```python def create_marker(value: str, kommkz: int, user_text: str = '') -> str: """Erstellt Marker mit Base64-Wert""" encoded = encode_value(value) suffix = f" {user_text}" if user_text else "" return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}" def parse_marker(bemerkung: str) -> Optional[Dict]: """Parse Marker und dekodiere Wert""" pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)' match = re.match(pattern, bemerkung) if not match: return None encoded_value = match.group(1) synced_value = decode_value(encoded_value) # Dekodiert! return { 'synced_value': synced_value, # Original-Wert 'kommKz': int(match.group(2)), 'is_slot': False, 'user_text': match.group(3).strip() } ``` **Bidirektionales Matching**: ```python def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]: """Findet Advoware-Eintrag für EspoCRM-Wert""" for komm in advo_kommunikationen: bemerkung = komm.get('bemerkung') or '' marker = parse_marker(bemerkung) if marker and marker['synced_value'] == espo_value: return komm # Match via dekodiertem Wert! ✅ return None ``` ### 6.4 Test-Ergebnisse ✅ **Alle 7 Tests erfolgreich** (scripts/test_kommunikation_sync_implementation.py): 1. ✅ **Base64-Encoding bidirektional**: - `max@example.com` ↔ `bWF4QGV4YW1wbGUuY29t` - Special chars: `test:special]@example.com` ↔ `dGVzdDpzcGVjaWFsXUBleGFtcGxlLmNvbQ` 2. ✅ **Marker-Parsing**: synced_value korrekt dekodiert 3. ✅ **Marker-Erstellung**: Base64-Wert im Marker 4. ✅ **4-Tier Typ-Erkennung**: Marker > Top-Level > Pattern > Default 5. ✅ **Typ-Klassifizierung**: Email vs Phone types 6. ✅ **Integration mit bidirektionalem Matching**: ```python # Szenario: Wert ändert in Advoware old_value = "new@example.com" marker = create_marker(old_value, 4) # [ESPOCRM:bmV3QGV4YW1wbGUuY29t:4] # User ändert zu: new_value = "changed@example.com" # Sync dekodiert Marker: parsed = parse_marker(marker) assert parsed['synced_value'] == "new@example.com" # ✅ # Findet Match in EspoCRM: espo_match = find_in_espocrm(parsed['synced_value']) # Updates EspoCRM + Marker mit neuem Wert ``` 7. ✅ **Top-Level Feld Priorität**: telGesch, mobil etc. überschreiben Pattern ### 6.5 Migration von Hash zu Base64 **Backward Compatibility**: `parse_marker()` erkennt alte Hash-Marker automatisch: ```python if marker and len(encoded_value) == 8 and all(c in '0123456789abcdef' for c in encoded_value): # Legacy hash marker → Kann nicht dekodiert werden synced_value = encoded_value # Fallback else: synced_value = decode_value(encoded_value) # Base64 ``` **Automatische Migration**: Beim nächsten Sync werden Hash-Marker automatisch auf Base64 aktualisiert. ### 6.6 Vollständiger Sync-Ablauf mit Base64 **Szenario**: ``` Initial State: tlf: "old@example.com" bemerkung: "[ESPOCRM:abc12345:4]" User ändert tlf in Advoware: tlf: "new@example.com" bemerkung: "[ESPOCRM:abc12345:4]" ← UNVERÄNDERT! Problem: calculate_hash("new@example.com") ≠ "abc12345" → Matching zu EspoCRM schlägt fehl ``` ### 6.2 Lösung: Automatische Hash-Validierung Die `sync_advoware_to_espocrm()` Funktion validiert ALLE Hashes vor dem Sync: ```python def sync_advoware_to_espocrm(self, beteiligte_id: str, betnr: int): """Mit automatischer Hash-Validierung und Marker-Update""" for komm in kommunikationen: tlf = komm.get('tlf') bemerkung = komm.get('bemerkung') komm_id = komm.get('id') marker = parse_marker(bemerkung) if marker and not marker['is_slot']: current_hash = calculate_hash(tlf) # HASH-MISMATCH → Wert wurde in Advoware geändert if marker['hash'] != current_hash: logger.info(f"Hash-Mismatch detected: komm_id={komm_id}") # Update Marker mit neuem Hash (behält User-Text) user_text = marker.get('user_text', '') new_marker = create_marker(tlf, marker['kommKz'], user_text) self.advoware.update_kommunikation(betnr, komm_id, { 'bemerkung': new_marker }) result['markers_updated'] += 1 # ... Rest des Syncs ``` **Vorteile**: - ✅ Automatische Selbstheilung bei Änderungen in Advoware - ✅ User-Text wird beibehalten - ✅ kommKz bleibt erhalten (aus altem Marker) - ✅ Matching funktioniert beim nächsten Sync wieder **Result-Struktur** (erweitert): ```python { 'emails_synced': 3, 'phones_synced': 2, 'markers_updated': 1, # 🆕 Anzahl korrigierter Marker 'errors': [] } ``` ### 6.3 Integration in Beteiligte-Sync Der Kommunikation-Sync ist **integraler Bestandteil** des Beteiligte-Syncs: **Implementierung in `beteiligte_sync_event_step.py`**: ```python from services.advoware_service import AdvowareService from services.kommunikation_sync_utils import ( KommunikationSyncManager, detect_kommunikation_changes ) # In handler() advo_service = AdvowareService(context) komm_sync = KommunikationSyncManager(advo_service, espocrm) # In handle_update() async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, ...): # 1. Speichere alte Version für Change Detection old_advo_entity = advo_entity.copy() # 2. Sync STAMMDATEN (wie bisher) comparison = sync_utils.compare_entities(espo_entity, advo_entity) if comparison == 'espocrm_newer': # Update Advoware Stammdaten await advoware.api_call(f'.../Beteiligte/{betnr}', 'PUT', data=merged_data) # 3. KOMMUNIKATION SYNC (nach Stammdaten) advo_entity_refreshed = await advoware.api_call(f'.../Beteiligte/{betnr}', 'GET') if detect_kommunikation_changes(old_advo_entity, advo_entity_refreshed): context.logger.info("📞 Kommunikation-Änderungen erkannt") komm_result = komm_sync.sync_bidirectional(entity_id, betnr, direction='to_espocrm') context.logger.info(f"✅ Kommunikation synced: {komm_result}") elif comparison == 'advoware_newer': # Update EspoCRM Stammdaten await espocrm.update_entity('CBeteiligte', entity_id, espo_data) # 3. KOMMUNIKATION SYNC if detect_kommunikation_changes(old_advo_entity, advo_entity): komm_result = komm_sync.sync_bidirectional(entity_id, betnr, direction='to_espocrm') ``` **Reihenfolge ist wichtig**: 1. **Erst** Stammdaten-Sync (name, anrede, etc.) 2. **Dann** Kommunikation-Sync (emails, phones) 3. Change Detection via `rowId` (Stammdaten) + Array-Vergleich (Kommunikation) **Fehlerbehandlung**: ```python try: komm_result = komm_sync.sync_bidirectional(...) context.logger.info(f"✅ Kommunikation synced: {komm_result}") except Exception as e: # Kommunikation-Fehler blockiert NICHT den Stammdaten-Sync context.logger.error(f"⚠️ Kommunikation-Sync fehlgeschlagen: {e}") # Stammdaten sind bereits gespeichert → syncStatus bleibt 'clean' ``` **Vorteile der Integration**: - ✅ Atomare Operation: Stammdaten + Kommunikation in einem Durchlauf - ✅ Keine separaten Webhooks nötig - ✅ Konsistente Change Detection - ✅ Fehler-Isolation: Kommunikation-Fehler blockiert nicht Stammdaten-Sync ### 6.4 Vollständiger Sync-Ablauf **Beispiel: User ändert Email in Advoware** 1. **User-Aktion**: `old@example.com` → `new@example.com` in Advoware 2. **Webhook**: Advoware Beteiligte-Änderung 3. **Stammdaten-Check**: `rowId` geändert → `comparison = 'advoware_newer'` 4. **Kommunikation-Check**: `detect_kommunikation_changes() = True` 5. **Sync Advoware → EspoCRM**: - Hash-Validierung: `abc12345 ≠ calculate_hash("new@example.com")` - **Marker-Update**: `[ESPOCRM:def67890:4]` - **EspoCRM-Update**: `emailAddressData = [{emailAddress: "new@example.com", ...}]` 6. **Result**: `{emails_synced: 1, markers_updated: 1, errors: []}` **Beispiel: User löscht Email in EspoCRM** 1. **User-Aktion**: Löscht `max@example.com` in EspoCRM 2. **Webhook**: EspoCRM CBeteiligte-Änderung 3. **Kommunikation-Check**: `detect_espocrm_kommunikation_changes() = True` 4. **Sync EspoCRM → Advoware**: - Hash-Map: `abc12345` in Advoware, aber nicht in EspoCRM - **Empty Slot**: `tlf = '', bemerkung = "[ESPOCRM-SLOT:4]"` 5. **Result**: `{deleted: 1, errors: []}` --- **Implementation Status: ✅ COMPLETE + INTEGRATED** **Ende der Analyse** ✅