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