- 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.
77 KiB
Kommunikation-Synchronisation: Analyse EspoCRM ↔ Advoware
Erstellt: 8. Februar 2026
Status: ✅ API vollständig getestet
Basis: Advoware API v1, EspoCRM Custom Entity
📋 Inhaltsverzeichnis
- Executive Summary
- Advoware API Analyse
- EspoCRM Konzept
- Feld-Mapping
- Sync-Strategie
- 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
- kommKz ist READ-ONLY bei PUT: Kommunikationstyp kann nach Erstellung nicht geändert werden
- Kein DELETE: Manuelle Intervention via Notification erforderlich
- kommArt vs. kommKz:
kommArtwird automatisch vonkommKzabgeleitet
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
{
"tlf": "string (nullable)",
"bemerkung": "string (nullable)",
"kommKz": "integer (enum 1-12)",
"online": "boolean"
}
Response (GET/POST/PUT)
{
"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:
{
"kommKz": 0,
"kommArt": 0
}
Beobachtungen:
- ✅ POST Response: kommKz wird korrekt zurückgegeben (z.B. 3 für Mobil)
- ✅ PUT Response: kommKz wird zurückgegeben (aber oft ignoriert bei Änderungsversuch)
- ❌ GET Response: kommKz ist IMMER 0 (für alle Kommunikationen!)
Test-Beispiel:
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:
- Fehlende Berechtigung zum Lesen dieser Felder (Role-basiert)
- Bug in Advoware GET-Serialisierung
- 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:
kommKzwird 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 ✅
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 ✅
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 ❌
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 ❌
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
{
"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:
# 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:
{
"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:
- kommKz in Advoware GET nicht lesbar ist (Bug: immer 0)
- kommKz in Advoware nicht änderbar ist (READ-ONLY)
- EspoCRM muss als "Source of Truth" für den Typ dienen
3.3 Empfehlung
➡️ Option B (Custom Entity) wird DRINGEND EMPFOHLEN weil:
- Vollständigkeit: Alle 12 Advoware-Typen unterstützt (nicht nur Email/Phone)
- Matching: Entity-ID ermöglicht stabiles Matching
- Wartbarkeit: Saubere Trennung von Stammdaten und Kommunikation
- Konsistenz: Gleicher Ansatz wie Adressen und Bankverbindungen (separate Entities)
Migration von Standard zu Custom:
# 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
{
"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:
-
advowareId speichern (bevorzugt)
- Bei CREATE: Speichere
idvon Advoware Response - Bei SYNC: Matche via
advowareId - ✅ Stabil, zuverlässig
- Bei CREATE: Speichere
-
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)
# 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→emailAddressundloweronline→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)
# 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:
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
- ❌
idFeld in emailAddressData: Wird ignoriert/entfernt - ❌ kommKz/kommArt in GET: Beide immer 0 (Bug)
- ✅ Advoware hat eindeutige
idpro 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?
# 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):
- Aus bemerkung-Marker (wenn vorhanden) → Genau
- Aus Top-Level Feldern (telGesch, emailGesch, etc.) → Genau für einen Eintrag
- Aus Wert (Email='@', Phone=Rest) → Grob
- Default (4=MailGesch, 1=TelGesch) → Fallback
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
# 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
# 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
# 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
# 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:
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:
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:
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
{
"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(oderWysiwygfalls 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
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):
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):
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/phoneNumberWert - Bei Wert-Änderung: DELETE + CREATE (kein UPDATE)
- Keine DELETE-Detection möglich
- Nur One-Way: Advoware → EspoCRM
Siehe Abschnitt 5.1 für Details.
1. Advoware → EspoCRM (primary=true)
Alle Advoware-Kommunikationen werden mit primary=true markiert (geschützt):
# 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:
# 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:
- Sync erkennt rowId-Änderung von Advoware-Eintrag 123
- Sucht
max@new.comin EspoCRM → nicht gefunden - Fügt
max@new.commit primary=true hinzu max@old.combleibt 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)
{
'kommKz': map_enum(espo['kommunikationstyp']), # 1-12
'tlf': espo['wert'], # "+49 511..."
'bemerkung': espo['bemerkung'], # Notiz
'online': espo['isOnline'] # Boolean
}
Enum-Mapping:
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)
{
# kommKz NICHT ÄNDERBAR!
'kommKz': current_advo['kommKz'], # Verwende aktuellen Wert
'tlf': espo['wert'], # ÄNDERBAR
'bemerkung': espo['bemerkung'], # ÄNDERBAR
'online': espo['isOnline'] # ÄNDERBAR
}
Wichtig:
kommKzMUSS im Request enthalten sein (API-Validierung)- Aber Wert wird ignoriert - immer aktuellen Wert verwenden!
4.3 Advoware → EspoCRM
{
'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):
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
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)
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=falseund bleiben erhalten - Bei jedem Sync werden Advoware-Einträge komplett überschrieben
5.2 Change Detection
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
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)
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)
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
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
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
- Entity erstellen:
CKommunikation - Felder definieren:
kommunikationstyp(enum)wert(string)bemerkung(text)isOnline(bool)isPrimary(bool)beteiligteId(link zu CBeteiligte)- Sync-Felder (advowareId, rowId, syncStatus, etc.)
- Relationship: Many-to-One zu CBeteiligte
Phase 2: Mapper Implementierung
-
kommunikation_mapper.py:
map_ckommunikation_to_advoware_create()- Alle Feldermap_ckommunikation_to_advoware_update()- Nur R/W Feldermap_advoware_to_ckommunikation()- Reverse mappingdetect_readonly_changes()- kommKz Detection
-
Enum-Mappings:
ESPO_TO_ADVO_KOMMKZADVO_TO_ESPO_KOMMKZ
Phase 3: Sync Service
-
kommunikation_sync.py:
create_kommunikation()- POST zu Advowareupdate_kommunikation()- PUT (nur R/W)handle_kommunikation_deletion()- Notificationsync_from_advoware()- Import_find_kommunikation_by_advoware_id()- Matching
-
NotificationManager Integration:
readonly_field_conflict- kommKz geändertdelete_not_supported- Manuelle Löschung
Phase 4: Webhook Integration
-
Webhook Endpoints:
kommunikation_create_api_step.pykommunikation_update_api_step.pykommunikation_delete_api_step.py
-
Event Handler:
kommunikation_sync_event_step.py- Subscribe:
vmh.kommunikation.{create|update|delete}
Phase 5: Testing
-
Unit Tests:
- Mapper-Funktionen
- Enum-Conversions
- Readonly-Detection
-
Integration Tests:
- CREATE mit allen kommKz-Typen
- UPDATE R/W Felder
- UPDATE kommKz → Notification
- DELETE → Notification
- SYNC from Advoware
-
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
- CREATE: Automatisch (alle Felder)
- UPDATE: Automatisch (tlf, bemerkung, online) + Notification bei kommKz-Änderung
- DELETE: Notification für manuelle Löschung
- 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 Matchingparse_marker(bemerkung): Extrahiert Marker aus bemerkungcreate_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- Aus Marker (höchste Priorität)
- Aus Top-Level Feldern (telGesch, emailGesch, etc.)
- Aus Wert-Pattern (@ = Email, sonst Phone)
- Default (MailGesch=4, TelGesch=1)
advoware_to_espocrm_email(): Mapping Advoware → EspoCRM Emailadvoware_to_espocrm_phone(): Mapping Advoware → EspoCRM Phonefind_matching_advoware(): Hash-basierte Suche in Advowarefind_empty_slot(): Findet wiederverwendbare leere Slotsshould_sync_to_espocrm(): Filtert leere Slots und ungültige Einträge
Konstanten:
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
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
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:
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):
# 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):
# 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 Analysescripts/test_kommart_values.py: kommArt-Bug Verifikationscripts/analyze_beteiligte_endpoint.py: Top-Level Felder Analysescripts/test_espocrm_kommunikation.py: EspoCRM Struktur-Tests
Manuelle Tests:
-
Szenario 1 - Löschen in EspoCRM:
- Lösche Email in EspoCRM
- Trigger Webhook → Sync
- Verify: Advoware hat Empty Slot
[ESPOCRM-SLOT:4]
-
Szenario 2 - Ändern in EspoCRM:
- Ändere Email-Wert in EspoCRM
- Trigger Webhook → Sync
- Verify: Advoware hat neuen Wert + neuen Hash-Marker
-
Szenario 3 - Neu in EspoCRM:
- Füge Email in EspoCRM hinzu
- Trigger Webhook → Sync
- Verify: Advoware hat neue Kommunikation ODER reused Slot
-
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:
# 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!
# 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:
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:
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:
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):
-
✅ Base64-Encoding bidirektional:
max@example.com↔bWF4QGV4YW1wbGUuY29t- Special chars:
test:special]@example.com↔dGVzdDpzcGVjaWFsXUBleGFtcGxlLmNvbQ
-
✅ Marker-Parsing: synced_value korrekt dekodiert
-
✅ Marker-Erstellung: Base64-Wert im Marker
-
✅ 4-Tier Typ-Erkennung: Marker > Top-Level > Pattern > Default
-
✅ Typ-Klassifizierung: Email vs Phone types
-
✅ Integration mit bidirektionalem Matching:
# 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 -
✅ 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:
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:
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):
{
'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:
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:
- Erst Stammdaten-Sync (name, anrede, etc.)
- Dann Kommunikation-Sync (emails, phones)
- Change Detection via
rowId(Stammdaten) + Array-Vergleich (Kommunikation)
Fehlerbehandlung:
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
- User-Aktion:
old@example.com→new@example.comin Advoware - Webhook: Advoware Beteiligte-Änderung
- Stammdaten-Check:
rowIdgeändert →comparison = 'advoware_newer' - Kommunikation-Check:
detect_kommunikation_changes() = True - Sync Advoware → EspoCRM:
- Hash-Validierung:
abc12345 ≠ calculate_hash("new@example.com") - Marker-Update:
[ESPOCRM:def67890:4] - EspoCRM-Update:
emailAddressData = [{emailAddress: "new@example.com", ...}]
- Hash-Validierung:
- Result:
{emails_synced: 1, markers_updated: 1, errors: []}
Beispiel: User löscht Email in EspoCRM
- User-Aktion: Löscht
max@example.comin EspoCRM - Webhook: EspoCRM CBeteiligte-Änderung
- Kommunikation-Check:
detect_espocrm_kommunikation_changes() = True - Sync EspoCRM → Advoware:
- Hash-Map:
abc12345in Advoware, aber nicht in EspoCRM - Empty Slot:
tlf = '', bemerkung = "[ESPOCRM-SLOT:4]"
- Hash-Map:
- Result:
{deleted: 1, errors: []}
Implementation Status: ✅ COMPLETE + INTEGRATED
Ende der Analyse ✅