# Advoware Sync - System-Übersicht **Letzte Aktualisierung**: 8. Februar 2026 **Status**: ✅ Production Ready --- ## Inhaltsverzeichnis 1. [System-Architektur](#system-architektur) 2. [Beteiligte Sync (Stammdaten)](#beteiligte-sync-stammdaten) 3. [Kommunikation Sync](#kommunikation-sync) 4. [Sync Status Management](#sync-status-management) 5. [Bekannte Einschränkungen](#bekannte-einschränkungen) 6. [Troubleshooting](#troubleshooting) --- ## System-Architektur ### Defense in Depth: Webhook + Cron Das Sync-System verwendet **zwei parallele Mechanismen** für maximale Zuverlässigkeit: ``` ┌──────────────────────────────────────────────────────────┐ │ EspoCRM │ │ CBeteiligte Entity (Stammdaten + Kommunikation) │ └────────────┬────────────────────────────────────┬────────┘ │ │ Webhook (Echtzeit) Cron (Fallback) │ │ ▼ ▼ ┌────────────────┐ ┌────────────────┐ │ Event Handler │ │ Cron Job │ │ (Event-driven) │◄─────────────────│ (alle 15min) │ └────────┬───────┘ └────────────────┘ │ │ vmh.beteiligte.{create,update,delete,sync_check} ▼ ┌─────────────────────────────────────────────────────┐ │ Sync Event Handler │ │ 1. Redis Lock (verhindert Race Conditions) │ │ 2. Beteiligte Sync (Stammdaten) │ │ - rowId-basierte Change Detection │ │ - Timestamp-Vergleich für Konflikte │ │ 3. Kommunikation Sync (Phone/Email/Fax) │ │ - Hash-basierte Change Detection │ │ - Marker-Strategy für Bidirectional Matching │ │ 4. Lock Release │ └─────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────┐ │ Advoware REST API │ │ - POST/PUT/GET /Beteiligte (Stammdaten) │ │ - POST/PUT /Kommunikationen (Phone/Email/Fax) │ └─────────────────────────────────────────────────────┘ ``` ### Warum beide Mechanismen? **Webhook (Primary)**: - ✅ Echtzeit-Sync bei Änderungen - ✅ Schnelles User-Feedback - ❌ Kann fehlschlagen (Netzwerk, Server down, Race Conditions) **Cron (Fallback)**: - ✅ Garantierte Synchronisation alle 15 Minuten - ✅ Findet "verlorene" Entities (pending_sync, dirty, failed) - ✅ Auto-Retry für fehlgeschlagene Syncs - ❌ Bis zu 15 Minuten Verzögerung ### Komponenten | Komponente | Datei | Beschreibung | |------------|-------|--------------| | **Event Handler** | [beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py) | Central Sync Orchestrator | | **Cron Job** | [beteiligte_sync_cron_step.py](../steps/vmh/beteiligte_sync_cron_step.py) | 15-Minuten Fallback | | **Beteiligte Sync** | [beteiligte_sync_utils.py](../services/beteiligte_sync_utils.py) | Stammdaten-Sync Logik | | **Kommunikation Sync** | [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) | Phone/Email/Fax Sync | | **Mapper** | [espocrm_mapper.py](../services/espocrm_mapper.py) | Entity Transformations | | **Advoware API** | [advoware.py](../services/advoware.py) | HMAC-512 Auth REST Client | | **EspoCRM API** | [espocrm.py](../services/espocrm.py) | X-Api-Key REST Client | --- ## Beteiligte Sync (Stammdaten) ### Scope **Synchronisierte Felder** (8 Stammdatenfelder): - `name` - Nachname / Firmenname (max 140 chars) - `vorname` - Vorname bei natürlichen Personen (max 30 chars) - `rechtsform` - Rechtsform (max 50 chars, muss in Advoware `/Rechtsformen` existieren) - `titel` - Akademischer Titel (max 50 chars) - `anrede` - Anrede (max 35 chars) - `bAnrede` - Briefanrede (max 150 chars) - `zusatz` - Zusatzinformation (max 100 chars) - `geburtsdatum` - Geburtsdatum (datetime) **NICHT synchronisiert**: - ❌ Kontaktdaten (Telefon, Email, Fax) → separate Kommunikation Sync - ❌ Adressen → separate Adressen Sync (geplant) - ❌ Bankverbindungen → separate Endpoints (TBD) ### Change Detection: rowId **Advoware rowId**: Base64-String, ändert sich bei **jedem** Advoware PUT: ```python # Beispiel GET /api/v1/advonet/Beteiligte/104860 { "betNr": 104860, "rowId": "FBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA", # Base64 "name": "Mustermann", "geaendertAm": "2026-02-08T10:30:00" } # Nach PUT → rowId ÄNDERT sich PUT /api/v1/advonet/Beteiligte/104860 Response: { "rowId": "GBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA", # NEU! ... } ``` **Vergleich-Logik**: ```python espo_rowid = espo_bet.get('advowareRowId') # Gespeichert in EspoCRM advo_rowid = advo_bet.get('rowId') # Aktuell aus Advoware if espo_rowid != advo_rowid: # Advoware wurde geändert! advo_changed = True ``` ### Konflikt-Behandlung **Erkennung**: Beide Seiten haben sich seit letztem Sync geändert ```python # EspoCRM: Timestamp-Vergleich espo_modified = espo_bet.get('modifiedAt') last_sync = espo_bet.get('advowareLastSync') espo_changed = espo_modified > last_sync # Advoware: rowId-Vergleich advo_changed = espo_rowid != advo_rowid # Konflikt? if espo_changed AND advo_changed: conflict = True ``` **Auflösung**: EspoCRM wins IMMER! ```python if conflict: # 1. Stammdaten: EspoCRM → Advoware await advoware.put_beteiligte(betnr, espo_data) # 2. Kommunikation: NUR EspoCRM → Advoware (direction='to_advoware') await komm_sync.sync_bidirectional(entity_id, betnr, direction='to_advoware') # 3. Notification an User await espocrm.create_notification( user_id, "Konflikt gelöst: EspoCRM-Daten wurden übernommen" ) ``` **Warum Kommunikation direction='to_advoware'?** Bei Konflikten sollen Advoware-Änderungen NICHT zurück zu EspoCRM übertragen werden, da sonst der Konflikt wieder auftritt (Ping-Pong). ### Sync-Ablauf #### 1. CREATE (Neu in EspoCRM) ``` User creates CBeteiligte in EspoCRM ↓ EspoCRM Webhook → vmh.beteiligte.create ↓ Event Handler: 1. Acquire Redis Lock (5min TTL) 2. Set syncStatus='syncing' 3. Map CBeteiligte → BeteiligterParameter 4. POST /api/v1/advonet/Beteiligte Response: {betNr: 104860, rowId: "FBAB..."} 5. Update EspoCRM: - betnr = 104860 - advowareRowId = "FBAB..." - advowareLastSync = NOW - syncStatus = 'clean' 6. Kommunikation Sync (meist leer bei CREATE) 7. Release Redis Lock Result: Entity existiert in beiden Systemen mit betNr-Link ``` #### 2. UPDATE (Änderung in EspoCRM) ``` User updates CBeteiligte in EspoCRM ↓ EspoCRM Webhook → vmh.beteiligte.update ↓ Event Handler: 1. Acquire Redis Lock 2. Set syncStatus='syncing' 3. GET /api/v1/advonet/Beteiligte/{betnr} 4. Timestamp-Vergleich: Case A: no_change (beide unverändert) → Skip Stammdaten, nur Kommunikation Sync Case B: espocrm_newer (nur EspoCRM geändert) → PUT Advoware, Kommunikation Sync (both) Case C: advoware_newer (nur Advoware geändert) → PATCH EspoCRM, Kommunikation Sync (both) Case D: conflict (beide geändert) → PUT Advoware (EspoCRM wins!) → Kommunikation Sync (to_advoware ONLY!) → Notification 5. Update EspoCRM (rowId, lastSync, syncStatus) 6. Release Lock Result: Beide Systeme synchronisiert ``` #### 3. DELETE (Gelöscht in EspoCRM) **Problem**: Advoware DELETE ist NICHT möglich (403 Forbidden) **Strategie**: Soft-Delete mit Notification ``` User deletes CBeteiligte in EspoCRM ↓ EspoCRM Webhook → vmh.beteiligte.delete ↓ Event Handler: 1. NO API Call zu Advoware (würde 403 geben) 2. Create Notification: "Beteiligter wurde in EspoCRM gelöscht. Bitte manuell in Advoware löschen: betNr 104860" 3. Set syncStatus='deleted_in_espocrm' (optional) Result: Entity nur in EspoCRM gelöscht, User muss manuell in Advoware löschen ``` ### Bekannte Einschränkungen #### 1. Nur 8 von 14 Advoware-Feldern funktionieren **Funktionierende Felder**: ✅ name, vorname, rechtsform, titel, anrede, bAnrede, zusatz, geburtsdatum **Ignorierte Felder** (im Swagger definiert, aber PUT ignoriert sie): ❌ art, kurzname, geburtsname, familienstand, handelsRegisterNummer, registergericht **Workaround**: Mapper verwendet nur die 8 funktionierenden Felder. Handelsregister-Daten müssen manuell in Advoware gepflegt werden. #### 2. Advoware DELETE gibt 403 **Problem**: `/api/v1/advonet/Beteiligte/{betNr}` DELETE → 403 Forbidden **Workaround**: Soft-Delete mit Notification, User löscht manuell in Advoware. #### 3. rowId ändert sich bei jedem PUT **Problem**: Auch wenn gleiche Werte geschrieben werden, ändert sich rowId **Auswirkung**: - Jeder Sync in Advoware invalididiert EspoCRM rowId - Timestamp ist wichtig für echte Änderungserkennung **Workaround**: Timestamp-Vergleich zusätzlich zu rowId-Vergleich. #### 4. Keine Batch-Updates **Problem**: Advoware API hat keinen Batch-Endpoint **Auswirkung**: Bei vielen Entities viele API-Calls (N+1 Problem) **Workaround**: - Cron lädt max 100 Entities pro Run - Priorisierung: pending_sync > dirty > failed > clean (>24h) --- ## Kommunikation Sync ### Scope **Synchronisierte Kommunikationstypen** (kommKz-Enum): | kommKz | Name | EspoCRM Type | |--------|------|--------------| | 1 | TelGesch | Office (Phone) | | 2 | FaxGesch | Office (Fax) | | 3 | Mobil | Mobile (Phone) | | 4 | MailGesch | Office (Email) | | 5 | Internet | Website (Email) | | 6 | TelPrivat | Home (Phone) | | 7 | FaxPrivat | Home (Fax) | | 8 | MailPrivat | Home (Email) | | 9 | AutoTelefon | Other (Phone) | | 10 | Sonstige | Other | | 11 | EPost | EPost (Email) | | 12 | Bea | BeA (Email) | ### Change Detection: Hash-basiert **Problem**: Advoware Beteiligte-rowId ändert sich NICHT bei Kommunikations-Änderungen! ```python # Beispiel GET /api/v1/advonet/Beteiligte/104860 { "betNr": 104860, "rowId": "FBAB...", # UNCHANGED! "kommunikation": [ {"id": 88001, "rowId": "ABCD...", "tlf": "0511/12345"}, {"id": 88002, "rowId": "EFGH...", "tlf": "max@example.com"} ] } # User ändert Email in Advoware → max@example.com zu new@example.com GET /api/v1/advonet/Beteiligte/104860 { "betNr": 104860, "rowId": "FBAB...", # STILL UNCHANGED! "kommunikation": [ {"id": 88001, "rowId": "ABCD...", "tlf": "0511/12345"}, {"id": 88002, "rowId": "IJKL...", "tlf": "new@example.com"} # ← rowId CHANGED! ] } ``` **Lösung**: MD5-Hash aller Kommunikation-rowIds ```python # Hash-Berechnung komm_rowids = sorted([k['rowId'] for k in kommunikationen]) komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16] # Speicherort in EspoCRM cbeteiligte.kommunikationHash = "a3f5d2e8b1c4f6a9" # 16 chars # Vergleich if stored_hash != current_hash: # Kommunikation hat sich geändert! ``` **Performance**: Hash nur neu berechnen wenn Advoware neu geladen wurde. ### Marker-Strategie für Bidirectional Matching **Problem**: Wie matchen wir Einträge zwischen beiden Systemen? ``` EspoCRM: - Email: max@example.com - Phone: +49 511 12345 Advoware: - tlf: "max@example.com", kommKz=4 - tlf: "+49 511 12345", kommKz=1 → Wie wissen wir, dass EspoCRM-Email zu Advoware-ID 88002 gehört? ``` **Lösung**: Base64-Marker in Advoware `bemerkung`-Feld ```python # Format marker = f"[ESPOCRM:{base64_value}:{kommKz}]" # Beispiel bemerkung = "[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4]" # Base64 von "max@example.com", kommKz=4 # Decoding import base64 decoded = base64.b64decode("bWF4QGV4YW1wbGUuY29t").decode() # → "max@example.com" ``` **User-Bemerkungen werden preserviert**: ```python bemerkung = "[ESPOCRM:...:4] Nur vormittags erreichbar" └─ Marker ─┘ └─ User-Text (bleibt erhalten!) ─┘ ``` ### 6 Sync-Varianten Der Kommunikation-Sync behandelt **6 verschiedene Szenarien**: | Var | Szenario | EspoCRM | Advoware | Aktion | |-----|----------|---------|----------|--------| | **Var1** | Neu in EspoCRM | +email | - | CREATE in Advoware (mit Marker) | | **Var2** | Gelöscht in EspoCRM | -email | (marker) | CREATE Empty Slot | | **Var3** | Gelöscht in Advoware | (entry) | -phone | DELETE in EspoCRM | | **Var4** | Neu in Advoware | - | +phone | CREATE in EspoCRM (setze Marker) | | **Var5** | Geändert in EspoCRM | email↑ | (synced) | UPDATE in Advoware | | **Var6** | Geändert in Advoware | (synced) | phone↑ | UPDATE in EspoCRM (update Marker) | #### Var1: Neu in EspoCRM → CREATE in Advoware ```python # EspoCRM hat neue Email espo_item = { 'emailAddress': 'new@example.com', 'type': 'Office' } # Mapper komm_kz = 4 # MailGesch (Office Email) base64_value = base64.b64encode(b'new@example.com').decode() marker = f"[ESPOCRM:{base64_value}:4]" # POST zu Advoware POST /api/v1/advonet/Beteiligte/104860/Kommunikationen { "tlf": "new@example.com", "kommKz": 4, "bemerkung": "[ESPOCRM:bmV3QGV4YW1wbGUuY29t:4]", "online": true } # Response { "id": 88003, "rowId": "MNOP...", "tlf": "new@example.com", ... } ``` #### Var2: Gelöscht in EspoCRM → Empty Slot **Problem**: Advoware DELETE gibt 403! **Lösung**: "Empty Slot" - Marker bleibt, aber tlf wird leer ```python # User löscht Email in EspoCRM # Advoware hat noch: { "id": 88003, "tlf": "old@example.com", "bemerkung": "[ESPOCRM:b2xkQGV4YW1wbGUuY29t:4]" } # PUT zu Advoware PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/88003 { "tlf": "", # LEER! "bemerkung": "[ESPOCRM-SLOT:4]", # Spezieller Slot-Marker "online": false } # Result: Slot kann später wiederverwendet werden ``` **Slot-Reuse**: Wenn User neue Email anlegt, wird Slot wiederverwendet: ```python # EspoCRM: User legt neue Email an espo_item = {'emailAddress': 'reuse@example.com', 'type': 'Office'} # Sync findet Empty Slot mit kommKz=4 slot = next(k for k in advo_komm if k['bemerkung'] == '[ESPOCRM-SLOT:4]') # PUT statt POST PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/{slot.id} { "tlf": "reuse@example.com", "bemerkung": "[ESPOCRM:cmV1c2VAZXhhbXBsZS5jb20=:4]", "online": true } ``` #### Var3: Gelöscht in Advoware → DELETE in EspoCRM ```python # Advoware: User löscht Telefonnummer # EspoCRM hat noch: espo_item = { 'phoneNumber': '+49 511 12345', 'type': 'Office' } # Sync erkennt: Marker fehlt in Advoware # → DELETE in EspoCRM DELETE /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers/{phoneId} ``` #### Var4: Neu in Advoware → CREATE in EspoCRM ```python # Advoware: User legt neue Telefonnummer an advo_item = { "id": 88004, "tlf": "+49 30 987654", "kommKz": 1, "bemerkung": null # KEIN Marker! } # Sync erkennt: Neuer Eintrag ohne Marker # → CREATE in EspoCRM POST /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers { "phoneNumber": "+49 30 987654", "type": "Office" } # → UPDATE Advoware (setze Marker) PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/88004 { "tlf": "+49 30 987654", "bemerkung": "[ESPOCRM:KzQ5IDMwIDk4NzY1NA==:1]" } ``` **Initial Sync Value-Matching**: Bei erstem Sync werden identische Werte NICHT doppelt angelegt: ```python # Initial Sync (kein kommunikationHash in EspoCRM) espo_items = [{'emailAddress': 'max@example.com'}] advo_items = [{'tlf': 'max@example.com', 'bemerkung': null}] # Kein Marker # Ohne Value-Matching → 2x max@example.com! # Mit Value-Matching → Nur Marker setzen, kein CREATE if is_initial_sync and value in advo_values: # Nur Marker setzen PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{id} { "tlf": "max@example.com", "bemerkung": "[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4]" } ``` #### Var5: Geändert in EspoCRM → UPDATE in Advoware ```python # User ändert Email in EspoCRM old_value = "old@example.com" new_value = "new@example.com" # Advoware findet Entry via Marker advo_item = find_by_marker(old_value, kommKz=4) # UPDATE Advoware PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{advo_item.id} { "tlf": "new@example.com", "bemerkung": "[ESPOCRM:bmV3QGV4YW1wbGUuY29t:4]", # Neuer Marker! "online": true } ``` #### Var6: Geändert in Advoware → UPDATE in EspoCRM ```python # User ändert Telefonnummer in Advoware advo_item = { "id": 88001, "tlf": "+49 511 999999", # GEÄNDERT! "bemerkung": "[ESPOCRM:KzQ5IDUxMSAxMjM0NQ==:1]" # Alter Marker! } # Sync findet EspoCRM-Entry via Marker espo_item = find_by_marker(decode_marker(bemerkung)) # UPDATE EspoCRM PATCH /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers/{espo_item.id} { "phoneNumber": "+49 511 999999" } # UPDATE Advoware Marker PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/88001 { "tlf": "+49 511 999999", "bemerkung": "[ESPOCRM:KzQ5IDUxMSA5OTk5OTk=:1]" # Neuer Marker! } ``` ### Konflikt-Behandlung **Bei Beteiligte-Konflikt**: Kommunikation Sync mit `direction='to_advoware'` ```python if beteiligte_conflict: # Kommunikation: Nur EspoCRM → Advoware await komm_sync.sync_bidirectional( entity_id, betnr, direction='to_advoware' # ← WICHTIG! ) ``` **Effekt**: - ✅ Var1 (EspoCRM neu) → CREATE in Advoware - ✅ Var2 (EspoCRM delete) → Empty Slot - ✅ Var5 (EspoCRM change) → UPDATE Advoware - ❌ Var3 (Advoware delete) → SKIP (nicht zu EspoCRM) - ❌ Var4 (Advoware neu) → SKIP (nicht zu EspoCRM) - ❌ Var6 (Advoware change) → REVERT (EspoCRM → Advoware) **Var6-Revert**: Advoware-Änderungen werden rückgängig gemacht: ```python # User änderte in Advoware: phone = "NEW" # EspoCRM hat noch: phone = "OLD" # Bei direction='to_advoware': # → PUT zu Advoware mit "OLD" (Revert!) PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{id} { "tlf": "OLD", # ← EspoCRM Value! "bemerkung": "[ESPOCRM:...:1]" } ``` ### Bekannte Einschränkungen #### 1. Advoware kommKz ist READ-ONLY bei PUT **Problem**: `kommKz` kann nach CREATE nicht mehr geändert werden ```python # POST: kommKz=1 (TelGesch) - ✅ Funktioniert POST /api/v1/advonet/Beteiligte/.../Kommunikationen {"tlf": "...", "kommKz": 1} # PUT: kommKz=3 (Mobil) - ❌ Wird ignoriert! PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/88001 {"tlf": "...", "kommKz": 3} # ← Ignoriert, bleibt bei 1! ``` **Workaround**: Type-Änderungen erfordern DELETE + CREATE (aber DELETE gibt 403!) **Praktische Lösung**: - User muss Type-Änderungen manuell in Advoware vornehmen - Notification in EspoCRM: "Typ-Änderung nicht möglich, bitte manuell in Advoware" #### 2. Advoware DELETE gibt 403 **Problem**: `/api/v1/advonet/Beteiligte/.../Kommunikationen/{id}` DELETE → 403 **Workaround**: Empty Slots (siehe Var2) **Vorteil**: Slots können wiederverwendet werden, keine Duplikate #### 3. Advoware GET kommKz=0 Bug **Problem**: Bei GET sind alle `kommKz` Werte 0 (Advoware-Bug) ```python # POST mit kommKz=4 ✅ Response: {"id": 88001, "kommKz": 4} # GET ❌ Response: {"id": 88001, "kommKz": 0} # Immer 0! ``` **Workaround**: kommKz aus EspoCRM + Marker ist "Source of Truth" ```python # Marker enthält kommKz marker = "[ESPOCRM:...:4]" # ↑ kommKz=4 ``` #### 4. Keine Änderungs-Detection für User-Bemerkungen **Problem**: User-Bemerkungen werden nicht synchronisiert ``` Advoware: bemerkung = "[ESPOCRM:...:4] Nur vormittags" EspoCRM: (keine Bemerkung) → User-Text "Nur vormittags" bleibt nur in Advoware ``` **Grund**: Marker + User-Text sind gemischt, Parsing komplex **Workaround**: User-Text ist Advoware-spezifisch, wird nicht synced #### 5. Performance: Sequentielle Verarbeitung **Problem**: Var1-6 werden sequenziell verarbeitet (N API-Calls) **Grund**: Advoware hat keinen Batch-Endpoint **Auswirkung**: Bei 20 Änderungen = 20 API-Calls (dauert ~10 Sekunden) **Workaround**: Lock-TTL ist 5 Minuten, reicht für normale Anzahl --- ## Sync Status Management ### Status-Werte | Status | Gesetzt von | Bedeutung | Cron Action | |--------|-------------|-----------|-------------| | `pending_sync` | EspoCRM | Neu, wartet auf ersten Sync | ✅ Sync | | `dirty` | EspoCRM | Geändert, Sync nötig | ✅ Sync | | `syncing` | Python | Sync läuft (Lock aktiv) | ❌ Skip | | `clean` | Python | Synchronisiert | ✅ Sync (nach 24h) | | `failed` | Python | Fehler, Retry möglich | ✅ Retry mit Backoff | | `permanently_failed` | Python | Max Retries (5x) | ✅ Auto-Reset nach 24h | | `conflict` | Python | Konflikt (optional) | ✅ Sync | | `deleted_in_advoware` | Python | 404 von Advoware | ❌ Skip | ### Status-Transitions ``` CREATE → pending_sync ↓ Webhook/Cron syncing ↓ Success clean UPDATE → dirty ↓ Webhook/Cron syncing ↓ Success clean Fehler → syncing ↓ Error failed (retry 1-4) ↓ Max Retries permanently_failed ↓ 24h später (Cron Auto-Reset) failed (retry 1) Konflikt → syncing ↓ Conflict Detected conflict ↓ Webhook/Cron syncing (EspoCRM wins) ↓ Success clean ``` ### Retry-Strategie mit Exponential Backoff ```python RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h MAX_SYNC_RETRIES = 5 # Retry Logic retry_count = espo_bet.get('syncRetryCount', 0) backoff_minutes = RETRY_BACKOFF_MINUTES[retry_count] next_retry = now + timedelta(minutes=backoff_minutes) # Status Update if retry_count >= MAX_SYNC_RETRIES: status = 'permanently_failed' auto_reset_at = now + timedelta(hours=24) else: status = 'failed' next_retry_at = next_retry ``` **Cron Skip-Logik**: ```python # Überspringe Entities wenn: # 1. syncStatus='syncing' (Lock aktiv) # 2. syncNextRetry > NOW (noch in Backoff-Phase) if sync_next_retry and now < sync_next_retry: continue # Skip this entity ``` ### Lock-Management **Redis Distributed Lock**: ```python lock_key = f"sync_lock:cbeteiligte:{entity_id}" acquired = redis.set(lock_key, "locked", nx=True, ex=300) # 5min TTL ``` **Lock Release mit Nested try/finally**: ```python lock_acquired = False try: lock_acquired = await sync_utils.acquire_sync_lock(entity_id) if not lock_acquired: return # Anderer Prozess synced bereits try: # Sync-Logik await sync_beteiligte(...) await sync_kommunikation(...) status = 'clean' except Exception as e: status = 'failed' raise finally: # GARANTIERTE Lock-Release (auch bei Exception) try: await sync_utils.release_sync_lock(entity_id, status, ...) except Exception as release_error: # Fallback: Force Redis lock delete await redis.delete(lock_key) except Exception as outer_error: # Lock wurde nicht acquired, kein Release nötig pass ``` **Warum nested try/finally?** Garantiert Lock-Release auch bei kritischen Fehlern (Timeout, Connection Lost, Memory Error). --- ## Bekannte Einschränkungen ### Advoware API Limits 1. **DELETE gibt 403** für Beteiligte und Kommunikationen - Workaround: Soft-Delete mit Notification (Beteiligte) oder Empty Slots (Kommunikation) 2. **Nur 8 von 14 Beteiligte-Felder funktionieren** - Ignoriert: art, kurzname, geburtsname, familienstand, handelsRegisterNummer, registergericht - Workaround: Manuelle Pflege dieser Felder in Advoware 3. **kommKz ist READ-ONLY bei PUT** - Typ-Änderungen (z.B. TelGesch → Mobil) nicht möglich - Workaround: Notification, User ändert manuell 4. **kommKz=0 bei GET** (Advoware-Bug) - Workaround: Marker enthält korrekten kommKz-Wert 5. **Keine Batch-Endpoints** - N+1 Problem bei vielen Änderungen - Workaround: Sequentielle Verarbeitung mit Lock-TTL 5min ### System-Limits 1. **Max 100 Entities pro Cron-Run** - Grund: Timeout-Vermeidung - Workaround: Priorisierung (pending_sync > dirty > failed) 2. **Lock-TTL 5 Minuten** - Bei sehr vielen Kommunikations-Änderungen evtl. zu kurz - Workaround: Bei >50 Kommunikationen wird Lock erneuert 3. **Keine Transaktionen** - Partial Updates möglich bei Fehlern - Workaround: Hash wird nur bei vollständigem Erfolg updated 4. **Webhook-Race-Conditions** - Mehrere schnelle Updates können Race Conditions erzeugen - Workaround: Redis Lock + Cron-Fallback --- ## Troubleshooting ### Symptom: Entity bleibt bei 'syncing' **Ursache**: Lock wurde nicht released (Server-Crash, Timeout) **Lösung**: ```bash # Redis Lock manuell löschen redis-cli DEL sync_lock:cbeteiligte:68e4aef68d2b4fb98 # Entity Status zurücksetzen # In EspoCRM Admin: syncStatus = 'dirty' ``` ### Symptom: Entity bei 'permanently_failed' **Ursache**: 5 Sync-Versuche fehlgeschlagen **Analyse**: ```python # Check syncErrorMessage in EspoCRM entity = await espocrm.get_entity('CBeteiligte', entity_id) print(entity['syncErrorMessage']) # z.B. "404 Not Found: betNr 104860" ``` **Lösungen**: - **404**: betNr existiert nicht in Advoware → betNr in EspoCRM korrigieren - **400**: Validation-Error → Daten prüfen (z.B. rechtsform nicht in Liste) - **401**: Auth-Error → Advoware Token prüfen - **503**: Advoware down → Warten auf Auto-Reset (24h) **Manueller Reset**: ```python # In EspoCRM Admin: syncStatus = 'dirty' syncRetryCount = 0 syncNextRetry = null ``` ### Symptom: Duplikate in Kommunikation **Ursache**: Initial Sync ohne Value-Matching (sollte gefixt sein) **Analyse**: ```python # Check kommunikationHash entity = await espocrm.get_entity('CBeteiligte', entity_id) print(entity['kommunikationHash']) # null = Initial Sync lief nicht # Check Marker in Advoware advo_bet = await advoware.get_beteiligter(betnr) for komm in advo_bet['kommunikation']: print(komm['bemerkung']) # Sollte [ESPOCRM:...:X] enthalten ``` **Lösung**: ```python # Manuell Duplikate entfernen (via EspoCRM) # Dann syncStatus='dirty' → Re-Sync setzt Marker ``` ### Symptom: Hash mismatch nach Sync **Ursache**: Partial Update (einige Var fehlgeschlagen) **Analyse**: ```python # Check syncErrorMessage entity = await espocrm.get_entity('CBeteiligte', entity_id) print(entity['syncErrorMessage']) # z.B. "Var1: 2 succeeded, Var4: 1 failed (403)" ``` **Lösung**: - Hash wird NUR bei vollständigem Erfolg updated - Bei partial failure: syncStatus='failed', Hash bleibt alt - Retry wird Diff korrekt berechnen ### Symptom: Konflikt-Loop **Ursache**: User ändert in Advoware während Sync läuft **Lösung**: Lock sollte dies verhindern, aber bei Race Condition: ```python # Check syncStatus History (in Logs) # Wenn Konflikt > 3x in 15min → Manuelle Intervention # Temporär Webhook disable für diese Entity # In EspoCRM Admin: syncStatus = 'clean' # User kontaktieren, Änderungen koordinieren ``` --- ## Best Practices ### 1. Monitoring **Metriken**: - `sync_duration_seconds` - Sync-Dauer pro Entity - `sync_errors_total` - Anzahl Fehler pro Error-Type - `sync_retries_total` - Retry-Count pro Entity - `sync_conflicts_total` - Konflikt-Rate **Alerts**: - permanently_failed > 10 Entities - sync_duration > 60 Sekunden - sync_errors > 50/hour ### 2. Daten-Qualität **Vor Sync prüfen**: - ✅ rechtsform existiert in Advoware `/Rechtsformen` - ✅ Email-Format valide - ✅ Telefonnummer Format valide (E.164 empfohlen) **Validation**: ```python # In Event Handler vor Sync validation_errors = await sync_utils.validate_entity(espo_entity) if validation_errors: await sync_utils.release_sync_lock( entity_id, 'failed', f"Validation failed: {validation_errors}" ) return ``` ### 3. Testing **Test-Entities**: - Dedizierte Test-betNr in Advoware (z.B. 999xxx) - Test-Entities in EspoCRM mit Präfix "TEST-" - Separate Service-Account für Tests **Integration-Tests**: ```bash # Scenario: Create-Update-Conflict pytest tests/integration/test_beteiligte_sync.py::test_full_lifecycle ``` ### 4. Rollback bei Problemen **Webhook temporär disablen**: ```bash # In EspoCRM Webhook-Settings: # vmh.beteiligte.* → Status: Inactive ``` **Cron temporär stoppen**: ```bash # In Kubernetes/systemd: kubectl scale deployment motia-cron --replicas=0 ``` **Entities zurücksetzen**: ```sql -- In EspoCRM Database (PostgreSQL) UPDATE c_beteiligte SET sync_status = 'pending_sync', sync_retry_count = 0 WHERE sync_status = 'permanently_failed'; ``` --- ## Weiterführende Links - [Beteiligte Sync Details](BETEILIGTE_SYNC.md) - [Kommunikation Sync Details](KOMMUNIKATION_SYNC.md) - [Field Mapping Referenz](ADVOWARE_BETEILIGTE_FIELDS.md) - [Advoware API Swagger](advoware/advoware_api_swagger.json) - [Troubleshooting Guide](TROUBLESHOOTING.md) - [Archiv: Historische Analysen](archive/)