- 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.
29 KiB
Advoware Sync - System-Übersicht
Letzte Aktualisierung: 8. Februar 2026
Status: ✅ Production Ready
Inhaltsverzeichnis
- System-Architektur
- Beteiligte Sync (Stammdaten)
- Kommunikation Sync
- Sync Status Management
- Bekannte Einschränkungen
- 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 | Central Sync Orchestrator |
| Cron Job | beteiligte_sync_cron_step.py | 15-Minuten Fallback |
| Beteiligte Sync | beteiligte_sync_utils.py | Stammdaten-Sync Logik |
| Kommunikation Sync | kommunikation_sync_utils.py | Phone/Email/Fax Sync |
| Mapper | espocrm_mapper.py | Entity Transformations |
| Advoware API | advoware.py | HMAC-512 Auth REST Client |
| EspoCRM API | 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/Rechtsformenexistieren)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:
# 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:
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
# 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!
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!
# 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
# 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
# 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:
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 | - | CREATE in Advoware (mit Marker) | |
| Var2 | Gelöscht in EspoCRM | (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
# 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
# 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:
# 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
# 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
# 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:
# 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
# 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
# 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'
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:
# 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
# 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)
# 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"
# 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
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:
# Ü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:
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:
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
-
DELETE gibt 403 für Beteiligte und Kommunikationen
- Workaround: Soft-Delete mit Notification (Beteiligte) oder Empty Slots (Kommunikation)
-
Nur 8 von 14 Beteiligte-Felder funktionieren
- Ignoriert: art, kurzname, geburtsname, familienstand, handelsRegisterNummer, registergericht
- Workaround: Manuelle Pflege dieser Felder in Advoware
-
kommKz ist READ-ONLY bei PUT
- Typ-Änderungen (z.B. TelGesch → Mobil) nicht möglich
- Workaround: Notification, User ändert manuell
-
kommKz=0 bei GET (Advoware-Bug)
- Workaround: Marker enthält korrekten kommKz-Wert
-
Keine Batch-Endpoints
- N+1 Problem bei vielen Änderungen
- Workaround: Sequentielle Verarbeitung mit Lock-TTL 5min
System-Limits
-
Max 100 Entities pro Cron-Run
- Grund: Timeout-Vermeidung
- Workaround: Priorisierung (pending_sync > dirty > failed)
-
Lock-TTL 5 Minuten
- Bei sehr vielen Kommunikations-Änderungen evtl. zu kurz
- Workaround: Bei >50 Kommunikationen wird Lock erneuert
-
Keine Transaktionen
- Partial Updates möglich bei Fehlern
- Workaround: Hash wird nur bei vollständigem Erfolg updated
-
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:
# 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:
# 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:
# In EspoCRM Admin:
syncStatus = 'dirty'
syncRetryCount = 0
syncNextRetry = null
Symptom: Duplikate in Kommunikation
Ursache: Initial Sync ohne Value-Matching (sollte gefixt sein)
Analyse:
# 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:
# Manuell Duplikate entfernen (via EspoCRM)
# Dann syncStatus='dirty' → Re-Sync setzt Marker
Symptom: Hash mismatch nach Sync
Ursache: Partial Update (einige Var fehlgeschlagen)
Analyse:
# 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:
# 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 Entitysync_errors_total- Anzahl Fehler pro Error-Typesync_retries_total- Retry-Count pro Entitysync_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:
# 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:
# Scenario: Create-Update-Conflict
pytest tests/integration/test_beteiligte_sync.py::test_full_lifecycle
4. Rollback bei Problemen
Webhook temporär disablen:
# In EspoCRM Webhook-Settings:
# vmh.beteiligte.* → Status: Inactive
Cron temporär stoppen:
# In Kubernetes/systemd:
kubectl scale deployment motia-cron --replicas=0
Entities zurücksetzen:
-- In EspoCRM Database (PostgreSQL)
UPDATE c_beteiligte
SET sync_status = 'pending_sync',
sync_retry_count = 0
WHERE sync_status = 'permanently_failed';