Files
motia/bitbylaw/docs/SYNC_OVERVIEW.md
bitbylaw 89fc657d47 feat(sync): Implement comprehensive sync fixes and optimizations as of February 8, 2026
- 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.
2026-02-08 22:59:47 +00:00

29 KiB

Advoware Sync - System-Übersicht

Letzte Aktualisierung: 8. Februar 2026
Status: Production Ready


Inhaltsverzeichnis

  1. System-Architektur
  2. Beteiligte Sync (Stammdaten)
  3. Kommunikation Sync
  4. Sync Status Management
  5. Bekannte Einschränkungen
  6. 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 /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:

# 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 +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

# 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

  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:

# 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 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:

# 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';