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

77 KiB

Kommunikation-Synchronisation: Analyse EspoCRM ↔ Advoware

Erstellt: 8. Februar 2026
Status: API vollständig getestet
Basis: Advoware API v1, EspoCRM Custom Entity


📋 Inhaltsverzeichnis

  1. Executive Summary
  2. Advoware API Analyse
  3. EspoCRM Konzept
  4. Feld-Mapping
  5. Sync-Strategie
  6. Implementierungsplan

1. Executive Summary

Was funktioniert

Operation Status Felder
POST (Create) Vollständig Alle 4 Felder
GET (Read) Vollständig Enthalten in Beteiligte-Response
PUT (Update) ⚠️ Teilweise 3 von 4 Feldern
DELETE 403 Forbidden Nicht verfügbar

⚠️ Kritische Einschränkungen

  1. kommKz ist READ-ONLY bei PUT: Kommunikationstyp kann nach Erstellung nicht geändert werden
  2. Kein DELETE: Manuelle Intervention via Notification erforderlich
  3. kommArt vs. kommKz: kommArt wird automatisch von kommKz abgeleitet

2. Advoware API Analyse

2.1 Endpoints

POST   /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen
PUT    /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen/{kommunikationId}
GET    /api/v1/advonet/Beteiligte/{beteiligterId}  (enthält kommunikation array)
DELETE /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen/{kommunikationId}  ❌ 403

2.2 Datenmodell

POST/PUT Request Body

{
  "tlf": "string (nullable)",
  "bemerkung": "string (nullable)",
  "kommKz": "integer (enum 1-12)",
  "online": "boolean"
}

Response (GET/POST/PUT)

{
  "id": 88002,
  "betNr": 104860,
  "rowId": "FBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA",
  "kommArt": 0,
  "kommKz": 1,
  "tlf": "0511/12345-60",
  "bemerkung": null,
  "online": false
}

2.3 KommKz Enum (Kommunikationskennzeichen)

Wert Name Beschreibung
1 TelGesch Geschäftstelefon
2 FaxGesch Geschäftsfax
3 Mobil Mobiltelefon
4 MailGesch Geschäfts-Email
5 Internet Website/URL
6 TelPrivat Privattelefon
7 FaxPrivat Privatfax
8 MailPrivat Private Email
9 AutoTelefon Autotelefon
10 Sonstige Sonstige Kommunikation
11 EPost E-Post (DE-Mail)
12 Bea BeA (Besonderes elektronisches Anwaltspostfach)

2.4 Feld-Analyse ( API-verifiziert)

⚠️ KRITISCHER BUG: kommKz in GET immer 0

Entdeckung: Bei allen Tests zeigt der GET-Endpoint für ALLE Kommunikationen:

{
  "kommKz": 0,
  "kommArt": 0
}

Beobachtungen:

  1. POST Response: kommKz wird korrekt zurückgegeben (z.B. 3 für Mobil)
  2. PUT Response: kommKz wird zurückgegeben (aber oft ignoriert bei Änderungsversuch)
  3. GET Response: kommKz ist IMMER 0 (für alle Kommunikationen!)

Test-Beispiel:

POST mit kommKz=3 (Mobil)
→ POST Response: kommKz=3 ✓

GET nach POST
→ GET Response: kommKz=0 ✗

PUT mit kommKz=7 (Versuch zu ändern)
→ PUT Response: kommKz=3 (ignoriert!)
→ GET Response: kommKz=0

Alle 11 getesteten Kommunikationen zeigen in GET: kommKz=0, kommArt=0

Mögliche Ursachen:

  1. Fehlende Berechtigung zum Lesen dieser Felder (Role-basiert)
  2. Bug in Advoware GET-Serialisierung
  3. kommKz wird nur intern gespeichert, nicht im Hauptdatensatz

Implikationen für Sync:

  • ⚠️ kommKz kann NICHT via GET verifiziert werden
  • ⚠️ Keine Möglichkeit den aktuellen Typ zu lesen
  • ⚠️ Sync-Strategie muss angepasst werden: EspoCRM ist "Source of Truth"

POST (CREATE) - Alle Felder

✅ tlf          - string, nullable - Telefonnummer/Email/URL
✅ bemerkung    - string, nullable - Notiz/Beschreibung
✅ kommKz       - integer 1-12     - Kommunikationstyp
✅ online       - boolean          - Online-Kommunikation? (Email/Internet)

Test-Ergebnis: Alle 4 Felder können bei POST gesetzt werden.

PUT (UPDATE) - 3 von 4 Feldern

✅ tlf          - WRITABLE   - Kann geändert werden
✅ bemerkung    - WRITABLE   - Kann geändert werden
❌ kommKz       - READ-ONLY  - Kann NICHT geändert werden (bleibt beim Ursprungswert!)
✅ online       - WRITABLE   - Kann geändert werden

Test-Ergebnis:

  • kommKz wird bei PUT akzeptiert, aber ignoriert!
  • Response enthält oft den ursprünglichen kommKz-Wert (aber nicht zuverlässig)
  • WICHTIG: GET zeigt IMMER kommKz=0 (nicht nutzbar für Verifizierung!)
  • rowId ändert sich bei jedem erfolgreichen PUT

Response-Only Felder (automatisch generiert)

🔒 id           - integer     - Kommunikations-ID (PK)
🔒 betNr        - integer     - Beteiligten-ID (FK)
🔒 rowId        - string      - Änderungserkennung (Base64, ~40 Zeichen)
🔒 kommArt      - integer     - Wird von kommKz abgeleitet

Wichtig: kommArt ist ein internes Feld, das Advoware automatisch aus kommKz berechnet.

2.5 Test-Ergebnisse

Test 1: POST - Neue Kommunikation erstellen

POST /api/v1/advonet/Beteiligte/104860/Kommunikationen

Request:
{
  "kommKz": 1,
  "tlf": "+49 511 123456-10",
  "bemerkung": "TEST: Hauptnummer",
  "online": false
}

Response: 201 Created
[{
  "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOEAFAAAA",
  "id": 149331,
  "betNr": 104860,
  "kommArt": 1,
  "tlf": "+49 511 123456-10",
  "bemerkung": "TEST: Hauptnummer",
  "kommKz": 1,
  "online": false
}]

Status: Erfolgreich

Test 2: PUT - tlf ändern

PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331

Request:
{
  "kommKz": 1,
  "tlf": "+49 511 999999-99",
  "bemerkung": "TEST: Hauptnummer",
  "online": false
}

Response: 200 OK
{
  "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOFABAAAA",  # GEÄNDERT!
  "id": 149331,
  "betNr": 104860,
  "kommArt": 1,
  "tlf": "+49 511 999999-99",  # GEÄNDERT!
  "bemerkung": "TEST: Hauptnummer",
  "kommKz": 1,
  "online": false
}

Status: tlf erfolgreich geändert, rowId aktualisiert

Test 3: PUT - kommKz ändern

PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331

Request:
{
  "kommKz": 6,  # Versuche zu ändern: TelGesch → TelPrivat
  "tlf": "+49 511 999999-99",
  "bemerkung": "TEST: Geändert",
  "online": false
}

Response: 200 OK
{
  "rowId": "FBABAAAAEMDGACAAFLBGNLBAAAAAEDNHOFABAAAA",  # GEÄNDERT
  "id": 149331,
  "betNr": 104860,
  "kommArt": 1,
  "tlf": "+49 511 999999-99",
  "bemerkung": "TEST: Geändert",
  "kommKz": 1,  # NICHT GEÄNDERT! Bleibt bei 1
  "online": false
}

Status: kommKz wird IGNORIERT (bleibt bei 1), aber rowId ändert sich trotzdem

Test 4: DELETE

DELETE /api/v1/advonet/Beteiligte/104860/Kommunikationen/149331

Response: 403 Forbidden

Status: DELETE nicht verfügbar (wie bei Adressen/Bankverbindungen)


3. EspoCRM Konzept

3.1 Aktuelle Situation ( API-verifiziert)

Status: CKommunikation Entity existiert NICHT

EspoCRM hat KEINE separate Kommunikations-Entity. Stattdessen:

Standard EspoCRM Felder in CBeteiligte

{
  "id": "68e4af00172be7924",
  "name": "Max Mustermann",
  
  // Primäre Kommunikation (einfache Felder)
  "emailAddress": "max@example.com",
  "phoneNumber": "+49 511 12345",
  
  // Erweiterte Kommunikation (Arrays)
  "emailAddressData": [
    {
      "emailAddress": "max@example.com",
      "lower": "max@example.com",
      "primary": true,
      "optOut": false,
      "invalid": false
    },
    {
      "emailAddress": "max.private@gmail.com",
      "lower": "max.private@gmail.com",
      "primary": false,
      "optOut": false,
      "invalid": false
    }
  ],
  
  "phoneNumberData": [
    {
      "phoneNumber": "+49 511 12345",
      "primary": true,
      "type": "Office",
      "optOut": false,
      "invalid": false
    }
  ]
}

Wichtige Erkenntnisse:

  • Keine IDs in emailAddressData/phoneNumberData
  • Kein Typ-Feld (keine kommKz-Unterscheidung möglich)
  • primary Flag für Haupt-Kommunikation
  • Arrays unterstützen mehrere Einträge
  • ⚠️ NUR Email und Phone - keine Fax, BeA, etc.

3.2 Zwei Sync-Strategien

Option A: Integration in Beteiligte-Sync (Einfach, eingeschränkt)

Vorteile:

  • Keine neuen Entities erforderlich
  • Nutzt vorhandene EspoCRM-Struktur
  • Einfache Implementierung

Nachteile:

  • Nur Email und Telefon (keine Fax, BeA, etc.)
  • Kein Typ-Mapping (alle Emails sind "MailGesch", alle Phones sind "TelGesch")
  • Kein Matching via ID möglich (nur via Wert)
  • Schwierig zu synchronisieren (Array-Manipulation in Beteiligte-Update)

Umsetzung:

# In beteiligte_sync.py nach Stammdaten-Update
await sync_kommunikation_from_espocrm_data(
    espo_entity['emailAddressData'],
    espo_entity['phoneNumberData'],
    betnr
)

Option B: Custom CKommunikation Entity (Empfohlen)

Vorteile:

  • Vollständige Unterstützung aller 12 Advoware-Typen
  • Separate Entity mit eigener ID (für Matching)
  • Typ-Feld für kommKz-Mapping
  • Saubere Trennung (separater Sync-Service)
  • Flexibel erweiterbar

Nachteile:

  • ⚠️ Custom Entity muss in EspoCRM angelegt werden
  • ⚠️ Zusätzlicher Sync-Service erforderlich

Entity-Design:

{
  "id": "string",
  "name": "string (auto-generiert)",
  "deleted": false,
  
  // Kommunikationsdaten
  "kommunikationstyp": "enum (kommKz)",
  "originalKommunikationstyp": "enum (IMMUTABLE nach CREATE)", 
  "wert": "string (tlf)",
  "bemerkung": "text",
  "isOnline": "bool",
  "isPrimary": "bool",
  
  // Beziehung
  "beteiligteId": "string (FK zu CBeteiligte)",
  "beteiligteName": "string (Link-Name)",
  
  // Advoware Sync
  "advowareId": "int",
  "advowareRowId": "varchar(50)",
  "syncStatus": "enum (clean|dirty|failed)",
  "advowareLastSync": "datetime",
  "syncErrorMessage": "text"
}

Wichtig: originalKommunikationstyp speichert den Typ bei Erstellung und ist IMMUTABLE. Dies wird benötigt weil:

  1. kommKz in Advoware GET nicht lesbar ist (Bug: immer 0)
  2. kommKz in Advoware nicht änderbar ist (READ-ONLY)
  3. EspoCRM muss als "Source of Truth" für den Typ dienen

3.3 Empfehlung

➡️ Option B (Custom Entity) wird DRINGEND EMPFOHLEN weil:

  1. Vollständigkeit: Alle 12 Advoware-Typen unterstützt (nicht nur Email/Phone)
  2. Matching: Entity-ID ermöglicht stabiles Matching
  3. Wartbarkeit: Saubere Trennung von Stammdaten und Kommunikation
  4. Konsistenz: Gleicher Ansatz wie Adressen und Bankverbindungen (separate Entities)

Migration von Standard zu Custom:

# Einmaliger Import der bestehenden Daten
async def migrate_standard_to_custom():
    for bet in all_beteiligte:
        # Importiere Emails
        for email in bet['emailAddressData']:
            await espo.create_entity('CKommunikation', {
                'beteiligteId': bet['id'],
                'kommunikationstyp': 'MailGesch',
                'wert': email['emailAddress'],
                'isPrimary': email['primary'],
                'isOnline': True
            })
        
        # Importiere Phones
        for phone in bet['phoneNumberData']:
            await espo.create_entity('CKommunikation', {
                'beteiligteId': bet['id'],
                'kommunikationstyp': 'TelGesch',
                'wert': phone['phoneNumber'],
                'isPrimary': phone['primary'],
                'isOnline': False
            })

3.2 Kommunikationstyp Enum

{
  "TelGesch": "Geschäftstelefon",
  "FaxGesch": "Geschäftsfax",
  "Mobil": "Mobiltelefon",
  "MailGesch": "Geschäfts-Email",
  "Internet": "Website",
  "TelPrivat": "Privattelefon",
  "FaxPrivat": "Privatfax",
  "MailPrivat": "Private Email",
  "AutoTelefon": "Autotelefon",
  "Sonstige": "Sonstige",
  "EPost": "E-Post",
  "Bea": "BeA"
}

3.3 Matching-Strategie

Problem: Keine stabile ID für Matching zwischen Systemen

Lösungsansätze:

  1. advowareId speichern (bevorzugt)

    • Bei CREATE: Speichere id von Advoware Response
    • Bei SYNC: Matche via advowareId
    • Stabil, zuverlässig
  2. Kombination tlf + kommKz (Fallback)

    • Matche via tlf-Wert UND Typ
    • ⚠️ Funktioniert nicht wenn tlf geändert wird
    • ⚠️ Duplikate möglich

Empfehlung: Variante 1 (advowareId) wie bei Adressen


4. Feld-Mapping

4.1 Kommunikationstypen-Mapping (Advoware → EspoCRM)

Da EspoCRM keine separate CKommunikation Entity hat, nutzen wir die Standard-Arrays:

kommKz Advoware Typ EspoCRM Ziel phoneNumberData.type Notiz
1 TelGesch phoneNumberData Office
2 FaxGesch phoneNumberData Fax
3 Mobil phoneNumberData Mobile
4 MailGesch emailAddressData -
5 Internet NICHT UNTERSTÜTZT - URL-Feld fehlt
6 TelPrivat phoneNumberData Home
7 FaxPrivat phoneNumberData Fax
8 MailPrivat emailAddressData -
9 AutoTelefon phoneNumberData Mobile
10 Sonstige phoneNumberData Other
11 EPost emailAddressData -
12 Bea emailAddressData -

11 von 12 Typen werden unterstützt (nur "Internet" fehlt)

4.2 Advoware → EspoCRM Mapping

Email-Kommunikation (kommKz: 4, 8, 11, 12)

# Advoware Kommunikation
{
  "id": 149331,
  "rowId": "eXqf+gAAAAAAAAA=",
  "kommKz": 4,          # MailGesch
  "kommArt": 1,         # Email
  "tlf": "max@example.com",
  "bemerkung": "Geschäftlich",
  "online": true
}

# → EspoCRM emailAddressData Element
{
  "emailAddress": "max@example.com",
  "lower": "max@example.com",
  "primary": true,      # Von Advoware (geschützt)
  "optOut": false,
  "invalid": false
}

Mapping-Logik:

  • tlfemailAddress und lower
  • onlineprimary (Advoware-Einträge sind immer primary=true)
  • bemerkung Geht verloren (kein Feld in EspoCRM)

Phone-Kommunikation (kommKz: 1, 2, 3, 6, 7, 9, 10)

# Advoware Kommunikation
{
  "id": 149332,
  "rowId": "eXqf+gAAAAAAAAB=",
  "kommKz": 3,          # Mobil
  "kommArt": 0,         # Telefon
  "tlf": "+49 170 1234567",
  "bemerkung": "Privat",
  "online": false
}

# → EspoCRM phoneNumberData Element
{
  "phoneNumber": "+49 170 1234567",
  "type": "Mobile",     # Von kommKz abgeleitet
  "primary": false,     # online=false
  "optOut": false,
  "invalid": false
}

Typ-Mapping:

KOMMKZ_TO_PHONE_TYPE = {
    1: 'Office',     # TelGesch
    2: 'Fax',        # FaxGesch  
    3: 'Mobile',     # Mobil
    6: 'Home',       # TelPrivat
    7: 'Fax',        # FaxPrivat
    9: 'Mobile',     # AutoTelefon
    10: 'Other'      # Sonstige
}

4.3 Matching-Strategie: bemerkung-Marker System IMPLEMENTIERT

Ausgangslage:

  • Separate CKommunikation Entity: Unpraktikabel
  • PhoneNumber/EmailAddress Relationships: 403 Forbidden
  • id Feld in emailAddressData: Wird ignoriert/entfernt
  • kommKz/kommArt in GET: Beide immer 0 (Bug)
  • Advoware hat eindeutige id pro Kommunikation
  • Top-Level Felder (telGesch, emailGesch, etc.) für EINEN Eintrag pro Typ

LÖSUNG: Marker in Advoware bemerkung-Feld

Marker-Format:

[ESPOCRM:base64_value:kommKz] Optionale User-Bemerkung
[ESPOCRM-SLOT:kommKz]  (bei gelöschten Einträgen)

Base64-Encoding: Der Wert wird URL-safe Base64-kodiert gespeichert.

Beispiele:

  • [ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Hauptadresse - Email von EspoCRM (max@example.com)
  • [ESPOCRM:KzQ5IDE3MCAxMjM0NTY3:1] Zentrale - Telefon (+49 170 1234567)
  • [ESPOCRM-SLOT:3] - Leerer Slot für Mobil (nach Löschung)
  • Wichtig: Nur vormittags - Von Advoware (kein Marker)

Warum Base64 statt Hash?

# Hash-Problem: Nicht rückrechenbar
old_hash = hash("old@example.com")  # abc123
new_value = "new@example.com"
# Kann old_hash nicht zu EspoCRM matchen!

# Base64-Lösung: Bidirektional
encoded = base64("old@example.com")  # b2xkQGV4YW1wbGUuY29t
decoded = decode(encoded)  # "old@example.com" ✅
# Kann dekodieren → Match in EspoCRM finden!

Typ-Erkennung (Priorität):

  1. Aus bemerkung-Marker (wenn vorhanden) → Genau
  2. Aus Top-Level Feldern (telGesch, emailGesch, etc.) → Genau für einen Eintrag
  3. Aus Wert (Email='@', Phone=Rest) → Grob
  4. Default (4=MailGesch, 1=TelGesch) → Fallback
def detect_kommkz(wert: str, beteiligte: dict, bemerkung: str = None) -> int:
    """Erkenne kommKz mit mehrstufiger Strategie"""
    
    # 1. Aus Marker
    if bemerkung and '[ESPOCRM:' in bemerkung:
        match = re.search(r'\[ESPOCRM(?:-SLOT)?:[^:]+:(\d+)\]', bemerkung)
        if match:
            return int(match.group(1))
    
    # 2. Aus Top-Level Feldern (für EINEN Eintrag genau)
    type_map = {
        'telGesch': 1, 'faxGesch': 2, 'mobil': 3, 'emailGesch': 4,
        'internet': 5, 'telPrivat': 6, 'faxPrivat': 7, 'email': 4,
        'autotelefon': 9, 'ePost': 11, 'bea': 12
    }
    for field, kommkz in type_map.items():
        if beteiligte.get(field) == wert:
            return kommkz
    
    # 3. Aus Wert (Email vs. Phone)
    if '@' in wert:
        return 4  # MailGesch
    elif wert.strip():
        return 1  # TelGesch
    
    return 0

Bidirektionaler Sync - 4 Szenarien:

Var1: Löschen in EspoCRM

# EspoCRM: max@example.com gelöscht
# Advoware: Eintrag mit "[ESPOCRM:abc:4] Geschäftlich"

# Sync erkennt: In Advoware aber nicht in EspoCRM
# → Leere Slot (Wert löschen, Typ behalten)
await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={
    'tlf': '',
    'bemerkung': '[ESPOCRM-SLOT:4]',  # Slot-Marker
    'kommKz': 4,  # Bleibt
    'online': False
})

Var2: Ändern in EspoCRM

# EspoCRM: max@old.com → max@new.com
# Advoware: "[ESPOCRM:old-hash:4]"

# Sync findet Eintrag via alten Hash
# → UPDATE mit neuem Wert
await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={
    'tlf': 'max@new.com',
    'bemerkung': '[ESPOCRM:new-hash:4]',
    'kommKz': 4,
    'online': True
})

Var3: Neu in EspoCRM

# EspoCRM: Neue Email hinzugefügt

# Sync sucht leeren Slot mit kommKz=4
empty_slots = [k for k in advo_komm 
               if '[ESPOCRM-SLOT:4]' in (k.get('bemerkung') or '')]

if empty_slots:
    # UPDATE leeren Slot
    await advoware.api_call(f'.../Kommunikationen/{slot_id}', 'PUT', ...)
else:
    # CREATE neue Kommunikation
    await advoware.api_call(f'.../Beteiligte/{betnr}/Kommunikationen', 'POST', ...)

Var4: Neu in Advoware

# Advoware: Neue Kommunikation (keine Marker)

# Sync erkennt: Kein Marker in bemerkung
# → Neue Kommunikation von Advoware

# Typ-Erkennung:
kommkz = detect_kommkz(wert, beteiligte, bemerkung)  # Mit Top-Level

# Zu EspoCRM synchen + Marker setzen
await espo.update_entity('CBeteiligte', bet_id, {
    'emailAddressData': [...],  # Neue Email
})

# Marker in Advoware setzen
await advoware.api_call(f'.../Kommunikationen/{advo_id}', 'PUT', data={
    'tlf': wert,
    'bemerkung': f'[ESPOCRM:{hash}:{kommkz}] {original_bemerkung}',
    'kommKz': kommkz,
    'online': online
})

Vorteile:

Vorteil Beschreibung
Bidirektional CREATE/UPDATE/DELETE in beide Richtungen
Stabiles Matching Via Hash in Marker
Typ-Erhaltung kommKz wird gespeichert und wiederverwendet
Slot-Wiederverwendung Gelöschte Einträge werden recycelt
Keine EspoCRM-Anpassung Nutzt Standard emailAddressData/phoneNumberData
User-Bemerkung Bleibt erhalten nach Marker
Minimaler Typ-Verlust Top-Level Felder verbessern Typ-Erkennung

Einschränkungen:

Einschränkung Impact Mitigation
⚠️ Typ-Info teilweise verloren Mehrere Telefone → alle TelGesch Top-Level Matching minimiert Problem
⚠️ bemerkung wird modifiziert Marker im Feld sichtbar Am Ende anfügen, prefix erkennbar
⚠️ Leere Slots Sammeln sich an Periodischer Cleanup-Job
⚠️ Hash-Kollisionen Theoretisch möglich SHA256[:8] = 1:16 Millionen

Option A: One-Way Sync (Advoware → EspoCRM) EINFACHSTE LÖSUNG

Prinzip: Advoware ist Master, EspoCRM ist Read-Only Viewer

Implementierung:

async def sync_kommunikation_one_way(betnr: int, bet_id: str):
    """
    Komplett-Überschreibung: Alle Kommunikationen von Advoware → EspoCRM
    
    Keine Change Detection, kein Matching - einfach überschreiben
    """
    # 1. Hole Advoware Kommunikationen
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]
    advo_komm = advo_data.get('kommunikation', [])
    
    # 2. Konvertiere ALLE zu EspoCRM Format
    emails = []
    phones = []
    
    for k in advo_komm:
        kommkz = k.get('kommKz', 0)
        wert = k.get('tlf', '').strip()
        if not wert:
            continue
        
        if kommkz in [4, 8, 11, 12]:  # Email-Typen
            emails.append({
                'emailAddress': wert,
                'lower': wert.lower(),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            })
        elif kommkz in [1, 2, 3, 6, 7, 9, 10]:  # Phone-Typen
            type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 
                       6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
            phones.append({
                'phoneNumber': wert,
                'type': type_map.get(kommkz, 'Other'),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            })
    
    # 3. KOMPLETT ÜBERSCHREIBEN (kein Merge!)
    await espo.update_entity('CBeteiligte', bet_id, {
        'emailAddressData': emails,
        'phoneNumberData': phones
    })
    
    context.logger.info(f"One-Way Sync: {len(emails)} emails, {len(phones)} phones")

Vorteile:

  • Sehr einfach (50 Zeilen Code)
  • Kein Matching nötig
  • Keine Inkonsistenzen möglich
  • Change Detection via Advoware rowId reicht

Nachteile:

  • EspoCRM-Änderungen gehen verloren
  • Nicht bidirektional
  • User kann in EspoCRM nichts bearbeiten

Geeignet wenn:

  • Advoware ist primäres System
  • EspoCRM nur als Ansicht genutzt wird
  • Keine Bearbeitung in EspoCRM gewünscht

Option B: Wert-basiertes Matching mit Smart-Merge BESTE BALANCE

Prinzip: Matching via emailAddress/phoneNumber + intelligentes Merging

Implementierung:

async def sync_kommunikation_value_based(betnr: int, bet_id: str):
    """
    Wert-basiertes Matching mit Smart-Merge
    
    - Advoware-Einträge werden gematched und aktualisiert
    - EspoCRM-eigene Einträge bleiben erhalten
    - Bei Duplikaten: Advoware gewinnt
    """
    # 1. Hole beide Seiten
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]
    advo_komm = advo_data.get('kommunikation', [])
    
    espo_entity = await espo.get_entity('CBeteiligte', bet_id)
    espo_emails_current = espo_entity.get('emailAddressData', [])
    espo_phones_current = espo_entity.get('phoneNumberData', [])
    
    # 2. Konvertiere Advoware
    advo_emails = {}  # {emailAddress: data}
    advo_phones = {}  # {phoneNumber: data}
    
    for k in advo_komm:
        kommkz = k.get('kommKz', 0)
        wert = k.get('tlf', '').strip()
        if not wert:
            continue
        
        if kommkz in [4, 8, 11, 12]:
            advo_emails[wert] = {
                'emailAddress': wert,
                'lower': wert.lower(),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            }
        elif kommkz in [1, 2, 3, 6, 7, 9, 10]:
            type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 
                       6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
            advo_phones[wert] = {
                'phoneNumber': wert,
                'type': type_map.get(kommkz, 'Other'),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            }
    
    # 3. Smart-Merge: Advoware + nur nicht-existierende EspoCRM-Einträge
    merged_emails = list(advo_emails.values())
    merged_phones = list(advo_phones.values())
    
    # Füge EspoCRM-Einträge hinzu die NICHT in Advoware sind
    for espo_email in espo_emails_current:
        if espo_email['emailAddress'] not in advo_emails:
            merged_emails.append(espo_email)
    
    for espo_phone in espo_phones_current:
        if espo_phone['phoneNumber'] not in advo_phones:
            merged_phones.append(espo_phone)
    
    # 4. Update
    await espo.update_entity('CBeteiligte', bet_id, {
        'emailAddressData': merged_emails,
        'phoneNumberData': merged_phones
    })
    
    context.logger.info(
        f"Smart-Merge: {len(advo_emails)} Advoware emails, "
        f"{len(merged_emails) - len(advo_emails)} EspoCRM-only emails retained"
    )

Vorteile:

  • Einfach zu implementieren (80 Zeilen)
  • EspoCRM-eigene Einträge bleiben erhalten
  • Teilweise bidirektional (neue Einträge von EspoCRM bleiben)
  • Change Detection via rowId

Nachteile:

  • ⚠️ EspoCRM-Änderungen an Advoware-Einträgen gehen verloren
  • ⚠️ Bei Wert-Änderung in Advoware: Duplikat entsteht
  • ⚠️ Kein echter bidirektionaler Sync

Geeignet wenn:

  • Advoware ist primär, aber EspoCRM kann ergänzen
  • User können in EspoCRM zusätzliche Kontakte hinzufügen
  • Advoware-Einträge sollen nicht in EspoCRM geändert werden

Option C: Array-Level Change Detection FÜR KOMPLEXERE LOGIK

Prinzip: Speichere Hash des kompletten Arrays, bei Änderung: Analyse

Implementierung:

import hashlib
import json

def calculate_array_hash(data: list) -> str:
    """Berechnet Hash für emailAddressData/phoneNumberData"""
    # Sortiere und normalisiere für stabilen Hash
    normalized = sorted([
        {k: v for k, v in item.items() if k != 'lower'}  # 'lower' ist redundant
        for item in data
    ], key=lambda x: x.get('emailAddress') or x.get('phoneNumber'))
    
    return hashlib.sha256(
        json.dumps(normalized, sort_keys=True).encode()
    ).hexdigest()[:16]


async def detect_kommunikation_changes(bet_id: str):
    """Erkennt ob emailAddressData/phoneNumberData geändert wurden"""
    
    # Hole aktuelle Daten
    entity = await espo.get_entity('CBeteiligte', bet_id)
    current_emails = entity.get('emailAddressData', [])
    current_phones = entity.get('phoneNumberData', [])
    
    # Berechne Hashes
    current_email_hash = calculate_array_hash(current_emails)
    current_phone_hash = calculate_array_hash(current_phones)
    
    # Hole gespeicherte Hashes aus Redis/DB
    stored_hashes = await get_kommunikation_hashes(bet_id)
    
    changes = {
        'emails_changed': current_email_hash != stored_hashes.get('email_hash'),
        'phones_changed': current_phone_hash != stored_hashes.get('phone_hash'),
        'current_email_hash': current_email_hash,
        'current_phone_hash': current_phone_hash
    }
    
    if changes['emails_changed'] or changes['phones_changed']:
        context.logger.info(f"Kommunikation changed for {bet_id}")
        
        # Analysiere WAS geändert wurde
        changes['added_emails'] = find_added_items(
            stored_hashes.get('emails', []), current_emails, 'emailAddress'
        )
        changes['removed_emails'] = find_removed_items(
            stored_hashes.get('emails', []), current_emails, 'emailAddress'
        )
        
        # Speichere neue Hashes
        await store_kommunikation_hashes(bet_id, {
            'email_hash': current_email_hash,
            'phone_hash': current_phone_hash,
            'emails': current_emails,
            'phones': current_phones
        })
    
    return changes


def find_added_items(old_list: list, new_list: list, key: str) -> list:
    """Findet hinzugefügte Einträge"""
    old_values = {item[key] for item in old_list}
    return [item for item in new_list if item[key] not in old_values]


def find_removed_items(old_list: list, new_list: list, key: str) -> list:
    """Findet entfernte Einträge"""
    new_values = {item[key] for item in new_list}
    return [item for item in old_list if item[key] not in new_values]

Vorteile:

  • Erkennt granulare Änderungen (added/removed/modified)
  • Kann intelligente Sync-Entscheidungen treffen
  • Ermöglicht Konflikt-Handling

Nachteile:

  • ⚠️ Komplexer (150+ Zeilen)
  • ⚠️ Speichert Kopie der Daten (für Diff)
  • ⚠️ Immer noch wert-basiertes Matching

Geeignet wenn:

  • Granulare Change Detection gewünscht
  • Konflikt-Handling wichtig
  • Bereit für höhere Komplexität

Empfehlung

Für schnelle Implementation: Option A (One-Way Sync)

  • 50 Zeilen Code
  • In 1 Stunde implementiert
  • Deckt 80% der Use-Cases ab

Für Produktiv-Einsatz: Option B (Smart-Merge)

  • 80 Zeilen Code
  • Beste Balance zwischen Einfachheit und Flexibilität
  • EspoCRM-User können ergänzen

Struktur des Custom Fields

{
  "kommunikationMapping": {
    "emails": [
      {
        "emailAddress": "max@example.com",
        "advowareId": 149331,
        "advowareRowId": "eXqf+gAAAAAAAAA=",
        "lastSync": "2026-02-08T10:30:00Z"
      },
      {
        "emailAddress": "info@company.com",
        "advowareId": 149332,
        "advowareRowId": "eXqf+gAAAAAAAAB=",
        "lastSync": "2026-02-08T10:30:00Z"
      }
    ],
    "phones": [
      {
        "phoneNumber": "+49 511 12345",
        "advowareId": 149333,
        "advowareRowId": "eXqf+gAAAAAAAAC=",
        "lastSync": "2026-02-08T10:30:00Z"
      }
    ]
  }
}

EspoCRM Custom Field Konfiguration

Feld-Definition (in EspoCRM Admin → Entity Manager → CBeteiligte → Fields):

  • Name: kommunikationMapping
  • Type: Text (oder Wysiwyg falls UI wichtig)
  • Label: Kommunikation Sync Mapping (wird nicht im UI angezeigt)
  • Tooltip: Mapping von Advoware Kommunikations-IDs (automatisch verwaltet)
  • Read-Only: Yes (User soll nicht editieren)
  • Hidden in Detail: Yes (nicht sichtbar)

Matching-Algorithmus

async def match_email_with_advoware(email_address: str, bet_id: str) -> Optional[dict]:
    """
    Findet Advoware-Kommunikation für eine Email-Adresse
    
    Returns: {"advowareId": 123, "advowareRowId": "ABC"} oder None
    """
    # Hole Mapping aus EspoCRM
    entity = await espo.get_entity('CBeteiligte', bet_id)
    mapping_json = entity.get('kommunikationMapping')
    
    if not mapping_json:
        return None
    
    mapping = json.loads(mapping_json) if isinstance(mapping_json, str) else mapping_json
    
    # Suche Email
    for email_entry in mapping.get('emails', []):
        if email_entry['emailAddress'] == email_address:
            return {
                'advowareId': email_entry['advowareId'],
                'advowareRowId': email_entry['advowareRowId']
            }
    
    return None


async def update_kommunikation_mapping(bet_id: str, betnr: int):
    """
    Aktualisiert das Mapping basierend auf aktuellen Advoware-Daten
    
    Wird aufgerufen:
    - Nach jedem Advoware → EspoCRM Sync
    - Bei Beteiligte-Webhook
    """
    # Hole Advoware Kommunikationen
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]
    advo_komm = advo_data.get('kommunikation', [])
    
    # Baue Mapping
    mapping = {
        'emails': [],
        'phones': []
    }
    
    for k in advo_komm:
        kommkz = k.get('kommKz', 0)
        wert = k.get('tlf', '').strip()
        if not wert:
            continue
        
        entry = {
            'advowareId': k.get('id'),
            'advowareRowId': k.get('rowId'),
            'lastSync': datetime.now().isoformat()
        }
        
        # Email-Typen
        if kommkz in [4, 8, 11, 12]:
            entry['emailAddress'] = wert
            mapping['emails'].append(entry)
        
        # Phone-Typen
        elif kommkz in [1, 2, 3, 6, 7, 9, 10]:
            entry['phoneNumber'] = wert
            mapping['phones'].append(entry)
    
    # Speichere Mapping
    await espo.update_entity('CBeteiligte', bet_id, {
        'kommunikationMapping': json.dumps(mapping, ensure_ascii=False)
    })

Sync-Ablauf mit Mapping

Advoware → EspoCRM (Webhook-getriggert):

async def sync_kommunikation_from_advoware(betnr: int, bet_id: str):
    """Vollständiger Sync mit Mapping-Update"""
    
    # 1. Hole Advoware Daten
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]
    advo_komm = advo_data.get('kommunikation', [])
    
    # 2. Konvertiere zu EspoCRM Format
    emails = []
    phones = []
    mapping = {'emails': [], 'phones': []}
    
    for k in advo_komm:
        kommkz = k.get('kommKz', 0)
        wert = k.get('tlf', '').strip()
        if not wert:
            continue
        
        # Email
        if kommkz in [4, 8, 11, 12]:
            emails.append({
                'emailAddress': wert,
                'lower': wert.lower(),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            })
            mapping['emails'].append({
                'emailAddress': wert,
                'advowareId': k.get('id'),
                'advowareRowId': k.get('rowId'),
                'lastSync': datetime.now().isoformat()
            })
        
        # Phone
        elif kommkz in [1, 2, 3, 6, 7, 9, 10]:
            type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 
                       6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
            phones.append({
                'phoneNumber': wert,
                'type': type_map.get(kommkz, 'Other'),
                'primary': k.get('online', False),
                'optOut': False,
                'invalid': False
            })
            mapping['phones'].append({
                'phoneNumber': wert,
                'advowareId': k.get('id'),
                'advowareRowId': k.get('rowId'),
                'lastSync': datetime.now().isoformat()
            })
    
    # 3. Update EspoCRM (Daten + Mapping)
    await espo.update_entity('CBeteiligte', bet_id, {
        'emailAddressData': emails,
        'phoneNumberData': phones,
        'kommunikationMapping': json.dumps(mapping, ensure_ascii=False)
    })

EspoCRM → Advoware (Change Detection):

async def sync_kommunikation_to_advoware(bet_id: str, betnr: int):
    """
    Synchronisiert Änderungen von EspoCRM zu Advoware
    
    Wird aufgerufen bei:
    - EspoCRM-Webhook (CBeteiligte UPDATE)
    - Change Detection erkennt emailAddressData/phoneNumberData Änderung
    """
    # Hole EspoCRM Daten
    entity = await espo.get_entity('CBeteiligte', bet_id)
    current_emails = entity.get('emailAddressData', [])
    current_phones = entity.get('phoneNumberData', [])
    
    # Hole Mapping
    mapping_json = entity.get('kommunikationMapping', '{}')
    mapping = json.loads(mapping_json) if isinstance(mapping_json, str) else mapping_json
    
    # Verarbeite Emails
    for email in current_emails:
        email_addr = email['emailAddress']
        
        # Finde im Mapping
        advo_info = next((e for e in mapping.get('emails', []) 
                         if e['emailAddress'] == email_addr), None)
        
        if advo_info:
            # UPDATE in Advoware
            advo_id = advo_info['advowareId']
            await advoware.api_call(
                f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advo_id}',
                method='PUT',
                data={
                    'kommKz': 4,  # Von gespeichertem Typ (via separate Logik)
                    'tlf': email_addr,
                    'bemerkung': '',
                    'online': email.get('primary', False)
                }
            )
        else:
            # CREATE in Advoware (neue Email)
            result = await advoware.api_call(
                f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen',
                method='POST',
                data={
                    'kommKz': 4,  # MailGesch
                    'tlf': email_addr,
                    'bemerkung': 'Von EspoCRM erstellt',
                    'online': email.get('primary', False)
                }
            )
            
            # Update Mapping
            created = result[0] if isinstance(result, list) else result
            mapping.setdefault('emails', []).append({
                'emailAddress': email_addr,
                'advowareId': created['id'],
                'advowareRowId': created['rowId'],
                'lastSync': datetime.now().isoformat()
            })
    
    # Erkenne GELÖSCHTE Emails (in Mapping aber nicht in current_emails)
    current_email_addrs = {e['emailAddress'] for e in current_emails}
    for mapped_email in mapping.get('emails', []):
        if mapped_email['emailAddress'] not in current_email_addrs:
            # Email wurde in EspoCRM gelöscht → DELETE in Advoware
            advo_id = mapped_email['advowareId']
            try:
                await advoware.api_call(
                    f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advo_id}',
                    method='DELETE'
                )
            except Exception as e:
                if '403' in str(e):
                    # DELETE nicht erlaubt → Notification
                    await notification_manager.notify_manual_action_required(
                        entity_type='CBeteiligte',
                        entity_id=bet_id,
                        action_type='delete_not_supported',
                        details={
                            'message': 'Kommunikation kann nicht gelöscht werden',
                            'advoware_id': advo_id,
                            'email': mapped_email['emailAddress']
                        }
                    )
    
    # Update Mapping
    await espo.update_entity('CBeteiligte', bet_id, {
        'kommunikationMapping': json.dumps(mapping, ensure_ascii=False)
    })

Vorteile der Custom Field Lösung

Aspekt Lösung
Stabiles Matching Via advowareId (nicht abhängig vom Wert)
Change Detection Via advowareRowId
Bidirektional Vollständig (CREATE/UPDATE/DELETE)
Wert-Änderungen Kein Problem (Matching via ID)
DELETE Detection Möglich (Vergleich Mapping vs. current)
Typ-Tracking Via separates Feld oder Ableitung
Implementation ⚠️ Erfordert Custom Field in EspoCRM

Nachteile & Mitigations

Nachteil Mitigation
Custom Field nötig Einmaliges Setup in EspoCRM Admin
Daten-Duplikation Akzeptabel (Mapping ist klein)
Inkonsistenz möglich Auto-Rebuild bei jedem Advoware-Sync
User könnte löschen Field als readOnly + hidden markieren

4.4 Alternative: Wert-basiertes Matching (Fallback)

Falls Custom Field NICHT gewünscht, gibt es einen einfacheren Ansatz ohne IDs:

Hybrid-Strategie ohne Mapping:

  • Matching via emailAddress/phoneNumber Wert
  • Bei Wert-Änderung: DELETE + CREATE (kein UPDATE)
  • Keine DELETE-Detection möglich
  • Nur One-Way: Advoware → EspoCRM

Siehe Abschnitt 5.1 für Details.

1. Advoware → EspoCRM (primary=true)

Alle Advoware-Kommunikationen werden mit primary=true markiert (geschützt):

# Sync-Ablauf
advoware_emails = get_advoware_kommunikation(betnr, types=[4, 8, 11, 12])
espocrm_emails_current = get_espocrm_entity(bet_id)['emailAddressData']

# Trenne primary (Advoware) von non-primary (EspoCRM-only)
espocrm_secondary = [e for e in espocrm_emails_current if not e.get('primary')]

# Konvertiere Advoware zu EspoCRM Format
advoware_as_espocrm = [
    {
        'emailAddress': k['tlf'],
        'lower': k['tlf'].lower(),
        'primary': True,  # IMMER true für Advoware
        'optOut': False,
        'invalid': False
    }
    for k in advoware_emails
]

# Merge: Advoware (primary) + EspoCRM (secondary)
merged = advoware_as_espocrm + espocrm_secondary

# Update CBeteiligte
await espo.update_entity('CBeteiligte', bet_id, {
    'emailAddressData': merged
})

Vorteile:

  • Advoware behält vollständige Kontrolle
  • EspoCRM kann eigene Einträge ergänzen (primary=false)
  • Kein Datenverlust
  • Nutzt Standard-EspoCRM-Felder

2. EspoCRM → Advoware (NUR primary=false)

Nur EspoCRM-eigene Einträge (primary=false) werden NICHT zu Advoware synchronisiert:

# Bei EspoCRM-Webhook: Prüfe primary-Flag
for email in espocrm_entity['emailAddressData']:
    if email.get('primary'):
        # Von Advoware → IGNORIEREN (wird via Advoware-Webhook synchronisiert)
        continue
    else:
        # EspoCRM-eigener Eintrag → Behalten (nur in EspoCRM)
        pass

3. Change Detection

  • Advoware: Via rowId (wie bei Adressen/Bankverbindungen)
  • EspoCRM: Keine Change Detection für primary=false Einträge
  • Advoware ist Master für alle primary=true Einträge

4. Wert-Änderungen (Edge Case)

Szenario: Email/Phone ändert in Advoware

Vorher:  max@old.com  (Advoware ID=123, rowId=ABC)
Nachher: max@new.com  (Advoware ID=123, rowId=XYZ)  # rowId ändert!

Problem: Matching via Wert findet max@old.com nicht mehr

Verhalten:

  1. Sync erkennt rowId-Änderung von Advoware-Eintrag 123
  2. Sucht max@new.com in EspoCRM → nicht gefunden
  3. Fügt max@new.com mit primary=true hinzu
  4. max@old.com bleibt mit primary=false erhalten (!)

Ergebnis: Temporäres Duplikat

Cleanup:

  • Option A: User löscht manuell in EspoCRM
  • Option B: Automatisches Cleanup von verwaisten primary=false Einträgen mit alten Advoware-Pattern

4.4 Akzeptierte Einschränkungen

Einschränkung Impact Mitigation
Kein ID-Feld Matching via Wert fragil primary-Flag trennt Advoware/EspoCRM
Wert-Änderung → Duplikat User sieht alte+neue Adresse Manueller Cleanup oder Auto-Cleanup-Job
bemerkung geht verloren Notizen nicht in EspoCRM Akzeptiert (EspoCRM hat kein Feld)
kommKz unlesbar (Bug) Typ-Info verloren Irrelevant (Typ ergibt sich aus Array)
Internet-Typ fehlt URLs nicht sync-bar Akzeptiert (11/12 Typen OK)

4.5 EspoCRM → Advoware Mapping (Optional)

{
    'kommKz': map_enum(espo['kommunikationstyp']),  # 1-12
    'tlf': espo['wert'],                            # "+49 511..."
    'bemerkung': espo['bemerkung'],                 # Notiz
    'online': espo['isOnline']                      # Boolean
}

Enum-Mapping:

ESPO_TO_ADVO_KOMMKZ = {
    'TelGesch': 1,
    'FaxGesch': 2,
    'Mobil': 3,
    'MailGesch': 4,
    'Internet': 5,
    'TelPrivat': 6,
    'FaxPrivat': 7,
    'MailPrivat': 8,
    'AutoTelefon': 9,
    'Sonstige': 10,
    'EPost': 11,
    'Bea': 12
}

4.2 EspoCRM → Advoware (UPDATE)

{
    # kommKz NICHT ÄNDERBAR!
    'kommKz': current_advo['kommKz'],  # Verwende aktuellen Wert
    'tlf': espo['wert'],               # ÄNDERBAR
    'bemerkung': espo['bemerkung'],    # ÄNDERBAR
    'online': espo['isOnline']         # ÄNDERBAR
}

Wichtig:

  • kommKz MUSS im Request enthalten sein (API-Validierung)
  • Aber Wert wird ignoriert - immer aktuellen Wert verwenden!

4.3 Advoware → EspoCRM

{
    'name': f"{map_enum_reverse(advo['kommKz'])}: {advo['tlf'][:30]}",
    'kommunikationstyp': map_enum_reverse(advo['kommKz']),
    'wert': advo['tlf'],
    'bemerkung': advo['bemerkung'],
    'isOnline': advo['online'],
    'advowareId': advo['id'],
    'advowareRowId': advo['rowId']
}

Enum-Mapping (Reverse):

ADVO_TO_ESPO_KOMMKZ = {
    1: 'TelGesch',
    2: 'FaxGesch',
    3: 'Mobil',
    4: 'MailGesch',
    5: 'Internet',
    6: 'TelPrivat',
    7: 'FaxPrivat',
    8: 'MailPrivat',
    9: 'AutoTelefon',
    10: 'Sonstige',
    11: 'EPost',
    12: 'Bea'
}

4.4 READ-ONLY Felder Detection

def detect_readonly_changes(espo_entity, advo_entity):
    """Prüft ob READ-ONLY Felder geändert wurden"""
    
    espo_kommkz = ESPO_TO_ADVO_KOMMKZ.get(espo_entity['kommunikationstyp'])
    advo_kommkz = advo_entity['kommKz']
    
    if espo_kommkz != advo_kommkz:
        return {
            'readonly_fields': ['kommunikationstyp'],
            'espo_value': espo_entity['kommunikationstyp'],
            'advo_value': ADVO_TO_ESPO_KOMMKZ[advo_kommkz]
        }
    
    return None

5. Sync-Strategie

Entscheidung: Integration in Beteiligte-Sync (kein separates CKommunikation Entity)

5.1 Advoware → EspoCRM (Webhook-getriggert)

async def sync_kommunikation_to_espocrm(betnr: int, bet_id: str):
    """
    Synchronisiert Advoware Kommunikationen zu EspoCRM als Teil von CBeteiligte
    
    Wird getriggert von:
    - Beteiligte-Webhook (wenn rowId von Kommunikationen ändert)
    - Kann auch manuell aufgerufen werden
    """
    
    # 1. Hole Advoware Beteiligte (inkl. Kommunikationen)
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]  # API gibt Liste zurück
    advo_komm = advo_data.get('kommunikation', [])
    
    # 2. Hole aktuelle EspoCRM emailAddressData/phoneNumberData
    espo_entity = await espocrm.get_entity('CBeteiligte', bet_id)
    espo_emails_current = espo_entity.get('emailAddressData', [])
    espo_phones_current = espo_entity.get('phoneNumberData', [])
    
    # 3. Konvertiere Advoware zu EspoCRM Format
    advo_as_emails = []
    advo_as_phones = []
    
    for k in advo_komm:
        kommkz = k.get('kommKz', 0)
        wert = k.get('tlf', '').strip()
        if not wert:
            continue  # Skip leere Einträge
        
        # Email-Typen: 4=MailGesch, 8=MailPrivat, 11=EPost, 12=Bea
        if kommkz in [4, 8, 11, 12]:
            advo_as_emails.append({
                'emailAddress': wert,
                'lower': wert.lower(),
                'primary': True,  # Markiere als Advoware-Eintrag
                'optOut': False,
                'invalid': False
            })
        
        # Phone-Typen: 1,2,3,6,7,9,10 (alle außer 4,5,8,11,12)
        elif kommkz in [1, 2, 3, 6, 7, 9, 10]:
            type_map = {
                1: 'Office',    # TelGesch
                2: 'Fax',       # FaxGesch
                3: 'Mobile',    # Mobil
                6: 'Home',      # TelPrivat
                7: 'Fax',       # FaxPrivat
                9: 'Mobile',    # AutoTelefon
                10: 'Other'     # Sonstige
            }
            advo_as_phones.append({
                'phoneNumber': wert,
                'type': type_map.get(kommkz, 'Other'),
                'primary': True,  # Markiere als Advoware-Eintrag
                'optOut': False,
                'invalid': False
            })
        # kommKz=5 (Internet) wird übersprungen (nicht unterstützt)
    
    # 4. Behalte EspoCRM-eigene Einträge (primary=false)
    espo_secondary_emails = [e for e in espo_emails_current if not e.get('primary', False)]
    espo_secondary_phones = [p for p in espo_phones_current if not p.get('primary', False)]
    
    # 5. Merge: Advoware (primary) + EspoCRM (secondary)
    merged_emails = advo_as_emails + espo_secondary_emails
    merged_phones = advo_as_phones + espo_secondary_phones
    
    # 6. Update CBeteiligte
    update_data = {
        'emailAddressData': merged_emails,
        'phoneNumberData': merged_phones
    }
    
    await espocrm.update_entity('CBeteiligte', bet_id, update_data)
    
    context.logger.info(
        f"Kommunikation synced: {len(advo_as_emails)} emails, "
        f"{len(advo_as_phones)} phones from Advoware + "
        f"{len(espo_secondary_emails)} EspoCRM emails, "
        f"{len(espo_secondary_phones)} EspoCRM phones"
    )

Wichtig:

  • Alle Advoware-Einträge haben primary=true
  • EspoCRM-eigene Einträge haben primary=false und bleiben erhalten
  • Bei jedem Sync werden Advoware-Einträge komplett überschrieben

5.2 Change Detection

async def handle_beteiligte_webhook(webhook_data):
    """
    Webhook von Advoware bei Beteiligte-Änderung
    
    Prüft ob Kommunikationen geändert wurden via rowId
    """
    
    betnr = webhook_data['beteiligterId']
    
    # Hole aktuelle Advoware-Daten
    advo_entity = await advoware.api_call(f'api/v1/advonet/Beteiligte/{betnr}')
    advo_data = advo_entity[0]
    advo_komm = advo_data.get('kommunikation', [])
    
    # Hole gespeicherte rowIds aus Redis/DB
    stored_row_ids = await get_stored_kommunikation_rowids(betnr)
    current_row_ids = [k.get('rowId') for k in advo_komm if k.get('rowId')]
    
    # Vergleiche
    if set(current_row_ids) != set(stored_row_ids):
        context.logger.info(f"Kommunikation changed for BetNr {betnr}")
        
        # Sync zu EspoCRM
        bet_id = await get_espocrm_id_for_betnr(betnr)
        await sync_kommunikation_to_espocrm(betnr, bet_id)
        
        # Update gespeicherte rowIds
        await store_kommunikation_rowids(betnr, current_row_ids)
    else:
        context.logger.debug(f"No kommunikation changes for BetNr {betnr}")

5.3 EspoCRM → Advoware (Optional, nicht empfohlen)

Entscheidung: EspoCRM-eigene Einträge (primary=false) werden NICHT zu Advoware synchronisiert.

Begründung:

  • EspoCRM kann keine Advoware-IDs speichern (kein custom field in Arrays)
  • Matching via Wert ist fragil (bei Änderung)
  • Konflikt-Handling komplex
  • User-Story: EspoCRM als "Viewer" mit optionalen Ergänzungen

Alternative (falls gewünscht): One-Shot-Import

async def import_espocrm_kommunikation_to_advoware(bet_id: str, betnr: int):
    """
    Einmaliger Import von EspoCRM → Advoware
    
    NUR für primary=false Einträge (EspoCRM-eigene)
    User muss manuell triggern
    """
    
    espo_entity = await espocrm.get_entity('CBeteiligte', bet_id)
    
    # Nur non-primary Einträge
    to_import_emails = [e for e in espo_entity.get('emailAddressData', []) 
                        if not e.get('primary', False)]
    to_import_phones = [p for p in espo_entity.get('phoneNumberData', []) 
                        if not p.get('primary', False)]
    
    for email in to_import_emails:
        # Erstelle in Advoware
        await advoware.api_call(
            f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen',
            method='POST',
            data={
                'kommKz': 4,  # MailGesch
                'tlf': email['emailAddress'],
                'bemerkung': 'Importiert aus EspoCRM',
                'online': email.get('primary', False)
            }
        )
    
    # Danach: Setze primary=true (jetzt von Advoware kontrolliert)
    await resync_kommunikation_to_espocrm(betnr, bet_id)
async def create_kommunikation(espo_entity, betnr):
    """Erstellt neue Kommunikation in Advoware"""
    
    # 1. Mappe ALLE Felder
    advo_data = {
        'kommKz': ESPO_TO_ADVO_KOMMKZ[espo_entity['kommunikationstyp']],
        'tlf': espo_entity['wert'],
        'bemerkung': espo_entity['bemerkung'],
        'online': espo_entity['isOnline']
    }
    
    # 2. POST zu Advoware
    result = await advoware.api_call(
        f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen',
        method='POST',
        data=advo_data
    )
    
    # 3. Extrahiere ID und rowId
    created = result[0] if isinstance(result, list) else result
    
    # 4. Update EspoCRM mit Advoware-IDs
    await espocrm.update_entity('CKommunikation', espo_entity['id'], {
        'advowareId': created['id'],
        'advowareRowId': created['rowId'],
        'syncStatus': 'clean',
        'advowareLastSync': datetime.now()
    })

5.2 UPDATE (EspoCRM → Advoware)

async def update_kommunikation(espo_entity, betnr):
    """Update Kommunikation (nur R/W Felder)"""
    
    advoware_id = espo_entity['advowareId']
    
    # WICHTIG: kommKz kann NICHT via GET gelesen werden (Bug: immer 0)
    # → Verwende gespeicherten Wert aus EspoCRM
    stored_kommkz = ESPO_TO_ADVO_KOMMKZ.get(espo_entity['kommunikationstyp'])
    
    # 1. Check ob kommKz in EspoCRM geändert wurde
    if stored_kommkz != espo_entity.get('originalKommKz'):
        # Typ wurde in EspoCRM geändert → Notification
        await notification_manager.notify_manual_action_required(
            entity_type='CKommunikation',
            entity_id=espo_entity['id'],
            action_type='readonly_field_conflict',
            details={
                'readonly_fields': ['kommunikationstyp'],
                'message': 'Kommunikationstyp kann nicht geändert werden',
                'description': (
                    f"Der Kommunikationstyp (kommKz) ist READ-ONLY in Advoware.\n\n"
                    f"**Aktuelle Situation:**\n"
                    f"- Ursprungstyp: {espo_entity.get('originalKommKz')}\n"
                    f"- Neuer Typ: {espo_entity['kommunikationstyp']}\n\n"
                    f"**Workaround:**\n"
                    f"1. Löschen Sie die Kommunikation in EspoCRM\n"
                    f"2. Erstellen Sie sie neu mit dem gewünschten Typ\n"
                    f"3. Die neue Kommunikation wird automatisch nach Advoware synchronisiert"
                ),
                'advoware_id': advoware_id,
                'betnr': betnr
            },
            create_task=True
        )
        return
    
    # 2. Update nur R/W Felder
    advo_data = {
        'kommKz': stored_kommkz,  # WICHTIG: Verwende gespeicherten Wert!
        'tlf': espo_entity['wert'],
        'bemerkung': espo_entity['bemerkung'],
        'online': espo_entity['isOnline']
    }
    
    # 3. PUT zu Advoware
    result = await advoware.api_call(
        f'api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{advoware_id}',
        method='PUT',
        data=advo_data
    )
    
    # 4. Update rowId in EspoCRM
    await espocrm.update_entity('CKommunikation', espo_entity['id'], {
        'advowareRowId': result['rowId'],
        'syncStatus': 'clean',
        'advowareLastSync': datetime.now()
    })

Wichtige Änderungen gegenüber Standard-Pattern:

  • ⚠️ Kein GET vor PUT: kommKz ist in GET nicht lesbar (Bug: immer 0)
  • EspoCRM als Source of Truth: Verwende gespeicherten kommKz-Wert
  • originalKommKz Feld: Speichere ursprünglichen Typ für Änderungserkennung

5.3 DELETE - Notification Strategy

async def handle_kommunikation_deletion(espo_entity, betnr):
    """DELETE nicht möglich - Notification für manuelle Löschung"""
    
    advoware_id = espo_entity['advowareId']
    
    await notification_manager.notify_manual_action_required(
        entity_type='CKommunikation',
        entity_id=espo_entity['id'],
        action_type='delete_not_supported',
        details={
            'message': f'DELETE erforderlich für Kommunikation: {espo_entity["name"]}',
            'description': (
                f"Die Advoware API unterstützt keine Löschungen für Kommunikationen.\n\n"
                f"**Bitte manuell in Advoware löschen:**\n"
                f"- Typ: {espo_entity['kommunikationstyp']}\n"
                f"- Wert: {espo_entity['wert']}\n"
                f"- Beteiligter betNr: {betnr}\n"
                f"- Advoware ID: {advoware_id}\n\n"
                f"Die Kommunikation wurde in EspoCRM gelöscht, bleibt aber in Advoware "
                f"bestehen bis zur manuellen Löschung."
            ),
            'betnr': betnr,
            'advoware_id': advoware_id,
            'kommunikationstyp': espo_entity['kommunikationstyp'],
            'wert': espo_entity['wert']
        },
        create_task=True
    )

5.4 SYNC from Advoware

async def sync_from_advoware(betnr):
    """Sync Kommunikationen Advoware → EspoCRM"""
    
    # 1. Hole alle Kommunikationen vom Beteiligten
    beteiligte = await advoware.api_call(
        f'api/v1/advonet/Beteiligte/{betnr}',
        method='GET'
    )
    
    if isinstance(beteiligte, list):
        beteiligte = beteiligte[0]
    
    advo_kommunikationen = beteiligte.get('kommunikation', [])
    
    # 2. Hole CBeteiligte aus EspoCRM
    espo_beteiligte = await espocrm.list_entities(
        'CBeteiligte',
        filters={'betnr': betnr}
    )
    
    if not espo_beteiligte:
        logger.warning(f"Beteiligter {betnr} nicht in EspoCRM gefunden")
        return
    
    beteiligte_id = espo_beteiligte[0]['id']
    
    # 3. Hole bestehende CKommunikation Entities
    espo_kommunikationen = await espocrm.list_entities(
        'CKommunikation',
        filters={'beteiligteId': beteiligte_id}
    )
    
    # 4. Matche via advowareId
    espo_by_advo_id = {
        k['advowareId']: k 
        for k in espo_kommunikationen 
        if k.get('advowareId')
    }
    
    # 5. Sync jede Advoware-Kommunikation
    for advo_komm in advo_kommunikationen:
        advo_id = advo_komm['id']
        
        if advo_id in espo_by_advo_id:
            # UPDATE bestehende
            espo_komm = espo_by_advo_id[advo_id]
            
            # Check rowId für Änderungen
            if espo_komm.get('advowareRowId') != advo_komm['rowId']:
                # Advoware wurde geändert
                await update_from_advoware(espo_komm, advo_komm)
        else:
            # CREATE neue
            await create_from_advoware(beteiligte_id, advo_komm)

6. Implementierungsplan

Phase 1: EspoCRM Entity Setup

  1. Entity erstellen: CKommunikation
  2. Felder definieren:
    • kommunikationstyp (enum)
    • wert (string)
    • bemerkung (text)
    • isOnline (bool)
    • isPrimary (bool)
    • beteiligteId (link zu CBeteiligte)
    • Sync-Felder (advowareId, rowId, syncStatus, etc.)
  3. Relationship: Many-to-One zu CBeteiligte

Phase 2: Mapper Implementierung

  1. kommunikation_mapper.py:

    • map_ckommunikation_to_advoware_create() - Alle Felder
    • map_ckommunikation_to_advoware_update() - Nur R/W Felder
    • map_advoware_to_ckommunikation() - Reverse mapping
    • detect_readonly_changes() - kommKz Detection
  2. Enum-Mappings:

    • ESPO_TO_ADVO_KOMMKZ
    • ADVO_TO_ESPO_KOMMKZ

Phase 3: Sync Service

  1. kommunikation_sync.py:

    • create_kommunikation() - POST zu Advoware
    • update_kommunikation() - PUT (nur R/W)
    • handle_kommunikation_deletion() - Notification
    • sync_from_advoware() - Import
    • _find_kommunikation_by_advoware_id() - Matching
  2. NotificationManager Integration:

    • readonly_field_conflict - kommKz geändert
    • delete_not_supported - Manuelle Löschung

Phase 4: Webhook Integration

  1. Webhook Endpoints:

    • kommunikation_create_api_step.py
    • kommunikation_update_api_step.py
    • kommunikation_delete_api_step.py
  2. Event Handler:

    • kommunikation_sync_event_step.py
    • Subscribe: vmh.kommunikation.{create|update|delete}

Phase 5: Testing

  1. Unit Tests:

    • Mapper-Funktionen
    • Enum-Conversions
    • Readonly-Detection
  2. Integration Tests:

    • CREATE mit allen kommKz-Typen
    • UPDATE R/W Felder
    • UPDATE kommKz → Notification
    • DELETE → Notification
    • SYNC from Advoware
  3. End-to-End Tests:

    • Webhook → Sync → Advoware
    • Advoware Änderung → Import
    • Konfliktauflösung

📊 Zusammenfassung

Erfolgreich getestet

  • POST: Alle 4 Felder funktionieren
  • GET: Über Beteiligte-Endpoint verfügbar
  • PUT: 3 von 4 Feldern änderbar (tlf, bemerkung, online)
  • rowId: Ändert sich bei jedem UPDATE (perfekt für Change Detection)

Einschränkungen

  • kommKz: READ-ONLY bei PUT (Typ kann nicht geändert werden)
  • DELETE: 403 Forbidden (wie bei Adressen/Bankverbindungen)

💡 Empfohlene Sync-Strategie

  1. CREATE: Automatisch (alle Felder)
  2. UPDATE: Automatisch (tlf, bemerkung, online) + Notification bei kommKz-Änderung
  3. DELETE: Notification für manuelle Löschung
  4. SYNC: Via advowareId + rowId (wie bei Adressen)

🔗 Ähnlichkeiten zu Adressen-Sync

  • Gleiche Limitationen (kein DELETE)
  • Teilweise READ-ONLY Felder bei PUT
  • rowId-basierte Change Detection
  • advowareId für Matching
  • NotificationManager für manuelle Interventionen

Die Implementierung kann stark an adressen_sync.py angelehnt werden!


5. Implementation Details

5.1 Implementierte Module

Die Kommunikation-Sync besteht aus 3 Hauptmodulen:

services/kommunikation_mapper.py

Zweck: Datentyp-Mapping und Marker-Verwaltung

Hauptfunktionen:

  • calculate_hash(value): SHA256[:8] für Matching
  • parse_marker(bemerkung): Extrahiert Marker aus bemerkung
  • create_marker(value, kommKz, user_text): Erstellt [ESPOCRM:hash:kommKz]
  • create_slot_marker(kommKz): Erstellt [ESPOCRM-SLOT:kommKz]
  • detect_kommkz(value, beteiligte, bemerkung): 4-Stufen Typ-Erkennung
    1. Aus Marker (höchste Priorität)
    2. Aus Top-Level Feldern (telGesch, emailGesch, etc.)
    3. Aus Wert-Pattern (@ = Email, sonst Phone)
    4. Default (MailGesch=4, TelGesch=1)
  • advoware_to_espocrm_email(): Mapping Advoware → EspoCRM Email
  • advoware_to_espocrm_phone(): Mapping Advoware → EspoCRM Phone
  • find_matching_advoware(): Hash-basierte Suche in Advoware
  • find_empty_slot(): Findet wiederverwendbare leere Slots
  • should_sync_to_espocrm(): Filtert leere Slots und ungültige Einträge

Konstanten:

KOMMKZ_TEL_GESCH = 1
KOMMKZ_FAX_GESCH = 2
KOMMKZ_MOBIL = 3
KOMMKZ_MAIL_GESCH = 4
# ... etc (1-12)

EMAIL_KOMMKZ = [4, 8, 11, 12]  # Mail, MailPrivat, EPost, Bea
PHONE_KOMMKZ = [1, 2, 3, 6, 7, 9, 10]  # Alle Telefon-Typen

KOMMKZ_TO_PHONE_TYPE = {
    1: 'Office',    # TelGesch
    2: 'Fax',       # FaxGesch
    3: 'Mobile',    # Mobil
    6: 'Home',      # TelPrivat
    # ...
}

services/advoware_service.py

Zweck: Advoware API-Wrapper für Kommunikation-Operations

class AdvowareService:
    def get_beteiligter(betnr: int) -> Dict:
        """Lädt Beteiligte mit kommunikation[] array"""
    
    def create_kommunikation(betnr: int, data: dict) -> Dict:
        """POST /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen
        
        Required: tlf, kommKz
        Optional: bemerkung, online
        """
    
    def update_kommunikation(betnr: int, komm_id: int, data: dict) -> bool:
        """PUT /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}
        
        Writable: tlf, bemerkung, online
        READ-ONLY: kommKz
        """
    
    def delete_kommunikation(betnr: int, komm_id: int) -> bool:
        """DELETE (aktuell 403 Forbidden)
        
        Nicht verwendbar - nutze Empty Slots stattdessen
        """

services/kommunikation_sync_utils.py

Zweck: Bidirektionale Synchronisationslogik

class KommunikationSyncManager:
    def __init__(self, advoware: AdvowareService, espocrm: EspoCrmService):
        pass
    
    # ========== BIDIRECTIONAL ==========
    
    def sync_bidirectional(beteiligte_id: str, betnr: int, direction: str):
        """direction: 'both', 'to_espocrm', 'to_advoware'
        
        Returns: Combined results from both directions
        """
    
    # ========== ADVOWARE → ESPOCRM ==========
    
    def sync_advoware_to_espocrm(beteiligte_id: str, betnr: int):
        """
        Lädt Advoware Kommunikationen → Schreibt zu EspoCRM Arrays
        
        Schritte:
        1. Lade Advoware Beteiligte mit kommunikation[]
        2. Filtere: should_sync_to_espocrm() (keine leeren Slots)
        3. Erkenne Typ: detect_kommkz()
        4. Konvertiere: advoware_to_espocrm_email/phone()
        5. Update EspoCRM: emailAddressData[] und phoneNumberData[]
        
        Returns: {'emails_synced': int, 'phones_synced': int, 'errors': []}
        """
    
    # ========== ESPOCRM → ADVOWARE ==========
    
    def sync_espocrm_to_advoware(beteiligte_id: str, betnr: int):
        """
        Lädt EspoCRM Arrays → Schreibt zu Advoware Kommunikationen
        
        Schritte:
        1. Lade beide Seiten
        2. Baue Hash-Maps: EspoCRM values ↔ Advoware entries
        3. Erkenne Szenarien:
           - Deleted: In Advoware aber nicht in EspoCRM → Empty Slot
           - Changed: Hash match, Wert geändert → UPDATE
           - New: In EspoCRM aber nicht in Advoware → CREATE/REUSE
        
        Returns: {'created': int, 'updated': int, 'deleted': int, 'errors': []}
        """

Change Detection:

def detect_kommunikation_changes(old_bet: dict, new_bet: dict) -> bool:
    """
    Für Advoware Webhooks
    
    Vergleicht rowId arrays:
    - Anzahl geändert?
    - rowId Set geändert?
    """

def detect_espocrm_kommunikation_changes(old_data: dict, new_data: dict) -> bool:
    """
    Für EspoCRM Webhooks
    
    Vergleicht Arrays:
    - emailAddressData count/values
    - phoneNumberData count/values
    """

5.2 Integration in Webhook-System

Die Kommunikation-Sync wird in den bestehenden Beteiligte-Webhooks integriert:

Advoware Webhook (bei rowId-Änderung):

# In beteiligte_sync_event_handler

from services.advoware_service import AdvowareService
from services.espocrm import EspoCrmService
from services.kommunikation_sync_utils import (
    KommunikationSyncManager,
    detect_kommunikation_changes
)

advo_service = AdvowareService()
espo_service = EspoCrmService()
komm_sync = KommunikationSyncManager(advo_service, espo_service)

# Bei Beteiligte-Update
old_data = previous_beteiligte_data
new_data = current_beteiligte_data

if detect_kommunikation_changes(old_data, new_data):
    logger.info(f"[KOMM] Änderung erkannt für betnr={betnr}")
    result = komm_sync.sync_bidirectional(bet_id, betnr, direction='to_espocrm')
    logger.info(f"[KOMM] Sync-Result: {result}")

EspoCRM Webhook (bei Array-Änderung):

# In espocrm_webhook_handler

from services.kommunikation_sync_utils import detect_espocrm_kommunikation_changes

# Bei CBeteiligte-Update
old_data = previous_cbeteiligte_data
new_data = current_cbeteiligte_data

if detect_espocrm_kommunikation_changes(old_data, new_data):
    logger.info(f"[KOMM] EspoCRM Änderung erkannt für bet_id={bet_id}")
    result = komm_sync.sync_bidirectional(bet_id, betnr, direction='to_advoware')
    logger.info(f"[KOMM] Sync-Result: {result}")

5.3 Testing

Test-Scripts (bereits im Repo):

  • scripts/test_kommunikation_api.py: Vollständige API-Tests (POST/PUT/GET/DELETE)
  • scripts/test_kommunikation_kommkz_deep.py: kommKz-Bug Analyse
  • scripts/test_kommart_values.py: kommArt-Bug Verifikation
  • scripts/analyze_beteiligte_endpoint.py: Top-Level Felder Analyse
  • scripts/test_espocrm_kommunikation.py: EspoCRM Struktur-Tests

Manuelle Tests:

  1. Szenario 1 - Löschen in EspoCRM:

    • Lösche Email in EspoCRM
    • Trigger Webhook → Sync
    • Verify: Advoware hat Empty Slot [ESPOCRM-SLOT:4]
  2. Szenario 2 - Ändern in EspoCRM:

    • Ändere Email-Wert in EspoCRM
    • Trigger Webhook → Sync
    • Verify: Advoware hat neuen Wert + neuen Hash-Marker
  3. Szenario 3 - Neu in EspoCRM:

    • Füge Email in EspoCRM hinzu
    • Trigger Webhook → Sync
    • Verify: Advoware hat neue Kommunikation ODER reused Slot
  4. Szenario 4 - Neu in Advoware:

    • Erstelle Kommunikation in Advoware
    • Trigger Webhook → Sync
    • Verify: EspoCRM hat neue Email + Advoware hat Marker

6. Base64-Implementation (Ersetzt Hash-Strategie)

6.1 Problem: Hash ist nicht rückrechenbar

Kritisches Problem der Hash-Strategie:

# Szenario: User ändert Wert in Advoware
old_value = "old@example.com"
old_hash = calculate_hash(old_value)  # abc12345

# Marker in Advoware bemerkung: [ESPOCRM:abc12345:4]
# EspoCRM hat: old@example.com (mit Hash abc12345)

# USER ÄNDERT in Advoware:
new_value = "new@example.com"
new_hash = calculate_hash(new_value)  # xyz78901

# Sync-Problem:
# - Advoware Marker: [ESPOCRM:abc12345:4] (alter Hash!)
# - EspoCRM sucht: xyz78901 (neuer Hash)
# - Result: ❌ KEIN MATCH! Kann old@example.com nicht finden

Konsequenz: Hash-basiertes Matching funktioniert nur einseitig (EspoCRM → Advoware).

6.2 Lösung: Base64-Encoding

Brillante Idee: Speichere den tatsächlichen Wert (Base64-kodiert) statt Hash!

# Base64-Strategie
old_value = "old@example.com"
encoded = encode_value(old_value)  # b2xkQGV4YW1wbGUuY29t

# Marker in Advoware: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4]

# USER ÄNDERT in Advoware:
new_value = "new@example.com"

# Sync-Erfolg:
# - Marker enthält: b2xkQGV4YW1wbGUuY29t
# - Dekodiert zu: "old@example.com" ✅
# - Findet Match in EspoCRM!
# - Updated EspoCRM + Marker mit neuem Base64-Wert

Vorteile:

  • Bidirektional: Matching in beide Richtungen
  • Selbstheilend: Automatische Marker-Updates bei Wert-Änderungen
  • Escaping: Base64 löst : und ] Probleme
  • Kompakt: URL-safe Base64 ist kurz genug für bemerkung-Feld

6.3 Implementation

Encoding/Decoding:

import base64

def encode_value(value: str) -> str:
    """Base64 URL-safe encoding"""
    if not value:
        return ''
    return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=')

def decode_value(encoded: str) -> str:
    """Base64 decoding mit padding"""
    if not encoded:
        return ''
    padding = 4 - (len(encoded) % 4)
    if padding and padding != 4:
        encoded += '=' * padding
    return base64.urlsafe_b64decode(encoded).decode('utf-8')

Marker-Functions:

def create_marker(value: str, kommkz: int, user_text: str = '') -> str:
    """Erstellt Marker mit Base64-Wert"""
    encoded = encode_value(value)
    suffix = f" {user_text}" if user_text else ""
    return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}"

def parse_marker(bemerkung: str) -> Optional[Dict]:
    """Parse Marker und dekodiere Wert"""
    pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)'
    match = re.match(pattern, bemerkung)
    if not match:
        return None
    
    encoded_value = match.group(1)
    synced_value = decode_value(encoded_value)  # Dekodiert!
    
    return {
        'synced_value': synced_value,  # Original-Wert
        'kommKz': int(match.group(2)),
        'is_slot': False,
        'user_text': match.group(3).strip()
    }

Bidirektionales Matching:

def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]:
    """Findet Advoware-Eintrag für EspoCRM-Wert"""
    for komm in advo_kommunikationen:
        bemerkung = komm.get('bemerkung') or ''
        marker = parse_marker(bemerkung)
        
        if marker and marker['synced_value'] == espo_value:
            return komm  # Match via dekodiertem Wert! ✅
    
    return None

6.4 Test-Ergebnisse

Alle 7 Tests erfolgreich (scripts/test_kommunikation_sync_implementation.py):

  1. Base64-Encoding bidirektional:

    • max@example.combWF4QGV4YW1wbGUuY29t
    • Special chars: test:special]@example.comdGVzdDpzcGVjaWFsXUBleGFtcGxlLmNvbQ
  2. Marker-Parsing: synced_value korrekt dekodiert

  3. Marker-Erstellung: Base64-Wert im Marker

  4. 4-Tier Typ-Erkennung: Marker > Top-Level > Pattern > Default

  5. Typ-Klassifizierung: Email vs Phone types

  6. Integration mit bidirektionalem Matching:

    # Szenario: Wert ändert in Advoware
    old_value = "new@example.com"
    marker = create_marker(old_value, 4)  # [ESPOCRM:bmV3QGV4YW1wbGUuY29t:4]
    
    # User ändert zu:
    new_value = "changed@example.com"
    
    # Sync dekodiert Marker:
    parsed = parse_marker(marker)
    assert parsed['synced_value'] == "new@example.com"  # ✅
    
    # Findet Match in EspoCRM:
    espo_match = find_in_espocrm(parsed['synced_value'])
    # Updates EspoCRM + Marker mit neuem Wert
    
  7. Top-Level Feld Priorität: telGesch, mobil etc. überschreiben Pattern

6.5 Migration von Hash zu Base64

Backward Compatibility: parse_marker() erkennt alte Hash-Marker automatisch:

if marker and len(encoded_value) == 8 and all(c in '0123456789abcdef' for c in encoded_value):
    # Legacy hash marker → Kann nicht dekodiert werden
    synced_value = encoded_value  # Fallback
else:
    synced_value = decode_value(encoded_value)  # Base64

Automatische Migration: Beim nächsten Sync werden Hash-Marker automatisch auf Base64 aktualisiert.

6.6 Vollständiger Sync-Ablauf mit Base64

Szenario:

Initial State:
  tlf: "old@example.com"
  bemerkung: "[ESPOCRM:abc12345:4]"
  
User ändert tlf in Advoware:
  tlf: "new@example.com"
  bemerkung: "[ESPOCRM:abc12345:4]"  ← UNVERÄNDERT!
  
Problem:
  calculate_hash("new@example.com") ≠ "abc12345"
  → Matching zu EspoCRM schlägt fehl

6.2 Lösung: Automatische Hash-Validierung

Die sync_advoware_to_espocrm() Funktion validiert ALLE Hashes vor dem Sync:

def sync_advoware_to_espocrm(self, beteiligte_id: str, betnr: int):
    """Mit automatischer Hash-Validierung und Marker-Update"""
    
    for komm in kommunikationen:
        tlf = komm.get('tlf')
        bemerkung = komm.get('bemerkung')
        komm_id = komm.get('id')
        
        marker = parse_marker(bemerkung)
        
        if marker and not marker['is_slot']:
            current_hash = calculate_hash(tlf)
            
            # HASH-MISMATCH → Wert wurde in Advoware geändert
            if marker['hash'] != current_hash:
                logger.info(f"Hash-Mismatch detected: komm_id={komm_id}")
                
                # Update Marker mit neuem Hash (behält User-Text)
                user_text = marker.get('user_text', '')
                new_marker = create_marker(tlf, marker['kommKz'], user_text)
                
                self.advoware.update_kommunikation(betnr, komm_id, {
                    'bemerkung': new_marker
                })
                
                result['markers_updated'] += 1
        
        # ... Rest des Syncs

Vorteile:

  • Automatische Selbstheilung bei Änderungen in Advoware
  • User-Text wird beibehalten
  • kommKz bleibt erhalten (aus altem Marker)
  • Matching funktioniert beim nächsten Sync wieder

Result-Struktur (erweitert):

{
    'emails_synced': 3,
    'phones_synced': 2,
    'markers_updated': 1,  # 🆕 Anzahl korrigierter Marker
    'errors': []
}

6.3 Integration in Beteiligte-Sync

Der Kommunikation-Sync ist integraler Bestandteil des Beteiligte-Syncs:

Implementierung in beteiligte_sync_event_step.py:

from services.advoware_service import AdvowareService
from services.kommunikation_sync_utils import (
    KommunikationSyncManager,
    detect_kommunikation_changes
)

# In handler()
advo_service = AdvowareService(context)
komm_sync = KommunikationSyncManager(advo_service, espocrm)

# In handle_update()
async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, ...):
    # 1. Speichere alte Version für Change Detection
    old_advo_entity = advo_entity.copy()
    
    # 2. Sync STAMMDATEN (wie bisher)
    comparison = sync_utils.compare_entities(espo_entity, advo_entity)
    
    if comparison == 'espocrm_newer':
        # Update Advoware Stammdaten
        await advoware.api_call(f'.../Beteiligte/{betnr}', 'PUT', data=merged_data)
        
        # 3. KOMMUNIKATION SYNC (nach Stammdaten)
        advo_entity_refreshed = await advoware.api_call(f'.../Beteiligte/{betnr}', 'GET')
        
        if detect_kommunikation_changes(old_advo_entity, advo_entity_refreshed):
            context.logger.info("📞 Kommunikation-Änderungen erkannt")
            komm_result = komm_sync.sync_bidirectional(entity_id, betnr, direction='to_espocrm')
            context.logger.info(f"✅ Kommunikation synced: {komm_result}")
    
    elif comparison == 'advoware_newer':
        # Update EspoCRM Stammdaten
        await espocrm.update_entity('CBeteiligte', entity_id, espo_data)
        
        # 3. KOMMUNIKATION SYNC
        if detect_kommunikation_changes(old_advo_entity, advo_entity):
            komm_result = komm_sync.sync_bidirectional(entity_id, betnr, direction='to_espocrm')

Reihenfolge ist wichtig:

  1. Erst Stammdaten-Sync (name, anrede, etc.)
  2. Dann Kommunikation-Sync (emails, phones)
  3. Change Detection via rowId (Stammdaten) + Array-Vergleich (Kommunikation)

Fehlerbehandlung:

try:
    komm_result = komm_sync.sync_bidirectional(...)
    context.logger.info(f"✅ Kommunikation synced: {komm_result}")
except Exception as e:
    # Kommunikation-Fehler blockiert NICHT den Stammdaten-Sync
    context.logger.error(f"⚠️ Kommunikation-Sync fehlgeschlagen: {e}")
    # Stammdaten sind bereits gespeichert → syncStatus bleibt 'clean'

Vorteile der Integration:

  • Atomare Operation: Stammdaten + Kommunikation in einem Durchlauf
  • Keine separaten Webhooks nötig
  • Konsistente Change Detection
  • Fehler-Isolation: Kommunikation-Fehler blockiert nicht Stammdaten-Sync

6.4 Vollständiger Sync-Ablauf

Beispiel: User ändert Email in Advoware

  1. User-Aktion: old@example.comnew@example.com in Advoware
  2. Webhook: Advoware Beteiligte-Änderung
  3. Stammdaten-Check: rowId geändert → comparison = 'advoware_newer'
  4. Kommunikation-Check: detect_kommunikation_changes() = True
  5. Sync Advoware → EspoCRM:
    • Hash-Validierung: abc12345 ≠ calculate_hash("new@example.com")
    • Marker-Update: [ESPOCRM:def67890:4]
    • EspoCRM-Update: emailAddressData = [{emailAddress: "new@example.com", ...}]
  6. Result: {emails_synced: 1, markers_updated: 1, errors: []}

Beispiel: User löscht Email in EspoCRM

  1. User-Aktion: Löscht max@example.com in EspoCRM
  2. Webhook: EspoCRM CBeteiligte-Änderung
  3. Kommunikation-Check: detect_espocrm_kommunikation_changes() = True
  4. Sync EspoCRM → Advoware:
    • Hash-Map: abc12345 in Advoware, aber nicht in EspoCRM
    • Empty Slot: tlf = '', bemerkung = "[ESPOCRM-SLOT:4]"
  5. Result: {deleted: 1, errors: []}

Implementation Status: COMPLETE + INTEGRATED

Ende der Analyse