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

2537 lines
77 KiB
Markdown

# 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](#executive-summary)
2. [Advoware API Analyse](#advoware-api-analyse)
3. [EspoCRM Konzept](#espocrm-konzept)
4. [Feld-Mapping](#feld-mapping)
5. [Sync-Strategie](#sync-strategie)
6. [Implementierungsplan](#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
```json
{
"tlf": "string (nullable)",
"bemerkung": "string (nullable)",
"kommKz": "integer (enum 1-12)",
"online": "boolean"
}
```
#### Response (GET/POST/PUT)
```json
{
"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:
```json
{
"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**:
```bash
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 ✅
```bash
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 ✅
```bash
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 ❌
```bash
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 ❌
```bash
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
```json
{
"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**:
```python
# 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**:
```json
{
"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**:
```python
# 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
```javascript
{
"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)
```python
# 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**:
- `tlf``emailAddress` und `lower`
- `online``primary` (Advoware-Einträge sind immer primary=true)
- `bemerkung` → ❌ Geht verloren (kein Feld in EspoCRM)
#### Phone-Kommunikation (kommKz: 1, 2, 3, 6, 7, 9, 10)
```python
# 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**:
```python
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?**
```python
# 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
```python
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**
```python
# 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**
```python
# 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**
```python
# 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**
```python
# 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**:
```python
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**:
```python
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**:
```python
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
```json
{
"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
```python
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):
```python
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):
```python
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](#51-advoware--espocrm-webhook-getriggert) für Details.
#### 1. Advoware → EspoCRM (primary=true)
Alle Advoware-Kommunikationen werden mit **primary=true** markiert (geschützt):
```python
# 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:
```python
# 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)
```python
{
'kommKz': map_enum(espo['kommunikationstyp']), # 1-12
'tlf': espo['wert'], # "+49 511..."
'bemerkung': espo['bemerkung'], # Notiz
'online': espo['isOnline'] # Boolean
}
```
**Enum-Mapping**:
```python
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)
```python
{
# 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
```python
{
'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)**:
```python
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
```python
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)
```python
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
```python
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
```python
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)
```
```python
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)
```python
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
```python
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
```python
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**:
```python
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
```python
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
```python
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**:
```python
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):
```python
# 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):
```python
# 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**:
```python
# 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!
```python
# 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**:
```python
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**:
```python
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**:
```python
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.com``bWF4QGV4YW1wbGUuY29t`
- Special chars: `test:special]@example.com``dGVzdDpzcGVjaWFsXUBleGFtcGxlLmNvbQ`
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**:
```python
# 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:
```python
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:
```python
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):
```python
{
'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`**:
```python
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**:
```python
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.com` → `new@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**