- 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.
2537 lines
77 KiB
Markdown
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** ✅
|