# Beteiligte Sync - Bidirektionale Synchronisation EspoCRM ↔ Advoware ## Übersicht Bidirektionale Synchronisation der **Stammdaten** von Beteiligten zwischen EspoCRM (CBeteiligte) und Advoware (Beteiligte). **Scope**: Nur Stammdaten (Name, Rechtsform, Geburtsdatum, Anrede, Handelsregister) **Out of Scope**: Kontaktdaten (Telefon, Email, Fax, Bankverbindungen) → separate Endpoints ## Architektur ### Event-Driven Architecture ``` ┌─────────────┐ │ EspoCRM │ Webhook → vmh.beteiligte.{create,update,delete} │ CBeteiligte │ ↓ └─────────────┘ ┌────────────────────┐ │ Event Handler │ ┌─────────────┐ │ (sync_event_step) │ │ Cron │ ───→ │ │ │ (15 min) │ sync_ │ - Lock (Redis) │ └─────────────┘ check │ - Timestamp Check │ │ - Merge & Sync │ └────────┬───────────┘ ↓ ┌────────────────────┐ │ Advoware API │ │ /Beteiligte │ └────────────────────┘ ``` ### Komponenten 1. **Event Handler** ([beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py)) - Subscribes: `vmh.beteiligte.{create,update,delete,sync_check}` - Verarbeitet Sync-Events - Verwendet Redis distributed lock 2. **Cron Job** ([beteiligte_sync_cron_step.py](../steps/vmh/beteiligte_sync_cron_step.py)) - Läuft alle 15 Minuten - Findet Entities mit Sync-Bedarf - Emittiert `sync_check` Events 3. **Sync Utils** ([beteiligte_sync_utils.py](../services/beteiligte_sync_utils.py)) - Lock-Management (Redis distributed lock) - Timestamp-Vergleich - Merge-Utility für Advoware PUT - Notifications 4. **Mapper** ([espocrm_mapper.py](../services/espocrm_mapper.py)) - `map_cbeteiligte_to_advoware()` - EspoCRM → Advoware - `map_advoware_to_cbeteiligte()` - Advoware → EspoCRM - Nur Stammdaten, keine Kontaktdaten 5. **APIs** - [espocrm.py](../services/espocrm.py) - EspoCRM API Client - [advoware.py](../services/advoware.py) - Advoware API Client ## Sync-Strategie ### State Management - **Sync-Status in EspoCRM** (nicht PostgreSQL) - **Field**: `syncStatus` (enum mit 7 Werten) - **Lock**: Redis distributed lock (5 min TTL) ### Konfliktauflösung - **Policy**: EspoCRM wins - **Detection**: Timestamp-Vergleich (`modifiedAt` vs `geaendertAm`) - **Notification**: In-App Notification in EspoCRM ### Sync-Status Values ```typescript enum SyncStatus { clean // ✅ Synced, keine Änderungen dirty // 📝 Lokale Änderungen, noch nicht synced pending_sync // ⏳ Wartet auf ersten Sync syncing // 🔄 Sync läuft gerade (Lock) failed // ❌ Sync fehlgeschlagen (retry möglich) conflict // ⚠️ Konflikt erkannt permanently_failed // 💀 Max retries erreicht (5x) } ``` ## Datenfluss ### 1. Create (Neu in EspoCRM) ``` EspoCRM (neu) → Webhook → Event Handler ↓ Acquire Lock (Redis) ↓ Map EspoCRM → Advoware ↓ POST /api/v1/advonet/Beteiligte ↓ Response: {betNr: 12345} ↓ Update EspoCRM: betnr=12345, syncStatus=clean ↓ Release Lock ``` ### 2. Update (Änderung in EspoCRM) ``` EspoCRM (geändert) → Webhook → Event Handler ↓ Acquire Lock (Redis) ↓ GET /api/v1/advonet/Beteiligte/{betnr} ↓ Timestamp-Vergleich (rowId + modifiedAt vs geaendertAm): - no_change → Nur Kommunikation sync (direction=both) - espocrm_newer → Update Advoware (PUT) + Kommunikation sync (direction=both) - advoware_newer → Update EspoCRM (PATCH) + Kommunikation sync (direction=both) - conflict → EspoCRM wins (PUT) + Notification + Kommunikation sync (direction=to_advoware ONLY!) ↓ Kommunikation Sync (Hash-basiert, siehe unten) ↓ Release Lock (NACH Kommunikation-Sync!) ``` ### 3. Cron Check ``` Cron (alle 15 min) ↓ Query EspoCRM: - syncStatus IN (pending_sync, dirty, failed) - OR (clean AND advowareLastSync > 24h) ↓ Batch emit: vmh.beteiligte.sync_check events ↓ Event Handler (siehe Update) ``` ## Optimierungen ### 1. Redis Distributed Lock (Atomicity) ```python lock_key = f"sync_lock:cbeteiligte:{entity_id}" acquired = redis.set(lock_key, "locked", nx=True, ex=300) ``` - ✅ Verhindert Race Conditions - ✅ TTL verhindert Deadlocks (5 min) ### 2. Combined API Calls (Performance) ```python await sync_utils.release_sync_lock( entity_id, 'clean', extra_fields={'betnr': new_betnr} # ← kombiniert 2 calls in 1 ) ``` - ✅ 33% weniger API Requests ### 3. Merge Utility (Code Quality) ```python merged = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper) ``` - ✅ Keine Code-Duplikation - ✅ Konsistentes Logging - ✅ Wiederverwendbar ### 4. Max Retry Limit (Robustheit) ```python MAX_SYNC_RETRIES = 5 if retry_count >= 5: status = 'permanently_failed' send_notification("Max retries erreicht") ``` - ✅ Verhindert infinite loops - ✅ User wird benachrichtigt ### 5. Batch Processing (Scalability) ```python tasks = [context.emit(...) for entity_id in entity_ids] results = await asyncio.gather(*tasks, return_exceptions=True) ``` - ✅ 90% schneller bei 100 Entities ## Kommunikation-Sync Integration **WICHTIG**: Kommunikation-Sync läuft **IMMER** nach Stammdaten-Sync (auch bei `no_change`)! ### Hash-basierte Änderungserkennung ✅ Die Kommunikation-Synchronisation verwendet **MD5-Hash** der `kommunikation` rowIds aus Advoware: - **Hash-Berechnung**: MD5 von sortierten rowIds (erste 16 Zeichen) - **Speicherort**: `kommunikationHash` in EspoCRM CBeteiligte - **Vorteil**: Erkennt Kommunikations-Änderungen ohne Beteiligte-rowId-Änderung **Problem gelöst**: Beteiligte-rowId ändert sich **NICHT**, wenn nur Kommunikation geändert wird! ### 3-Way Diffing mit Konflikt-Erkennung ```python # Timestamp-basiert für EspoCRM espo_changed = espo_bet.modifiedAt > espo_bet.advowareLastSync # Hash-basiert für Advoware stored_hash = espo_bet.kommunikationHash # z.B. "a3f5d2e8b1c4f6a9" current_hash = MD5(sorted(komm.rowId for komm in advo_kommunikationen))[:16] advo_changed = stored_hash != current_hash # Konflikt-Erkennung if espo_changed AND advo_changed: espo_wins = True # EspoCRM gewinnt immer! ``` ### Konflikt-Behandlung: EspoCRM Wins **Bei Konflikt** (beide Seiten geändert): 1. **Stammdaten**: EspoCRM → Advoware (PUT) 2. **Kommunikation**: `direction='to_advoware'` (NUR EspoCRM→Advoware, blockiert Advoware→EspoCRM) 3. **Notification**: In-App Benachrichtigung 4. **Hash-Update**: Neuer Hash wird gespeichert **Ohne Konflikt**: - **Stammdaten**: Je nach Timestamp-Vergleich - **Kommunikation**: `direction='both'` (bidirektional) ### 6 Sync-Varianten (Var1-6) **Var1**: Neu in EspoCRM → CREATE in Advoware **Var2**: Gelöscht in EspoCRM → DELETE in Advoware (Empty Slot) **Var3**: Gelöscht in Advoware → DELETE in EspoCRM **Var4**: Neu in Advoware → CREATE in EspoCRM **Var5**: Geändert in EspoCRM → UPDATE in Advoware **Var6**: Geändert in Advoware → UPDATE in EspoCRM ### Base64-Marker Strategie ``` [ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich [ESPOCRM-SLOT:4] # Leerer Slot nach Löschung ``` ### Base64-Marker Strategie **Marker-Format** im Advoware `bemerkung` Feld: ``` [ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich [ESPOCRM-SLOT:4] # Leerer Slot nach Löschung ``` **Base64-Encoding statt Hash**: - **Vorteil**: Bidirektional! Marker enthält den **tatsächlichen Wert** (Base64-kodiert) - **Matching**: Selbst wenn Wert in Advoware ändert, kann alter Wert aus Marker dekodiert werden - **Beispiel**: ```python # Advoware: old@example.com → new@example.com # Alter Marker: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4] # Sync dekodiert: "old@example.com" → Findet Match in EspoCRM ✅ # Update: EspoCRM-Eintrag + Marker mit neuem Base64-Wert ``` ### 4-Stufen kommKz-Erkennung (Type Detection) **Problem**: Advoware `kommKz` ist via GET immer 0, via PUT read-only! **Lösung - Prioritäts-Kaskade**: 1. **Marker** (höchste Priorität) → `[ESPOCRM:...:3]` = kommKz 3 (Mobil) 2. **EspoCRM Type** (bei EspoCRM→Advoware) → `type: 'Mobile'` = kommKz 3 3. **Top-Level Felder** → `beteiligte.mobil` = kommKz 3 4. **Wert-Pattern** → `@` in Wert = Email (kommKz 4) 5. **Default** → Fallback (TelGesch=1, MailGesch=4) **Mapping EspoCRM phoneNumberData.type → kommKz**: ```python PHONE_TYPE_TO_KOMMKZ = { 'Office': 1, # TelGesch 'Fax': 2, # FaxGesch 'Mobile': 3, # Mobil 'Home': 6, # TelPrivat 'Other': 10 # Sonstige } ``` ### Slot-Wiederverwendung (Empty Slots) **Problem**: Advoware DELETE gibt 403 Forbidden! **Lösung**: Empty Slots mit Marker ```python # Gelöscht in EspoCRM → Create Empty Slot in Advoware { "tlf": "", "bemerkung": "[ESPOCRM-SLOT:4]", # kommKz=4 (Email) "kommKz": 4, "online": True } ``` **Wiederverwendung**: - Neue Einträge prüfen zuerst Empty Slots mit passendem kommKz - UPDATE statt CREATE spart API-Calls und IDs ### Lock-Management mit Redis **WICHTIG**: Lock wird erst NACH Kommunikation-Sync freigegeben! ```python # Pattern in allen 4 Szenarien: await sync_utils.acquire_sync_lock(entity_id) try: # 1. Stammdaten sync # 2. Kommunikation sync (run_kommunikation_sync helper) # 3. Lock release await sync_utils.release_sync_lock(entity_id, 'clean') finally: # Failsafe: Lock wird auch bei Exception released pass ``` **Vorher (BUG)**: Lock wurde teilweise VOR Kommunikation-Sync released! **Jetzt**: Konsistentes Pattern - Lock schützt gesamte Operation ### Implementation Details **Implementation**: - [kommunikation_mapper.py](../services/kommunikation_mapper.py) - Base64 encoding/decoding, kommKz detection - [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) - Sync-Manager mit 3-way diffing - [beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py) - Event handler mit helper function - Tests: [test_kommunikation_sync_implementation.py](../scripts/test_kommunikation_sync_implementation.py) **Helper Function** (DRY-Prinzip): ```python async def run_kommunikation_sync(entity_id, betnr, komm_sync, context, direction='both'): """Führt Kommunikation-Sync aus mit Error-Handling und Logging""" context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...") komm_result = await komm_sync.sync_bidirectional(entity_id, betnr, direction=direction) return komm_result ``` **Verwendet in**: - no_change: `direction='both'` - espocrm_newer: `direction='both'` - advoware_newer: `direction='both'` - **conflict**: `direction='to_advoware'` ← NUR EspoCRM→Advoware! ## Performance | Operation | API Calls | Latency | |-----------|-----------|---------| | CREATE | 2 | ~200ms | | UPDATE (initial) | 2 | ~250ms | | UPDATE (normal) | 2 | ~250ms | | Cron (100 entities) | 200 | ~1s (parallel) | ## Monitoring ### Sync-Status Tracking ```sql -- In EspoCRM SELECT syncStatus, COUNT(*) FROM c_beteiligte GROUP BY syncStatus; ``` ### Failed Syncs ```sql -- Entities mit Sync-Problemen SELECT id, name, syncStatus, syncErrorMessage, syncRetryCount FROM c_beteiligte WHERE syncStatus IN ('failed', 'permanently_failed') ORDER BY syncRetryCount DESC; ``` ## Fehlerbehandlung ### Retriable Errors - Netzwerk-Timeout - 500 Internal Server Error - 503 Service Unavailable → Status: `failed`, retry beim nächsten Cron ### Non-Retriable Errors - 400 Bad Request (invalid data) - 404 Not Found (entity deleted) - 401 Unauthorized (auth error) → Status: `failed`, keine automatischen Retries ### Max Retries Exceeded - Nach 5 Versuchen: `permanently_failed` - User erhält In-App Notification - Manuelle Prüfung erforderlich ## Testing ### Unit Tests ```bash cd /opt/motia-app/bitbylaw source python_modules/bin/activate python scripts/test_beteiligte_sync.py ``` ### Manual Test ```python # Test single entity sync event_data = { 'entity_id': '68e3e7eab49f09adb', 'action': 'sync_check', 'source': 'manual_test' } await beteiligte_sync_event_step.handler(event_data, context) ``` ## Entity Mapping ### EspoCRM CBeteiligte → Advoware Beteiligte | EspoCRM Field | Advoware Field | Type | Notes | |---------------|----------------|------|-------| | `lastName` | `name` | string | Bei Person | | `firstName` | `vorname` | string | Bei Person | | `firmenname` | `name` | string | Bei Firma | | `rechtsform` | `rechtsform` | string | Person/Firma | | `salutationName` | `anrede` | string | Herr/Frau | | `dateOfBirth` | `geburtsdatum` | date | Nur Person | | `handelsregisterNummer` | `handelsRegisterNummer` | string | Nur Firma | | `betnr` | `betNr` | int | Foreign Key | **Nicht gemapped**: Telefon, Email, Fax, Bankverbindungen (→ separate Endpoints) ## Troubleshooting ### Sync bleibt bei "syncing" hängen **Problem**: Redis lock expired, aber syncStatus nicht zurückgesetzt **Lösung**: ```python # Lock ist automatisch nach 5 min weg (TTL) # Manuelles zurücksetzen: await espocrm.update_entity('CBeteiligte', entity_id, {'syncStatus': 'dirty'}) ``` ### "Max retries exceeded" **Problem**: Entity ist `permanently_failed` **Lösung**: 1. Prüfe `syncErrorMessage` für Details 2. Behebe das Problem (z.B. invalide Daten) 3. Reset: `syncStatus='dirty', syncRetryCount=0` ### Race Condition / Parallele Syncs **Problem**: Zwei Syncs gleichzeitig (sollte nicht passieren) **Lösung**: Redis lock verhindert das automatisch ## Configuration ### Environment Variables ```bash # EspoCRM ESPOCRM_API_BASE_URL=https://crm.bitbylaw.com/api/v1 ESPOCRM_MARVIN_API_KEY=e53def10eea27b92a6cd00f40a3e09a4 # Advoware ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/ ADVOWARE_PRODUCT_ID=... ADVOWARE_APP_ID=... ADVOWARE_API_KEY=... # Redis REDIS_HOST=localhost REDIS_PORT=6379 REDIS_DB_ADVOWARE_CACHE=1 ``` ### EspoCRM Entity Fields Custom fields für Sync-Management: - `betnr` (int, unique) - Foreign Key zu Advoware - `syncStatus` (enum) - Sync-Status - `advowareLastSync` (datetime) - Letzter erfolgreicher Sync - `advowareDeletedAt` (datetime) - Soft-Delete timestamp - `advowareRowId` (varchar, 50) - Cached Advoware rowId für Change Detection - **`kommunikationHash` (varchar, 16)** - MD5-Hash der Kommunikation rowIds (erste 16 Zeichen) - `syncErrorMessage` (text, 2000 chars) - Letzte Fehlermeldung - `syncRetryCount` (int) - Anzahl fehlgeschlagener Versuche ## Deployment ### 1. Deploy Code ```bash cd /opt/motia-app/bitbylaw git pull source python_modules/bin/activate pip install -r requirements.txt ``` ### 2. Restart Motia ```bash # Motia Workbench restart (lädt neue Steps) systemctl restart motia-workbench # oder entsprechender Befehl ``` ### 3. Verify ```bash # Check logs tail -f /var/log/motia/workbench.log # Test single sync python scripts/test_beteiligte_sync.py ``` ## Weitere Advoware-Syncs Dieses System ist als **Template für alle Advoware-Syncs** designed. Wichtige Prinzipien: 1. **Redis Distributed Lock** für atomare Operations 2. **Merge Utility** für Read-Modify-Write Pattern 3. **Max Retries** mit Notification 4. **Batch Processing** in Cron 5. **Combined API Calls** wo möglich → Siehe [SYNC_TEMPLATE.md](SYNC_TEMPLATE.md) für Implementierungs-Template ## Siehe auch - [Entity Mapping Details](../ENTITY_MAPPING_CBeteiligte_Advoware.md) - [Advoware API Docs](advoware/) - [EspoCRM API Docs](API.md)