Files
motia/bitbylaw/docs/archive/BETEILIGTE_SYNC.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

16 KiB

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)

    • Subscribes: vmh.beteiligte.{create,update,delete,sync_check}
    • Verarbeitet Sync-Events
    • Verwendet Redis distributed lock
  2. Cron Job (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)

    • Lock-Management (Redis distributed lock)
    • Timestamp-Vergleich
    • Merge-Utility für Advoware PUT
    • Notifications
  4. Mapper (espocrm_mapper.py)

    • map_cbeteiligte_to_advoware() - EspoCRM → Advoware
    • map_advoware_to_cbeteiligte() - Advoware → EspoCRM
    • Nur Stammdaten, keine Kontaktdaten
  5. APIs

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

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)

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)

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)

merged = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
  • Keine Code-Duplikation
  • Konsistentes Logging
  • Wiederverwendbar

4. Max Retry Limit (Robustheit)

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)

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

# 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:
    # 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 Felderbeteiligte.mobil = kommKz 3
  4. Wert-Pattern@ in Wert = Email (kommKz 4)
  5. Default → Fallback (TelGesch=1, MailGesch=4)

Mapping EspoCRM phoneNumberData.type → kommKz:

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

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

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

Helper Function (DRY-Prinzip):

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

-- In EspoCRM
SELECT syncStatus, COUNT(*) 
FROM c_beteiligte 
GROUP BY syncStatus;

Failed Syncs

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

cd /opt/motia-app/bitbylaw
source python_modules/bin/activate
python scripts/test_beteiligte_sync.py

Manual Test

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

# 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

# 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

cd /opt/motia-app/bitbylaw
git pull
source python_modules/bin/activate
pip install -r requirements.txt

2. Restart Motia

# Motia Workbench restart (lädt neue Steps)
systemctl restart motia-workbench  # oder entsprechender Befehl

3. Verify

# 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 für Implementierungs-Template

Siehe auch