diff --git a/bitbylaw/docs/BETEILIGTE_SYNC.md b/bitbylaw/docs/BETEILIGTE_SYNC.md index 38e9f206..a96aa088 100644 --- a/bitbylaw/docs/BETEILIGTE_SYNC.md +++ b/bitbylaw/docs/BETEILIGTE_SYNC.md @@ -178,6 +178,51 @@ results = await asyncio.gather(*tasks, return_exceptions=True) ``` - ✅ 90% schneller bei 100 Entities +## Kommunikation-Sync Integration + +### Base64-Marker Strategie ✅ + +Die Kommunikation-Synchronisation (Telefon, Email) ist in den Beteiligte-Sync integriert. + +**Marker-Format**: +``` +[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich +[ESPOCRM-SLOT:4] # Leerer Slot nach Löschung +``` + +**Base64-Encoding statt Hash**: +- **Vorteil**: Bidirektional! Marker enthält den **tatsächlichen Wert** (Base64-kodiert) +- **Matching**: Selbst wenn Wert in Advoware ändert, kann alter Wert aus Marker dekodiert werden +- **Beispiel**: + ```python + # Advoware: old@example.com → new@example.com + # Alter Marker: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4] + # Sync dekodiert: "old@example.com" → Findet Match in EspoCRM ✅ + # Update: EspoCRM-Eintrag + Marker mit neuem Base64-Wert + ``` + +**Async/Await Architektur** ⚡: +- Alle Sync-Methoden sind **async** für Non-Blocking I/O +- AdvowareService: Native async (kein `asyncio.run()` mehr) +- KommunikationSyncManager: Vollständig async mit proper await +- Integration im Webhook-Handler: Seamless async/await flow + +**4-Stufen Typ-Erkennung**: +1. **Marker** (höchste Priorität) → `[ESPOCRM:...:3]` = kommKz 3 +2. **Top-Level Felder** → `beteiligte.mobil` = kommKz 3 +3. **Wert-Pattern** → `@` in Wert = Email (kommKz 4) +4. **Default** → Fallback (TelGesch=1, MailGesch=4) + +**Bidirektionale Sync**: +- **Advoware → EspoCRM**: Komplett (inkl. Marker-Update bei Wert-Änderung) +- **EspoCRM → Advoware**: Vollständig (CREATE/UPDATE/DELETE via Slots) +- **Slot-Wiederverwendung**: Gelöschte Einträge werden als `[ESPOCRM-SLOT:kommKz]` markiert + +**Implementation**: +- [kommunikation_mapper.py](../services/kommunikation_mapper.py) - Base64 encoding/decoding +- [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) - Sync-Manager +- Tests: [test_kommunikation_sync_implementation.py](../scripts/test_kommunikation_sync_implementation.py) + ## Performance | Operation | API Calls | Latency | diff --git a/bitbylaw/docs/KOMMUNIKATION_SYNC_ANALYSE.md b/bitbylaw/docs/KOMMUNIKATION_SYNC_ANALYSE.md new file mode 100644 index 00000000..5186f903 --- /dev/null +++ b/bitbylaw/docs/KOMMUNIKATION_SYNC_ANALYSE.md @@ -0,0 +1,2536 @@ +# 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** ✅ diff --git a/bitbylaw/motia-workbench.json b/bitbylaw/motia-workbench.json index 9775f23f..751b6e8b 100644 --- a/bitbylaw/motia-workbench.json +++ b/bitbylaw/motia-workbench.json @@ -95,8 +95,8 @@ "y": 188 }, "steps/vmh/bankverbindungen_sync_event_step.py": { - "x": 350, - "y": 1006 + "x": 539, + "y": 1004 }, "steps/vmh/webhook/beteiligte_update_api_step.py": { "x": 13, diff --git a/bitbylaw/scripts/analyze_beteiligte_endpoint.py b/bitbylaw/scripts/analyze_beteiligte_endpoint.py new file mode 100644 index 00000000..6fee27e4 --- /dev/null +++ b/bitbylaw/scripts/analyze_beteiligte_endpoint.py @@ -0,0 +1,152 @@ +""" +Detaillierte Analyse: Was liefert /api/v1/advonet/Beteiligte/{id}? + +Prüfe: +1. Kommunikation-Array: Alle Felder +2. kommKz und kommArt Werte +3. Adressen-Array (falls enthalten) +4. Vollständige Struktur +""" + +import asyncio +import json +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def main(): + print("\n" + "="*70) + print("DETAILLIERTE ANALYSE: Beteiligte Endpoint") + print("="*70) + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Hole kompletten Beteiligte + print(f"\n📋 GET /api/v1/advonet/Beteiligte/{TEST_BETNR}") + result = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_BETNR}') + + print(f"\nResponse Type: {type(result)}") + if isinstance(result, list): + print(f"Response Length: {len(result)}") + beteiligte = result[0] + else: + beteiligte = result + + # Zeige Top-Level Struktur + print_section("TOP-LEVEL FELDER") + print(f"\nVerfügbare Keys:") + for key in sorted(beteiligte.keys()): + value = beteiligte[key] + if isinstance(value, list): + print(f" • {key:30s}: [{len(value)} items]") + elif isinstance(value, dict): + print(f" • {key:30s}: {{dict}}") + else: + value_str = str(value)[:50] + print(f" • {key:30s}: {value_str}") + + # Kommunikationen + print_section("KOMMUNIKATION ARRAY") + + kommunikationen = beteiligte.get('kommunikation', []) + print(f"\n✅ {len(kommunikationen)} Kommunikationen gefunden") + + if kommunikationen: + print(f"\n📋 Erste Kommunikation - ALLE Felder:") + first = kommunikationen[0] + print(json.dumps(first, indent=2, ensure_ascii=False)) + + print(f"\n📊 Übersicht aller Kommunikationen:") + print(f"\n{'ID':>8s} | {'kommKz':>6s} | {'kommArt':>7s} | {'online':>6s} | {'Wert':40s} | {'Bemerkung'}") + print("-" * 120) + + for k in kommunikationen: + komm_id = k.get('id', 'N/A') + kommkz = k.get('kommKz', 'N/A') + kommart = k.get('kommArt', 'N/A') + online = k.get('online', False) + wert = (k.get('tlf') or '')[:40] + bemerkung = (k.get('bemerkung') or '')[:20] + + # Highlighting + kommkz_str = f"✅ {kommkz}" if kommkz not in [0, 'N/A'] else f"❌ {kommkz}" + kommart_str = f"✅ {kommart}" if kommart not in [0, 'N/A'] else f"❌ {kommart}" + + print(f"{komm_id:8} | {kommkz_str:>6s} | {kommart_str:>7s} | {str(online):>6s} | {wert:40s} | {bemerkung}") + + # Adressen + print_section("ADRESSEN ARRAY") + + adressen = beteiligte.get('adressen', []) + print(f"\n✅ {len(adressen)} Adressen gefunden") + + if adressen: + print(f"\n📋 Erste Adresse - Struktur:") + first_addr = adressen[0] + print(json.dumps(first_addr, indent=2, ensure_ascii=False)) + + # Bankverbindungen + print_section("BANKVERBINDUNGEN") + + bankverb = beteiligte.get('bankkverbindungen', []) # Typo im API? + if not bankverb: + bankverb = beteiligte.get('bankverbindungen', []) + + print(f"\n✅ {len(bankverb)} Bankverbindungen gefunden") + + if bankverb: + print(f"\n📋 Erste Bankverbindung - Keys:") + print(list(bankverb[0].keys())) + + # Analyse + print_section("ZUSAMMENFASSUNG") + + print(f"\n📊 Verfügbare Daten:") + print(f" • Kommunikationen: {len(kommunikationen)}") + print(f" • Adressen: {len(adressen)}") + print(f" • Bankverbindungen: {len(bankverb)}") + + print(f"\n🔍 kommKz/kommArt Status:") + if kommunikationen: + kommkz_values = [k.get('kommKz', 0) for k in kommunikationen] + kommart_values = [k.get('kommArt', 0) for k in kommunikationen] + + kommkz_non_zero = [v for v in kommkz_values if v != 0] + kommart_non_zero = [v for v in kommart_values if v != 0] + + print(f" • kommKz unique values: {set(kommkz_values)}") + print(f" • kommKz non-zero count: {len(kommkz_non_zero)} / {len(kommunikationen)}") + + print(f" • kommArt unique values: {set(kommart_values)}") + print(f" • kommArt non-zero count: {len(kommart_non_zero)} / {len(kommunikationen)}") + + if kommkz_non_zero: + print(f"\n ✅✅✅ JACKPOT! kommKz HAT WERTE im Beteiligte-Endpoint!") + print(f" → Wir können den Typ korrekt erkennen!") + elif kommart_non_zero: + print(f"\n ✅ kommArt hat Werte (Email/Phone unterscheidbar)") + else: + print(f"\n ❌ Beide sind 0 - müssen Typ aus Wert ableiten") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_hidden_ids.py b/bitbylaw/scripts/test_espocrm_hidden_ids.py new file mode 100644 index 00000000..d6c1690e --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_hidden_ids.py @@ -0,0 +1,261 @@ +""" +Deep-Dive: Suche nach versteckten ID-Feldern + +Die Relationships emailAddresses/phoneNumbers existieren (kein 404), +aber wir bekommen 403 Forbidden. + +Möglichkeiten: +1. IDs sind in emailAddressData versteckt (vielleicht als 'id' Feld?) +2. Es gibt ein separates ID-Array +3. IDs sind in einem anderen Format gespeichert +4. Admin-API-Key hat nicht genug Rechte +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def inspect_email_data_structure(): + """Schaue sehr genau in emailAddressData/phoneNumberData""" + print_section("DEEP INSPECTION: emailAddressData Structure") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + + email_data = entity.get('emailAddressData', []) + + print(f"\n📧 emailAddressData hat {len(email_data)} Einträge\n") + + for i, email in enumerate(email_data): + print(f"[{i+1}] RAW Type: {type(email)}") + print(f" Keys: {list(email.keys())}") + print(f" JSON:\n") + print(json.dumps(email, indent=4, ensure_ascii=False)) + + # Prüfe ob 'id' Feld vorhanden ist + if 'id' in email: + print(f"\n ✅ ID GEFUNDEN: {email['id']}") + else: + print(f"\n ❌ Kein 'id' Feld") + + # Prüfe alle Felder auf ID-ähnliche Werte + print(f"\n Alle Werte:") + for key, value in email.items(): + print(f" {key:20s} = {value}") + print() + + +async def test_raw_api_call(): + """Mache rohe API-Calls um zu sehen was wirklich zurückkommt""" + print_section("RAW API CALL: Direkt ohne Wrapper") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Test 1: Normale Entity-Abfrage + print(f"\n1️⃣ GET /CBeteiligte/{TEST_BETEILIGTE_ID}") + result1 = await espo.api_call(f'CBeteiligte/{TEST_BETEILIGTE_ID}') + + # Zeige nur Email-relevante Felder + email_fields = {k: v for k, v in result1.items() if 'email' in k.lower()} + print(json.dumps(email_fields, indent=2, ensure_ascii=False)) + + # Test 2: Mit maxDepth Parameter (falls EspoCRM das unterstützt) + print(f"\n2️⃣ GET mit maxDepth=2") + try: + result2 = await espo.api_call( + f'CBeteiligte/{TEST_BETEILIGTE_ID}', + params={'maxDepth': '2'} + ) + email_fields2 = {k: v for k, v in result2.items() if 'email' in k.lower()} + print(json.dumps(email_fields2, indent=2, ensure_ascii=False)) + except Exception as e: + print(f" ❌ Error: {e}") + + # Test 3: Select nur emailAddressData + print(f"\n3️⃣ GET mit select=emailAddressData") + result3 = await espo.api_call( + f'CBeteiligte/{TEST_BETEILIGTE_ID}', + params={'select': 'emailAddressData'} + ) + print(json.dumps(result3, indent=2, ensure_ascii=False)) + + +async def search_for_link_table(): + """Suche nach EntityEmailAddress oder EntityPhoneNumber Link-Tables""" + print_section("SUCHE: Link-Tables") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # In EspoCRM gibt es manchmal Link-Tables wie "EntityEmailAddress" + link_table_names = [ + 'EntityEmailAddress', + 'EntityPhoneNumber', + 'ContactEmailAddress', + 'ContactPhoneNumber', + 'CBeteiligteEmailAddress', + 'CBeteiligtePhoneNumber' + ] + + for table_name in link_table_names: + print(f"\n🔍 Teste: {table_name}") + try: + result = await espo.api_call(table_name, params={'maxSize': 3}) + print(f" ✅ Existiert! Total: {result.get('total', 'unknown')}") + if result.get('list'): + print(f" Beispiel:") + print(json.dumps(result['list'][0], indent=6, ensure_ascii=False)) + except Exception as e: + error_msg = str(e) + if '404' in error_msg: + print(f" ❌ 404 - Existiert nicht") + elif '403' in error_msg: + print(f" ⚠️ 403 - Existiert aber kein Zugriff") + else: + print(f" ❌ {error_msg}") + + +async def test_update_with_ids(): + """Test: Kann ich beim UPDATE IDs setzen?""" + print_section("TEST: Update mit IDs") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + print(f"\n💡 Idee: Vielleicht kann man beim UPDATE IDs mitgeben") + print(f" und EspoCRM erstellt dann die Verknüpfung?\n") + + # Hole aktuelle Daten + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + current_emails = entity.get('emailAddressData', []) + + print(f"Aktuelle Emails:") + for email in current_emails: + print(f" • {email.get('emailAddress')}") + + # Versuche ein Update mit expliziter ID + print(f"\n🧪 Teste: Füge 'id' Feld zu emailAddressData hinzu") + + test_emails = [] + for email in current_emails: + email_copy = email.copy() + # Generiere eine Test-ID (oder verwende eine echte wenn wir eine finden) + email_copy['id'] = f"test-id-{hash(email['emailAddress']) % 100000}" + test_emails.append(email_copy) + print(f" • {email['emailAddress']:40s} → id={email_copy['id']}") + + print(f"\n⚠️ ACHTUNG: Würde jetzt UPDATE machen mit:") + print(json.dumps({'emailAddressData': test_emails}, indent=2, ensure_ascii=False)) + print(f"\n→ NICHT ausgeführt (zu riskant ohne Backup)") + + +async def check_database_or_config(): + """Prüfe ob es Config/Settings gibt die IDs aktivieren""" + print_section("ESPOCRM CONFIG: ID-Unterstützung") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + print(f"\n📋 Hole App-Informationen:") + try: + # EspoCRM hat oft einen /App endpoint + app_info = await espo.api_call('App/user') + + # Zeige nur relevante Felder + if app_info: + relevant = ['acl', 'preferences', 'settings'] + for key in relevant: + if key in app_info: + print(f"\n{key}:") + # Suche nach Email/Phone-relevanten Einstellungen + data = app_info[key] + if isinstance(data, dict): + email_phone_settings = {k: v for k, v in data.items() + if 'email' in k.lower() or 'phone' in k.lower()} + if email_phone_settings: + print(json.dumps(email_phone_settings, indent=2, ensure_ascii=False)) + else: + print(" (keine Email/Phone-spezifischen Einstellungen)") + except Exception as e: + print(f" ❌ Error: {e}") + + # Prüfe Settings + print(f"\n📋 System Settings:") + try: + settings = await espo.api_call('Settings') + if settings: + email_phone_settings = {k: v for k, v in settings.items() + if 'email' in k.lower() or 'phone' in k.lower()} + if email_phone_settings: + print(json.dumps(email_phone_settings, indent=2, ensure_ascii=False)) + except Exception as e: + print(f" ❌ Error: {e}") + + +async def main(): + print("\n" + "="*70) + print("DEEP DIVE: SUCHE NACH PHONENUMBER/EMAILADDRESS IDs") + print("="*70) + + try: + # Sehr detaillierte Inspektion + await inspect_email_data_structure() + + # Rohe API-Calls + await test_raw_api_call() + + # Link-Tables + await search_for_link_table() + + # Update-Test (ohne tatsächlich zu updaten) + await test_update_with_ids() + + # Config + await check_database_or_config() + + print_section("FAZIT") + + print("\n🎯 Mögliche Szenarien:") + print("\n1️⃣ IDs existieren NICHT in emailAddressData") + print(" → Wert-basiertes Matching notwendig") + print(" → Hybrid-Strategie (primary-Flag)") + + print("\n2️⃣ IDs existieren aber sind versteckt/nicht zugänglich") + print(" → API-Rechte müssen erweitert werden") + print(" → Admin muss emailAddresses/phoneNumbers Relationship freigeben") + + print("\n3️⃣ IDs können beim UPDATE gesetzt werden") + print(" → Wir könnten eigene IDs generieren") + print(" → Advoware-ID direkt als EspoCRM-ID nutzen") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_id_collections.py b/bitbylaw/scripts/test_espocrm_id_collections.py new file mode 100644 index 00000000..b37fe48e --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_id_collections.py @@ -0,0 +1,250 @@ +""" +Test: Gibt es ID-Collections für EmailAddress/PhoneNumber? + +In EspoCRM gibt es bei Many-to-Many Beziehungen oft: +- entityNameIds (Array von IDs) +- entityNameNames (Dict ID → Name) + +Zum Beispiel: teamsIds, teamsNames + +Hypothese: Es könnte emailAddressesIds oder ähnlich geben +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def search_for_id_fields(): + """Suche nach allen ID-ähnlichen Feldern""" + print_section("SUCHE: ID-Collections") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + + print("\n🔍 Alle Felder die 'Ids' enthalten:") + ids_fields = {k: v for k, v in entity.items() if 'Ids' in k} + for key, value in sorted(ids_fields.items()): + print(f" • {key:40s}: {value}") + + print("\n🔍 Alle Felder die 'Names' enthalten:") + names_fields = {k: v for k, v in entity.items() if 'Names' in k} + for key, value in sorted(names_fields.items()): + print(f" • {key:40s}: {value}") + + print("\n🔍 Alle Felder mit 'email' oder 'phone' (case-insensitive):") + comm_fields = {k: v for k, v in entity.items() + if 'email' in k.lower() or 'phone' in k.lower()} + for key, value in sorted(comm_fields.items()): + value_str = str(value)[:80] if not isinstance(value, list) else f"[{len(value)} items]" + print(f" • {key:40s}: {value_str}") + + +async def test_specific_fields(): + """Teste spezifische Feld-Namen die existieren könnten""" + print_section("TEST: Spezifische Feld-Namen") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + potential_fields = [ + 'emailAddressesIds', + 'emailAddressIds', + 'phoneNumbersIds', + 'phoneNumberIds', + 'emailIds', + 'phoneIds', + 'emailAddressesNames', + 'phoneNumbersNames', + ] + + print("\n📋 Teste mit select Parameter:\n") + + for field in potential_fields: + try: + result = await espo.api_call( + f'CBeteiligte/{TEST_BETEILIGTE_ID}', + params={'select': f'id,{field}'} + ) + if field in result and result[field] is not None: + print(f" ✅ {field:30s}: {result[field]}") + else: + print(f" ⚠️ {field:30s}: Im Response aber None/leer") + except Exception as e: + print(f" ❌ {field:30s}: {str(e)[:60]}") + + +async def test_with_loadAdditionalFields(): + """EspoCRM unterstützt manchmal loadAdditionalFields Parameter""" + print_section("TEST: loadAdditionalFields Parameter") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + params_to_test = [ + {'loadAdditionalFields': 'true'}, + {'loadAdditionalFields': '1'}, + {'withLinks': 'true'}, + {'withRelated': 'emailAddresses,phoneNumbers'}, + ] + + for params in params_to_test: + print(f"\n📋 Teste mit params: {params}") + try: + result = await espo.api_call( + f'CBeteiligte/{TEST_BETEILIGTE_ID}', + params=params + ) + + # Suche nach neuen Feldern + new_fields = {k: v for k, v in result.items() + if ('email' in k.lower() or 'phone' in k.lower()) + and 'Data' not in k} + + if new_fields: + print(" ✅ Neue Felder gefunden:") + for k, v in new_fields.items(): + print(f" • {k}: {v}") + else: + print(" ⚠️ Keine neuen Felder") + + except Exception as e: + print(f" ❌ Error: {e}") + + +async def test_create_with_explicit_ids(): + """ + Was wenn wir bei CREATE/UPDATE explizite IDs für Email/Phone mitgeben? + Vielleicht gibt EspoCRM dann IDs zurück? + """ + print_section("IDEE: Explizite IDs bei UPDATE mitgeben") + + print("\n💡 EspoCRM Standard-Verhalten:") + print(" Bei Many-to-Many Beziehungen (z.B. Teams):") + print(" - INPUT: teamsIds: ['id1', 'id2']") + print(" - OUTPUT: teamsIds: ['id1', 'id2']") + print(" ") + print(" Könnte bei emailAddresses ähnlich funktionieren?") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Hole aktuelle Daten + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + current_emails = entity.get('emailAddressData', []) + + print("\n📋 Aktuelle emailAddressData:") + for e in current_emails: + print(f" • {e.get('emailAddress')}") + + # Versuche ein Update mit hypothetischen emailAddressesIds + print("\n🧪 Test: UPDATE mit emailAddressesIds Feld") + print(" (DRY RUN - nicht wirklich ausgeführt)") + + # Generiere Test-IDs (EspoCRM IDs sind meist 17 Zeichen) + test_ids = [f"test{str(i).zfill(13)}" for i in range(len(current_emails))] + + print(f"\n Würde senden:") + print(f" emailAddressesIds: {test_ids}") + print(f" emailAddressData: {[e['emailAddress'] for e in current_emails]}") + + print("\n ⚠️ Zu riskant ohne zu wissen was passiert") + + +async def check_standard_contact_entity(): + """ + Prüfe wie es bei Standard Contact Entity funktioniert + (als Referenz für Custom Entity) + """ + print_section("REFERENZ: Standard Contact Entity") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + print("\n📋 Hole ersten Contact als Referenz:") + try: + contacts = await espo.api_call('Contact', params={'maxSize': 1}) + + if contacts and contacts.get('list'): + contact = contacts['list'][0] + + print(f"\n Contact: {contact.get('name')}") + print(f"\n 🔍 Email/Phone-relevante Felder:") + + for key, value in sorted(contact.items()): + if 'email' in key.lower() or 'phone' in key.lower(): + value_str = str(value)[:80] if not isinstance(value, (list, dict)) else type(value).__name__ + print(f" • {key:35s}: {value_str}") + else: + print(" ⚠️ Keine Contacts vorhanden") + + except Exception as e: + print(f" ❌ Error: {e}") + + +async def main(): + print("\n" + "="*70) + print("SUCHE: EMAIL/PHONE ID-COLLECTIONS") + print("="*70) + print("\nZiel: Finde ID-Arrays für EmailAddress/PhoneNumber Entities\n") + + try: + await search_for_id_fields() + await test_specific_fields() + await test_with_loadAdditionalFields() + await test_create_with_explicit_ids() + await check_standard_contact_entity() + + print_section("FAZIT") + + print("\n🎯 Wenn KEINE ID-Collections existieren:") + print("\n Option 1: Separate CKommunikation Entity ✅ BESTE LÖSUNG") + print(" Struktur:") + print(" {") + print(" 'id': 'espocrm-generated-id',") + print(" 'beteiligteId': '68e4af00...',") + print(" 'typ': 'Email/Phone',") + print(" 'wert': 'max@example.com',") + print(" 'advowareId': 149331,") + print(" 'advowareRowId': 'ABC...'") + print(" }") + print("\n Vorteile:") + print(" • Eigene Entity-ID für jede Kommunikation") + print(" • advowareId/advowareRowId als eigene Felder") + print(" • Sauberes Datenmodell") + print(" • Stabiles bidirektionales Matching") + + print("\n Option 2: One-Way Sync (Advoware → EspoCRM)") + print(" • Matching via Wert (emailAddress/phoneNumber)") + print(" • Nur Advoware-Änderungen werden synchronisiert") + print(" • EspoCRM als Read-Only Viewer") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_id_injection.py b/bitbylaw/scripts/test_espocrm_id_injection.py new file mode 100644 index 00000000..5a9419f9 --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_id_injection.py @@ -0,0 +1,225 @@ +""" +TEST: Können wir eigene IDs in emailAddressData setzen? + +Wenn EspoCRM IDs beim UPDATE akzeptiert und speichert, +dann können wir: +- Advoware-ID als 'id' in emailAddressData speichern +- Stabiles Matching haben +- Bidirektionalen Sync machen + +Vorsichtiger Test mit Backup! +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_id_persistence(): + """ + Teste ob EspoCRM IDs in emailAddressData speichert + + Ablauf: + 1. Hole aktuelle Daten (Backup) + 2. Füge 'id' Feld zu EINEM Email hinzu + 3. UPDATE + 4. GET wieder + 5. Prüfe ob 'id' noch da ist + 6. Restore original falls nötig + """ + print_section("TEST: ID Persistence in emailAddressData") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # 1. Backup + print("\n1️⃣ Backup: Hole aktuelle Daten") + entity_backup = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + emails_backup = entity_backup.get('emailAddressData', []) + + print(f" Backup: {len(emails_backup)} Emails gesichert") + for email in emails_backup: + print(f" • {email['emailAddress']}") + + # 2. Modifiziere NUR das erste Email (primary) + print("\n2️⃣ Modifikation: Füge 'id' zu primary Email hinzu") + + emails_modified = [] + for i, email in enumerate(emails_backup): + email_copy = email.copy() + if email_copy.get('primary'): # Nur primary modifizieren + # Nutze einen recognizable Test-Wert + test_id = f"advoware-{i+1}-test-123" + email_copy['id'] = test_id + print(f" ✏️ {email['emailAddress']:40s} → id={test_id}") + else: + print(f" ⏭️ {email['emailAddress']:40s} (unverändert)") + emails_modified.append(email_copy) + + # 3. UPDATE + print("\n3️⃣ UPDATE: Sende modifizierte Daten") + try: + await espo.update_entity('CBeteiligte', TEST_BETEILIGTE_ID, { + 'emailAddressData': emails_modified + }) + print(" ✅ UPDATE erfolgreich") + except Exception as e: + print(f" ❌ UPDATE fehlgeschlagen: {e}") + return + + # 4. GET wieder + print("\n4️⃣ GET: Hole Daten wieder ab") + await asyncio.sleep(0.5) # Kurze Pause + entity_after = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + emails_after = entity_after.get('emailAddressData', []) + + print(f" Nach UPDATE: {len(emails_after)} Emails") + + # 5. Vergleiche + print("\n5️⃣ VERGLEICH: Ist 'id' noch da?") + id_found = False + for email in emails_after: + email_addr = email['emailAddress'] + has_id = 'id' in email + + if has_id: + print(f" ✅ {email_addr:40s} → id={email['id']}") + id_found = True + else: + print(f" ❌ {email_addr:40s} → KEIN id Feld") + + # 6. Ergebnis + print(f"\n6️⃣ ERGEBNIS:") + if id_found: + print(" 🎉 SUCCESS! EspoCRM speichert und liefert 'id' Feld zurück!") + print(" → Wir können Advoware-IDs in emailAddressData speichern") + print(" → Stabiles bidirektionales Matching möglich") + else: + print(" ❌ FAILED: EspoCRM ignoriert/entfernt 'id' Feld") + print(" → Wert-basiertes Matching notwendig") + print(" → Hybrid-Strategie (primary-Flag) ist beste Option") + + # 7. Restore (optional - nur wenn User will) + print(f"\n7️⃣ CLEANUP:") + print(" Original-Daten (ohne id):") + for email in emails_backup: + print(f" • {email['emailAddress']}") + + if id_found: + restore = input("\n 🔄 Restore zu Original (ohne id)? [y/N]: ").strip().lower() + if restore == 'y': + await espo.update_entity('CBeteiligte', TEST_BETEILIGTE_ID, { + 'emailAddressData': emails_backup + }) + print(" ✅ Restored") + else: + print(" ⏭️ Nicht restored (id bleibt)") + + return id_found + + +async def test_custom_field_approach(): + """ + Alternative: Nutze ein custom field in CBeteiligte für ID-Mapping + + Idee: Speichere JSON-Mapping in einem Textfeld + """ + print_section("ALTERNATIVE: Custom Field für ID-Mapping") + + print("\n💡 Idee: Nutze custom field 'kommunikationMapping'") + print(" Struktur:") + print(" {") + print(' "emails": [') + print(' {"emailAddress": "max@example.com", "advowareId": 123, "advowareRowId": "ABC"}') + print(' ],') + print(' "phones": [') + print(' {"phoneNumber": "+49...", "advowareId": 456, "advowareRowId": "DEF"}') + print(' ]') + print(" }") + + print("\n✅ Vorteile:") + print(" • Stabiles Matching via advowareId") + print(" • Change Detection via advowareRowId") + print(" • Bidirektionaler Sync möglich") + + print("\n❌ Nachteile:") + print(" • Erfordert custom field in EspoCRM") + print(" • Daten-Duplikation (in Data + Mapping)") + print(" • Fragil wenn emailAddress/phoneNumber ändert") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Prüfe ob custom field existiert + print("\n🔍 Prüfe ob 'kommunikationMapping' Feld existiert:") + try: + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + if 'kommunikationMapping' in entity: + print(f" ✅ Feld existiert: {entity['kommunikationMapping']}") + else: + print(f" ❌ Feld existiert nicht") + print(f" → Müsste in EspoCRM angelegt werden") + except Exception as e: + print(f" ❌ Error: {e}") + + +async def main(): + print("\n" + "="*70) + print("TEST: KÖNNEN WIR EIGENE IDs IN emailAddressData SETZEN?") + print("="*70) + print("\nZiel: Herausfinden ob EspoCRM 'id' Felder akzeptiert und speichert\n") + + try: + # Haupttest + id_works = await test_id_persistence() + + # Alternative + await test_custom_field_approach() + + print_section("FINAL RECOMMENDATION") + + if id_works: + print("\n🎯 EMPFEHLUNG: Nutze 'id' Feld in emailAddressData") + print("\n📋 Implementation:") + print(" 1. Bei Advoware → EspoCRM: Füge 'id' mit Advoware-ID hinzu") + print(" 2. Matching via 'id' Feld") + print(" 3. Change Detection via Advoware rowId") + print(" 4. Bidirektionaler Sync möglich") + else: + print("\n🎯 EMPFEHLUNG A: Hybrid-Strategie (primary-Flag)") + print(" • Einfach zu implementieren") + print(" • Nutzt Standard-EspoCRM") + print(" • Eingeschränkt bidirektional") + + print("\n🎯 EMPFEHLUNG B: Custom Field 'kommunikationMapping'") + print(" • Vollständig bidirektional") + print(" • Erfordert EspoCRM-Anpassung") + print(" • Komplexere Implementation") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_kommunikation.py b/bitbylaw/scripts/test_espocrm_kommunikation.py new file mode 100644 index 00000000..5f6b40e1 --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_kommunikation.py @@ -0,0 +1,277 @@ +""" +Test: EspoCRM Kommunikation - Wie werden Kontaktdaten gespeichert? + +Prüfe: +1. Gibt es ein separates CKommunikation Entity? +2. Wie sind Telefon/Email/Fax in CBeteiligte gespeichert? +3. Sind es Arrays oder einzelne Felder? +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +# Test-Beteiligter mit Kommunikationsdaten +TEST_BETEILIGTE_ID = '68e4af00172be7924' # Angela Mustermanns + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_cbeteiligte_structure(): + """Analysiere CBeteiligte Kommunikationsfelder""" + + print_section("TEST 1: CBeteiligte Entity Struktur") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Hole Beteiligten + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + + print(f"\n✅ Beteiligter geladen: {entity.get('name')}") + print(f" ID: {entity.get('id')}") + print(f" betNr: {entity.get('betnr')}") + + # Suche nach Kommunikationsfeldern + print("\n📊 Kommunikations-relevante Felder:") + + comm_fields = [ + 'phoneNumber', 'phoneNumberData', + 'emailAddress', 'emailAddressData', + 'fax', 'faxData', + 'mobile', 'mobileData', + 'website', + # Plural Varianten + 'phoneNumbers', 'emailAddresses', 'faxNumbers', + # Link-Felder + 'kommunikationIds', 'kommunikationNames', + 'kommunikationenIds', 'kommunikationenNames', + 'ckommunikationIds', 'ckommunikationNames' + ] + + found_fields = {} + + for field in comm_fields: + if field in entity: + value = entity[field] + found_fields[field] = value + print(f"\n ✓ {field}:") + print(f" Typ: {type(value).__name__}") + + if isinstance(value, list): + print(f" Anzahl: {len(value)}") + if len(value) > 0: + print(f" Beispiel: {json.dumps(value[0], indent=6, ensure_ascii=False)}") + elif isinstance(value, dict): + print(f" Keys: {list(value.keys())}") + print(f" Content: {json.dumps(value, indent=6, ensure_ascii=False)}") + else: + print(f" Wert: {value}") + + if not found_fields: + print("\n ⚠️ Keine Standard-Kommunikationsfelder gefunden") + + # Zeige alle Felder die "comm", "phone", "email", "fax", "tel" enthalten + print("\n📋 Alle Felder mit Kommunikations-Keywords:") + keywords = ['comm', 'phone', 'email', 'fax', 'tel', 'mobil', 'kontakt'] + + matching_fields = {} + for key, value in entity.items(): + key_lower = key.lower() + if any(kw in key_lower for kw in keywords): + matching_fields[key] = value + print(f" • {key}: {type(value).__name__}") + if isinstance(value, (str, int, bool)) and value: + print(f" = {value}") + + return entity, found_fields + + +async def test_ckommunikation_entity(): + """Prüfe ob CKommunikation Entity existiert""" + + print_section("TEST 2: CKommunikation Entity") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Versuche CKommunikation zu listen + try: + result = await espo.list_entities('CKommunikation', max_size=5) + + print(f"✅ CKommunikation Entity existiert!") + print(f" Anzahl gefunden: {len(result)}") + + if result: + print(f"\n📋 Beispiel-Kommunikation:") + print(json.dumps(result[0], indent=2, ensure_ascii=False)) + + return True, result + + except Exception as e: + if '404' in str(e) or 'not found' in str(e).lower(): + print(f"❌ CKommunikation Entity existiert NICHT") + print(f" Fehler: {e}") + return False, None + else: + print(f"⚠️ Fehler beim Abrufen: {e}") + return None, None + + +async def test_entity_metadata(): + """Hole Entity-Metadaten von CBeteiligte""" + + print_section("TEST 3: CBeteiligte Metadaten") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Hole Metadaten (falls API das unterstützt) + try: + # Versuche Entity-Defs zu holen + metadata = await espo.api_call('/Metadata', method='GET') + + if 'entityDefs' in metadata and 'CBeteiligte' in metadata['entityDefs']: + beteiligte_def = metadata['entityDefs']['CBeteiligte'] + + print("✅ Metadaten verfügbar") + + if 'fields' in beteiligte_def: + fields = beteiligte_def['fields'] + + print(f"\n📊 Kommunikations-Felder in Definition:") + for field_name, field_def in fields.items(): + field_lower = field_name.lower() + if any(kw in field_lower for kw in ['comm', 'phone', 'email', 'fax', 'tel']): + print(f"\n • {field_name}:") + print(f" type: {field_def.get('type')}") + if 'entity' in field_def: + print(f" entity: {field_def.get('entity')}") + if 'link' in field_def: + print(f" link: {field_def.get('link')}") + + return metadata + + except Exception as e: + print(f"⚠️ Metadaten nicht verfügbar: {e}") + return None + + +async def test_list_all_entities(): + """Liste alle verfügbaren Entities""" + + print_section("TEST 4: Alle verfügbaren Entities") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Häufige Entity-Namen die mit Kommunikation zu tun haben könnten + test_entities = [ + 'CKommunikation', + 'Kommunikation', + 'Communication', + 'PhoneNumber', + 'EmailAddress', + 'CPhoneNumber', + 'CEmailAddress', + 'CPhone', + 'CEmail', + 'CContact', + 'ContactData' + ] + + print("\n🔍 Teste verschiedene Entity-Namen:\n") + + existing = [] + + for entity_name in test_entities: + try: + result = await espo.list_entities(entity_name, max_size=1) + print(f" ✅ {entity_name} - existiert ({len(result)} gefunden)") + existing.append(entity_name) + except Exception as e: + if '404' in str(e) or 'not found' in str(e).lower(): + print(f" ❌ {entity_name} - existiert nicht") + else: + print(f" ⚠️ {entity_name} - Fehler: {str(e)[:50]}") + + return existing + + +async def main(): + print("\n" + "="*70) + print("ESPOCRM KOMMUNIKATION ANALYSE") + print("="*70) + print("\nZiel: Verstehen wie Kommunikationsdaten in EspoCRM gespeichert sind") + print("Frage: Gibt es separate Kommunikations-Entities oder nur Felder?\n") + + try: + # Test 1: CBeteiligte Struktur + entity, comm_fields = await test_cbeteiligte_structure() + + # Test 2: CKommunikation Entity + ckommunikation_exists, ckommunikation_data = await test_ckommunikation_entity() + + # Test 3: Metadaten + # metadata = await test_entity_metadata() + + # Test 4: Liste entities + existing_entities = await test_list_all_entities() + + # Zusammenfassung + print_section("ZUSAMMENFASSUNG") + + print("\n📊 Erkenntnisse:") + + if comm_fields: + print(f"\n✅ CBeteiligte hat Kommunikationsfelder:") + for field, value in comm_fields.items(): + vtype = type(value).__name__ + print(f" • {field} ({vtype})") + + if ckommunikation_exists: + print(f"\n✅ CKommunikation Entity existiert") + print(f" → Separate Kommunikations-Entities möglich") + elif ckommunikation_exists == False: + print(f"\n❌ CKommunikation Entity existiert NICHT") + print(f" → Kommunikation nur als Felder in CBeteiligte") + + if existing_entities: + print(f"\n📋 Gefundene Kommunikations-Entities:") + for ename in existing_entities: + print(f" • {ename}") + + print("\n💡 Empfehlung:") + if not comm_fields and not ckommunikation_exists: + print(" ⚠️ Keine Kommunikationsstruktur gefunden") + print(" → Eventuell müssen Custom Fields erst angelegt werden") + elif comm_fields and not ckommunikation_exists: + print(" → Verwende vorhandene Felder in CBeteiligte (phoneNumber, emailAddress, etc.)") + print(" → Sync als Teil des Beteiligte-Syncs (nicht separat)") + elif ckommunikation_exists: + print(" → Verwende CKommunikation Entity für separaten Kommunikations-Sync") + print(" → Ermöglicht mehrere Kommunikationseinträge pro Beteiligten") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_kommunikation_detail.py b/bitbylaw/scripts/test_espocrm_kommunikation_detail.py new file mode 100644 index 00000000..8f8b6962 --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_kommunikation_detail.py @@ -0,0 +1,202 @@ +""" +Detail-Analyse: emailAddressData und phoneNumberData Struktur + +Erkenntnisse: +- CKommunikation Entity existiert NICHT in EspoCRM +- CBeteiligte hat phoneNumberData und emailAddressData Arrays +- PhoneNumber und EmailAddress Entities existieren (aber 403 Forbidden - nur intern) + +Jetzt: Analysiere die Data-Arrays im Detail +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def analyze_communication_data(): + """Detaillierte Analyse der Communication-Data Felder""" + + print_section("DETAIL-ANALYSE: emailAddressData und phoneNumberData") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Hole Beteiligten + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + + print(f"\n✅ Beteiligter: {entity.get('name')}") + print(f" ID: {entity.get('id')}") + + # emailAddressData + print("\n" + "="*50) + print("emailAddressData") + print("="*50) + + email_data = entity.get('emailAddressData', []) + + if email_data: + print(f"\n📧 {len(email_data)} Email-Adresse(n):\n") + + for i, email in enumerate(email_data): + print(f"[{i+1}] {json.dumps(email, indent=2, ensure_ascii=False)}") + + # Analysiere Struktur + if i == 0: + print(f"\n📊 Feld-Struktur:") + for key, value in email.items(): + print(f" • {key:20s}: {type(value).__name__:10s} = {value}") + else: + print("\n❌ Keine Email-Adressen vorhanden") + + # phoneNumberData + print("\n" + "="*50) + print("phoneNumberData") + print("="*50) + + phone_data = entity.get('phoneNumberData', []) + + if phone_data: + print(f"\n📞 {len(phone_data)} Telefonnummer(n):\n") + + for i, phone in enumerate(phone_data): + print(f"[{i+1}] {json.dumps(phone, indent=2, ensure_ascii=False)}") + + # Analysiere Struktur + if i == 0: + print(f"\n📊 Feld-Struktur:") + for key, value in phone.items(): + print(f" • {key:20s}: {type(value).__name__:10s} = {value}") + else: + print("\n❌ Keine Telefonnummern vorhanden") + + # Prüfe andere Beteiligten mit mehr Kommunikationsdaten + print_section("SUCHE: Beteiligter mit mehr Kommunikationsdaten") + + print("\n🔍 Liste erste 20 Beteiligte und prüfe Kommunikationsdaten...\n") + + beteiligte_list = await espo.list_entities('CBeteiligte', max_size=20) + + best_example = None + max_comm_count = 0 + + for bet in beteiligte_list: + # list_entities kann Strings oder Dicts zurückgeben + if isinstance(bet, str): + continue + + email_count = len(bet.get('emailAddressData', [])) + phone_count = len(bet.get('phoneNumberData', [])) + total = email_count + phone_count + + if total > 0: + print(f"• {bet.get('name', 'N/A')[:40]:40s} | " + f"Email: {email_count} | Phone: {phone_count}") + + if total > max_comm_count: + max_comm_count = total + best_example = bet + + if best_example and max_comm_count > 0: + print(f"\n✅ Bester Beispiel-Beteiligter: {best_example.get('name')}") + print(f" Gesamt: {max_comm_count} Kommunikationseinträge") + + print("\n📧 emailAddressData:") + for i, email in enumerate(best_example.get('emailAddressData', [])): + print(f"\n [{i+1}] {json.dumps(email, indent=6, ensure_ascii=False)}") + + print("\n📞 phoneNumberData:") + for i, phone in enumerate(best_example.get('phoneNumberData', [])): + print(f"\n [{i+1}] {json.dumps(phone, indent=6, ensure_ascii=False)}") + + return entity, email_data, phone_data, best_example + + +async def main(): + print("\n" + "="*70) + print("ESPOCRM KOMMUNIKATION - DETAIL-ANALYSE") + print("="*70) + print("\nZiel: Verstehe die Struktur von emailAddressData und phoneNumberData") + print("Frage: Haben diese Arrays IDs für Matching mit Advoware?\n") + + try: + entity, emails, phones, best = await analyze_communication_data() + + print_section("ZUSAMMENFASSUNG") + + print("\n📊 Erkenntnisse:") + + print("\n1️⃣ EspoCRM Standard-Struktur:") + print(" • emailAddressData: Array von Email-Objekten") + print(" • phoneNumberData: Array von Telefon-Objekten") + print(" • Keine separate CKommunikation Entity") + + if emails: + print("\n2️⃣ emailAddressData Felder:") + sample = emails[0] + for key in sample.keys(): + print(f" • {key}") + + if 'id' in sample: + print("\n ✅ Hat 'id' Feld → Kann für Matching verwendet werden!") + else: + print("\n ❌ Kein 'id' Feld → Matching via Wert (emailAddress)") + + if phones: + print("\n3️⃣ phoneNumberData Felder:") + sample = phones[0] + for key in sample.keys(): + print(f" • {key}") + + if 'id' in sample: + print("\n ✅ Hat 'id' Feld → Kann für Matching verwendet werden!") + else: + print("\n ❌ Kein 'id' Feld → Matching via Wert (phoneNumber)") + + print("\n💡 Sync-Strategie:") + print("\n Option A: Kommunikation als Teil von Beteiligte-Sync") + print(" ────────────────────────────────────────────────────") + print(" • emailAddressData → Advoware Kommunikation (kommKz=4)") + print(" • phoneNumberData → Advoware Kommunikation (kommKz=1)") + print(" • Sync innerhalb von beteiligte_sync.py") + print(" • Kein separates Entity in EspoCRM nötig") + + print("\n Option B: Custom CKommunikation Entity erstellen") + print(" ────────────────────────────────────────────────────") + print(" • Neues Custom Entity in EspoCRM anlegen") + print(" • Many-to-One Beziehung zu CBeteiligte") + print(" • Separater kommunikation_sync.py") + print(" • Ermöglicht mehr Flexibilität (Fax, BeA, etc.)") + + print("\n ⚠️ WICHTIG:") + print(" • Standard EspoCRM hat NUR Email und Phone") + print(" • Advoware hat 12 verschiedene Kommunikationstypen") + print(" • Für vollständigen Sync → Custom Entity empfohlen") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_espocrm_phone_email_entities.py b/bitbylaw/scripts/test_espocrm_phone_email_entities.py new file mode 100644 index 00000000..c93d46cd --- /dev/null +++ b/bitbylaw/scripts/test_espocrm_phone_email_entities.py @@ -0,0 +1,297 @@ +""" +Test: PhoneNumber und EmailAddress als System-Entities + +Hypothese: +- PhoneNumber und EmailAddress sind separate Entities mit IDs +- CBeteiligte hat Links/Relations zu diesen Entities +- Wir können über related entries an die IDs kommen + +Ziele: +1. Hole CBeteiligte mit expanded relationships +2. Prüfe ob phoneNumbers/emailAddresses als Links verfügbar sind +3. Extrahiere IDs der verknüpften PhoneNumber/EmailAddress Entities +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_related_entities(): + """Test 1: Hole CBeteiligte mit allen verfügbaren Feldern""" + print_section("TEST 1: CBeteiligte - Alle Felder") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Hole Entity + entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + + print(f"\n✅ Beteiligter: {entity.get('name')}") + print(f"\n📋 Alle Top-Level Felder:") + for key in sorted(entity.keys()): + value = entity[key] + value_type = type(value).__name__ + + # Zeige nur ersten Teil von langen Werten + if isinstance(value, str) and len(value) > 60: + display = f"{value[:60]}..." + elif isinstance(value, list): + display = f"[{len(value)} items]" + elif isinstance(value, dict): + display = f"{{dict with {len(value)} keys}}" + else: + display = value + + print(f" • {key:30s}: {value_type:10s} = {display}") + + # Suche nach ID-Feldern für Kommunikation + print(f"\n🔍 Suche nach ID-Feldern für Email/Phone:") + + potential_id_fields = [k for k in entity.keys() if 'email' in k.lower() or 'phone' in k.lower()] + for field in potential_id_fields: + print(f" • {field}: {entity.get(field)}") + + return entity + + +async def test_list_with_select(): + """Test 2: Nutze select Parameter um spezifische Felder zu holen""" + print_section("TEST 2: CBeteiligte mit select Parameter") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Versuche verschiedene Feld-Namen + potential_fields = [ + 'emailAddresses', + 'phoneNumbers', + 'emailAddressId', + 'phoneNumberId', + 'emailAddressIds', + 'phoneNumberIds', + 'emailAddressList', + 'phoneNumberList' + ] + + print(f"\n📋 Teste verschiedene Feld-Namen:") + + for field in potential_fields: + try: + result = await espo.api_call( + f'CBeteiligte/{TEST_BETEILIGTE_ID}', + params={'select': field} + ) + if result and field in result: + print(f" ✅ {field:30s}: {result[field]}") + else: + print(f" ❌ {field:30s}: Nicht im Response") + except Exception as e: + print(f" ❌ {field:30s}: Error - {e}") + + +async def test_entity_relationships(): + """Test 3: Hole Links/Relationships über dedizierte Endpoints""" + print_section("TEST 3: Entity Relationships") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Test verschiedene Relationship-Endpoints + relationship_names = [ + 'emailAddresses', + 'phoneNumbers', + 'emails', + 'phones' + ] + + for rel_name in relationship_names: + print(f"\n🔗 Teste Relationship: {rel_name}") + try: + # EspoCRM API Format: /Entity/{id}/relationship-name + result = await espo.api_call(f'CBeteiligte/{TEST_BETEILIGTE_ID}/{rel_name}') + + if result: + print(f" ✅ Success! Type: {type(result)}") + + if isinstance(result, dict): + print(f" 📋 Response Keys: {list(result.keys())}") + + # Häufige EspoCRM Response-Strukturen + if 'list' in result: + items = result['list'] + print(f" 📊 {len(items)} Einträge in 'list'") + if items: + print(f"\n Erster Eintrag:") + print(json.dumps(items[0], indent=6, ensure_ascii=False)) + + if 'total' in result: + print(f" 📊 Total: {result['total']}") + + elif isinstance(result, list): + print(f" 📊 {len(result)} Einträge direkt als Liste") + if result: + print(f"\n Erster Eintrag:") + print(json.dumps(result[0], indent=6, ensure_ascii=False)) + else: + print(f" ⚠️ Empty response") + + except Exception as e: + error_msg = str(e) + if '404' in error_msg: + print(f" ❌ 404 Not Found - Relationship existiert nicht") + elif '403' in error_msg: + print(f" ❌ 403 Forbidden - Kein Zugriff") + else: + print(f" ❌ Error: {error_msg}") + + +async def test_direct_entity_access(): + """Test 4: Direkter Zugriff auf PhoneNumber/EmailAddress Entities""" + print_section("TEST 4: Direkte Entity-Abfrage") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + # Versuche die Entities direkt zu listen + for entity_type in ['PhoneNumber', 'EmailAddress']: + print(f"\n📋 Liste {entity_type} Entities:") + try: + # Mit Filter für unseren Beteiligten + result = await espo.api_call( + entity_type, + params={ + 'maxSize': 5, + 'where': json.dumps([{ + 'type': 'equals', + 'attribute': 'parentId', + 'value': TEST_BETEILIGTE_ID + }]) + } + ) + + if result and 'list' in result: + items = result['list'] + print(f" ✅ {len(items)} Einträge gefunden") + for item in items: + print(f"\n 📧/📞 {entity_type}:") + print(json.dumps(item, indent=6, ensure_ascii=False)) + else: + print(f" ⚠️ Keine Einträge oder unerwartetes Format") + print(f" Response: {result}") + + except Exception as e: + error_msg = str(e) + if '403' in error_msg: + print(f" ❌ 403 Forbidden") + print(f" → Versuche ohne Filter...") + + try: + # Ohne Filter + result = await espo.api_call(entity_type, params={'maxSize': 3}) + print(f" ✅ Ohne Filter: {result.get('total', 0)} total existieren") + except Exception as e2: + print(f" ❌ Auch ohne Filter: {e2}") + else: + print(f" ❌ Error: {error_msg}") + + +async def test_espocrm_metadata(): + """Test 5: Prüfe EspoCRM Metadata für CBeteiligte""" + print_section("TEST 5: EspoCRM Metadata") + + context = SimpleContext() + espo = EspoCRMAPI(context) + + print(f"\n📋 Hole Metadata für CBeteiligte:") + try: + # EspoCRM bietet manchmal Metadata-Endpoints + result = await espo.api_call('Metadata') + + if result and 'entityDefs' in result: + if 'CBeteiligte' in result['entityDefs']: + bet_meta = result['entityDefs']['CBeteiligte'] + + print(f"\n ✅ CBeteiligte Metadata gefunden") + + if 'links' in bet_meta: + print(f"\n 🔗 Links/Relationships:") + for link_name, link_def in bet_meta['links'].items(): + if 'email' in link_name.lower() or 'phone' in link_name.lower(): + print(f" • {link_name}: {link_def}") + + if 'fields' in bet_meta: + print(f"\n 📋 Relevante Felder:") + for field_name, field_def in bet_meta['fields'].items(): + if 'email' in field_name.lower() or 'phone' in field_name.lower(): + print(f" • {field_name}: {field_def.get('type', 'unknown')}") + else: + print(f" ⚠️ Unerwartetes Format") + + except Exception as e: + print(f" ❌ Error: {e}") + + +async def main(): + print("\n" + "="*70) + print("ESPOCRM PHONENUMBER/EMAILADDRESS - ENTITIES & IDS") + print("="*70) + print("\nZiel: Finde IDs für PhoneNumber/EmailAddress über Relationships\n") + + try: + # Test 1: Alle Felder inspizieren + entity = await test_related_entities() + + # Test 2: Select Parameter + await test_list_with_select() + + # Test 3: Relationships + await test_entity_relationships() + + # Test 4: Direkte Entity-Abfrage + await test_direct_entity_access() + + # Test 5: Metadata + await test_espocrm_metadata() + + print_section("ZUSAMMENFASSUNG") + + print("\n🎯 Erkenntnisse:") + print("\n Wenn PhoneNumber/EmailAddress System-Entities sind:") + print(" 1. ✅ Sie haben eigene IDs") + print(" 2. ✅ Stabiles Matching möglich") + print(" 3. ✅ Bidirektionaler Sync machbar") + print(" 4. ✅ Change Detection via ID") + + print("\n Wenn wir IDs haben:") + print(" • Können Advoware-ID zu EspoCRM-ID mappen") + print(" • Können Änderungen tracken") + print(" • Kein Problem bei Wert-Änderungen") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommart_values.py b/bitbylaw/scripts/test_kommart_values.py new file mode 100644 index 00000000..a52045fe --- /dev/null +++ b/bitbylaw/scripts/test_kommart_values.py @@ -0,0 +1,109 @@ +""" +Test: Was liefert kommArt im Vergleich zu kommKz? + +kommArt sollte sein: +- 0 = Telefon/Fax +- 1 = Email +- 2 = Internet + +Wenn kommArt funktioniert, können wir damit unterscheiden! +""" + +import asyncio +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def main(): + print("\n" + "="*70) + print("ADVOWARE kommArt vs kommKz") + print("="*70) + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Hole Beteiligte mit Kommunikationen + result = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_BETNR}') + beteiligte = result[0] + kommunikationen = beteiligte.get('kommunikation', []) + + print(f"\n✅ {len(kommunikationen)} Kommunikationen gefunden\n") + print(f"{'ID':>8s} | {'kommKz':>6s} | {'kommArt':>7s} | {'Wert':40s}") + print("-" * 70) + + kommkz_values = [] + kommart_values = [] + + for k in kommunikationen: + komm_id = k.get('id') + kommkz = k.get('kommKz', 'N/A') + kommart = k.get('kommArt', 'N/A') + wert = k.get('tlf', '')[:40] + + kommkz_values.append(kommkz) + kommart_values.append(kommart) + + # Markiere wenn Wert aussagekräftig ist + kommkz_str = f"{kommkz}" if kommkz != 0 else f"❌ {kommkz}" + kommart_str = f"{kommart}" if kommart != 0 else f"❌ {kommart}" + + print(f"{komm_id:8d} | {kommkz_str:>6s} | {kommart_str:>7s} | {wert}") + + print_section("ANALYSE") + + # Statistik + print(f"\n📊 kommKz Werte:") + print(f" • Alle Werte: {set(kommkz_values)}") + print(f" • Alle sind 0: {all(v == 0 for v in kommkz_values)}") + + print(f"\n📊 kommArt Werte:") + print(f" • Alle Werte: {set(kommart_values)}") + print(f" • Alle sind 0: {all(v == 0 for v in kommart_values)}") + + print_section("FAZIT") + + if not all(v == 0 for v in kommart_values): + print("\n✅ kommArt IST BRAUCHBAR!") + print("\nMapping:") + print(" 0 = Telefon/Fax") + print(" 1 = Email") + print(" 2 = Internet") + + print("\n🎉 PERFEKT! Wir können unterscheiden:") + print(" • kommArt=0 → Telefon (zu phoneNumberData)") + print(" • kommArt=1 → Email (zu emailAddressData)") + print(" • kommArt=2 → Internet (überspringen oder zu Notiz)") + + print("\n💡 Advoware → EspoCRM:") + print(" 1. Nutze kommArt um Typ zu erkennen") + print(" 2. Speichere in bemerkung: [ESPOCRM:hash:kommArt]") + print(" 3. Bei Reverse-Sync: Nutze kommArt aus bemerkung") + + else: + print("\n❌ kommArt ist AUCH 0 - genau wie kommKz") + print("\n→ Wir müssen Typ aus Wert ableiten (Email vs. Telefon)") + print(" • '@' im Wert → Email") + print(" • '+' oder Ziffern → Telefon") + print("\n→ Feinere Unterscheidung (TelGesch vs TelPrivat) NICHT möglich") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommunikation_api.py b/bitbylaw/scripts/test_kommunikation_api.py new file mode 100644 index 00000000..6a5a9692 --- /dev/null +++ b/bitbylaw/scripts/test_kommunikation_api.py @@ -0,0 +1,361 @@ +""" +Test: Advoware Kommunikation API +Testet POST/GET/PUT/DELETE Operationen für Kommunikationen + +Basierend auf Swagger: +- POST /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen +- PUT /api/v1/advonet/Beteiligte/{beteiligterId}/Kommunikationen/{kommunikationId} +- GET enthalten in Beteiligte response (kommunikation array) +- DELETE nicht dokumentiert (wird getestet) +""" + +import asyncio +import json +import sys +from services.advoware import AdvowareAPI +from services.espocrm import EspoCRMAPI + +# Test-Beteiligter +TEST_BETNR = 104860 # Angela Mustermanns + +# KommKz Enum (Kommunikationskennzeichen) +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' +} + + +class SimpleContext: + """Einfacher Context für Logging""" + class Logger: + def info(self, msg): print(f"ℹ️ {msg}") + def error(self, msg): print(f"❌ {msg}") + def warning(self, msg): print(f"⚠️ {msg}") + def debug(self, msg): print(f"🔍 {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70 + "\n") + + +def print_json(title, data): + print(f"\n{title}:") + print("-" * 70) + print(json.dumps(data, indent=2, ensure_ascii=False)) + print() + + +async def test_get_existing_kommunikationen(): + """Hole bestehende Kommunikationen vom Test-Beteiligten""" + print_section("TEST 1: GET Bestehende Kommunikationen") + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Hole kompletten Beteiligten + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + # Response ist ein Array (selbst bei einzelnem Beteiligten) + if isinstance(result, list) and len(result) > 0: + beteiligte = result[0] + elif isinstance(result, dict): + beteiligte = result + else: + print(f"❌ Unerwartetes Response-Format: {type(result)}") + return [] + + kommunikationen = beteiligte.get('kommunikation', []) + + print(f"✓ Beteiligter geladen: {beteiligte.get('name')} {beteiligte.get('vorname')}") + print(f"✓ Kommunikationen gefunden: {len(kommunikationen)}") + + if kommunikationen: + print_json("Bestehende Kommunikationen", kommunikationen) + + # Analysiere Felder + first = kommunikationen[0] + print("📊 Felder-Analyse (erste Kommunikation):") + for key, value in first.items(): + print(f" - {key}: {value} ({type(value).__name__})") + else: + print("ℹ️ Keine Kommunikationen vorhanden") + + return kommunikationen + + +async def test_post_kommunikation(): + """Teste POST - Neue Kommunikation erstellen""" + print_section("TEST 2: POST - Neue Kommunikation erstellen") + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Test verschiedene KommKz Typen + test_cases = [ + { + 'name': 'Geschäftstelefon', + 'data': { + 'kommKz': 1, # TelGesch + 'tlf': '+49 511 123456-10', + 'bemerkung': 'TEST: Hauptnummer', + 'online': False + } + }, + { + 'name': 'Geschäfts-Email', + 'data': { + 'kommKz': 4, # MailGesch + 'tlf': 'test@example.com', + 'bemerkung': 'TEST: Email', + 'online': True + } + }, + { + 'name': 'Mobiltelefon', + 'data': { + 'kommKz': 3, # Mobil + 'tlf': '+49 170 1234567', + 'bemerkung': 'TEST: Mobil', + 'online': False + } + } + ] + + created_ids = [] + + for test in test_cases: + print(f"\n📝 Erstelle: {test['name']}") + print_json("Request Payload", test['data']) + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen', + method='POST', + data=test['data'] + ) + + print_json("Response", result) + + # Extrahiere ID + if isinstance(result, list) and len(result) > 0: + created_id = result[0].get('id') + created_ids.append(created_id) + print(f"✅ Erstellt mit ID: {created_id}") + elif isinstance(result, dict): + created_id = result.get('id') + created_ids.append(created_id) + print(f"✅ Erstellt mit ID: {created_id}") + else: + print(f"❌ Unerwartetes Response-Format: {type(result)}") + + except Exception as e: + print(f"❌ Fehler: {e}") + + return created_ids + + +async def test_put_kommunikation(komm_id): + """Teste PUT - Kommunikation aktualisieren""" + print_section(f"TEST 3: PUT - Kommunikation {komm_id} aktualisieren") + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Hole aktuelle Daten + print("📥 Lade aktuelle Kommunikation...") + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + # Response ist ein Array + if isinstance(result, list) and len(result) > 0: + beteiligte = result[0] + elif isinstance(result, dict): + beteiligte = result + else: + print(f"❌ Unerwartetes Response-Format") + return False + + kommunikationen = beteiligte.get('kommunikation', []) + current_komm = next((k for k in kommunikationen if k.get('id') == komm_id), None) + + if not current_komm: + print(f"❌ Kommunikation {komm_id} nicht gefunden!") + return False + + print_json("Aktuelle Daten", current_komm) + + # Test 1: Ändere tlf-Feld + print("\n🔄 Test 1: Ändere tlf (Telefonnummer/Email)") + update_data = { + 'kommKz': current_komm['kommKz'], + 'tlf': '+49 511 999999-99', # Neue Nummer + 'bemerkung': current_komm.get('bemerkung', ''), + 'online': current_komm.get('online', False) + } + + print_json("Update Payload", update_data) + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + print_json("Response", result) + print("✅ tlf erfolgreich geändert") + except Exception as e: + print(f"❌ Fehler: {e}") + return False + + # Test 2: Ändere bemerkung + print("\n🔄 Test 2: Ändere bemerkung") + update_data['bemerkung'] = 'TEST: Geändert via API' + print_json("Update Payload", update_data) + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + print_json("Response", result) + print("✅ bemerkung erfolgreich geändert") + except Exception as e: + print(f"❌ Fehler: {e}") + return False + + # Test 3: Ändere kommKz (Typ) + print("\n🔄 Test 3: Ändere kommKz (Kommunikationstyp)") + update_data['kommKz'] = 6 # TelPrivat statt TelGesch + print_json("Update Payload", update_data) + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + print_json("Response", result) + print("✅ kommKz erfolgreich geändert") + except Exception as e: + print(f"❌ Fehler: {e}") + return False + + # Test 4: Ändere online-Flag + print("\n🔄 Test 4: Ändere online-Flag") + update_data['online'] = not update_data['online'] + print_json("Update Payload", update_data) + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + print_json("Response", result) + print("✅ online erfolgreich geändert") + except Exception as e: + print(f"❌ Fehler: {e}") + return False + + return True + + +async def test_delete_kommunikation(komm_id): + """Teste DELETE - Kommunikation löschen""" + print_section(f"TEST 4: DELETE - Kommunikation {komm_id} löschen") + + context = SimpleContext() + advo = AdvowareAPI(context) + + print(f"🗑️ Versuche Kommunikation {komm_id} zu löschen...") + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='DELETE' + ) + print_json("Response", result) + print("✅ DELETE erfolgreich!") + return True + except Exception as e: + print(f"❌ DELETE fehlgeschlagen: {e}") + + # Check ob 403 Forbidden (wie bei Adressen) + if '403' in str(e): + print("⚠️ DELETE ist FORBIDDEN (wie bei Adressen)") + + return False + + +async def main(): + print("\n" + "="*70) + print("ADVOWARE KOMMUNIKATION API - VOLLSTÄNDIGER TEST") + print("="*70) + print(f"\nTest-Beteiligter: {TEST_BETNR}") + print("\nKommKz (Kommunikationskennzeichen):") + for kz, name in KOMMKZ.items(): + print(f" {kz:2d} = {name}") + + try: + # TEST 1: GET bestehende + existing = await test_get_existing_kommunikationen() + + # TEST 2: POST neue + created_ids = await test_post_kommunikation() + + if not created_ids: + print("\n❌ Keine Kommunikationen erstellt - Tests abgebrochen") + return + + # TEST 3: PUT update (erste erstellte) + first_id = created_ids[0] + await test_put_kommunikation(first_id) + + # TEST 4: DELETE (erste erstellte) + await test_delete_kommunikation(first_id) + + # Finale Übersicht + print_section("ZUSAMMENFASSUNG") + print("✅ POST: Funktioniert (3 Typen getestet)") + print("✅ GET: Funktioniert (über Beteiligte-Endpoint)") + print("✓/✗ PUT: Siehe Testergebnisse oben") + print("✓/✗ DELETE: Siehe Testergebnisse oben") + + print("\n⚠️ WICHTIG:") + print(f" - Test-Kommunikationen in Advoware manuell prüfen!") + print(f" - BetNr: {TEST_BETNR}") + print(" - Suche nach: 'TEST:'") + + if len(created_ids) > 1: + print(f"\n📝 Erstellt wurden IDs: {created_ids}") + print(" Falls DELETE nicht funktioniert, manuell löschen!") + + except Exception as e: + print(f"\n❌ Unerwarteter Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommunikation_kommkz_deep.py b/bitbylaw/scripts/test_kommunikation_kommkz_deep.py new file mode 100644 index 00000000..a964e65f --- /dev/null +++ b/bitbylaw/scripts/test_kommunikation_kommkz_deep.py @@ -0,0 +1,252 @@ +""" +Tiefenanalyse: kommKz Feld-Verhalten + +Beobachtung: +- PUT Response zeigt kommKz: 1 +- Nachfolgender GET zeigt kommKz: 0 (!) +- 0 ist kein gültiger kommKz-Wert (1-12) + +Test: Prüfe ob kommKz überhaupt korrekt gespeichert/gelesen wird +""" + +import asyncio +import json +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_kommkz_behavior(): + """Teste kommKz Verhalten in Detail""" + + context = SimpleContext() + advo = AdvowareAPI(context) + + # SCHRITT 1: Erstelle mit kommKz=3 (Mobil) + print_section("SCHRITT 1: CREATE mit kommKz=3 (Mobil)") + + create_data = { + 'kommKz': 3, # Mobil + 'tlf': '+49 170 999-TEST', + 'bemerkung': 'TEST-DEEP: Initial kommKz=3', + 'online': False + } + + print(f"📤 CREATE Request:") + print(json.dumps(create_data, indent=2)) + + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen', + method='POST', + data=create_data + ) + + if isinstance(result, list): + created = result[0] + else: + created = result + + komm_id = created['id'] + + print(f"\n✅ POST Response:") + print(f" id: {created['id']}") + print(f" kommKz: {created['kommKz']}") + print(f" kommArt: {created['kommArt']}") + print(f" tlf: {created['tlf']}") + print(f" bemerkung: {created['bemerkung']}") + + # SCHRITT 2: Sofortiger GET nach CREATE + print_section("SCHRITT 2: GET direkt nach CREATE") + + beteiligte = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + if isinstance(beteiligte, list): + beteiligte = beteiligte[0] + + kommunikationen = beteiligte.get('kommunikation', []) + get_komm = next((k for k in kommunikationen if k['id'] == komm_id), None) + + if get_komm: + print(f"📥 GET Response:") + print(f" id: {get_komm['id']}") + print(f" kommKz: {get_komm['kommKz']}") + print(f" kommArt: {get_komm['kommArt']}") + print(f" tlf: {get_komm['tlf']}") + print(f" bemerkung: {get_komm['bemerkung']}") + + if get_komm['kommKz'] != 3: + print(f"\n⚠️ WARNUNG: kommKz nach CREATE stimmt nicht!") + print(f" Erwartet: 3") + print(f" Tatsächlich: {get_komm['kommKz']}") + + # SCHRITT 3: PUT mit gleichem kommKz (keine Änderung) + print_section("SCHRITT 3: PUT mit gleichem kommKz=3") + + update_data = { + 'kommKz': 3, # GLEICH wie original + 'tlf': '+49 170 999-TEST', + 'bemerkung': 'TEST-DEEP: PUT mit gleichem kommKz=3', + 'online': False + } + + print(f"📤 PUT Request (keine kommKz-Änderung):") + print(json.dumps(update_data, indent=2)) + + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + + print(f"\n✅ PUT Response:") + print(f" kommKz: {result['kommKz']}") + print(f" kommArt: {result['kommArt']}") + print(f" bemerkung: {result['bemerkung']}") + + # GET nach PUT + print(f"\n🔍 GET nach PUT:") + beteiligte = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + if isinstance(beteiligte, list): + beteiligte = beteiligte[0] + + kommunikationen = beteiligte.get('kommunikation', []) + get_komm = next((k for k in kommunikationen if k['id'] == komm_id), None) + + if get_komm: + print(f" kommKz: {get_komm['kommKz']}") + print(f" kommArt: {get_komm['kommArt']}") + print(f" bemerkung: {get_komm['bemerkung']}") + + # SCHRITT 4: PUT mit ANDEREM kommKz + print_section("SCHRITT 4: PUT mit kommKz=7 (FaxPrivat)") + + update_data = { + 'kommKz': 7, # ÄNDERN: Mobil → FaxPrivat + 'tlf': '+49 170 999-TEST', + 'bemerkung': 'TEST-DEEP: Versuch kommKz 3→7', + 'online': False + } + + print(f"📤 PUT Request (kommKz-Änderung 3→7):") + print(json.dumps(update_data, indent=2)) + + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=update_data + ) + + print(f"\n✅ PUT Response:") + print(f" kommKz: {result['kommKz']}") + print(f" kommArt: {result['kommArt']}") + print(f" bemerkung: {result['bemerkung']}") + + # GET nach PUT mit Änderungsversuch + print(f"\n🔍 GET nach PUT (mit Änderungsversuch):") + beteiligte = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + if isinstance(beteiligte, list): + beteiligte = beteiligte[0] + + kommunikationen = beteiligte.get('kommunikation', []) + get_komm = next((k for k in kommunikationen if k['id'] == komm_id), None) + + if get_komm: + print(f" kommKz: {get_komm['kommKz']}") + print(f" kommArt: {get_komm['kommArt']}") + print(f" bemerkung: {get_komm['bemerkung']}") + + print(f"\n📊 Zusammenfassung für ID {komm_id}:") + print(f" CREATE Request: kommKz=3") + print(f" CREATE Response: kommKz={created['kommKz']}") + print(f" GET nach CREATE: kommKz={kommunikationen[0].get('kommKz', 'N/A') if kommunikationen else 'N/A'}") + print(f" PUT Request (change): kommKz=7") + print(f" PUT Response: kommKz={result['kommKz']}") + print(f" GET nach PUT: kommKz={get_komm['kommKz']}") + + if get_komm['kommKz'] == 7: + print(f"\n✅ kommKz wurde geändert auf 7!") + elif get_komm['kommKz'] == 3: + print(f"\n❌ kommKz blieb bei 3 (READ-ONLY bestätigt)") + elif get_komm['kommKz'] == 0: + print(f"\n⚠️ kommKz ist 0 (ungültiger Wert - möglicherweise Bug in API)") + else: + print(f"\n⚠️ kommKz hat unerwarteten Wert: {get_komm['kommKz']}") + + # SCHRITT 5: Vergleiche mit bestehenden Kommunikationen + print_section("SCHRITT 5: Vergleich mit bestehenden Kommunikationen") + + print(f"\nAlle Kommunikationen von Beteiligten {TEST_BETNR}:") + for i, k in enumerate(kommunikationen): + print(f"\n [{i+1}] ID: {k['id']}") + print(f" kommKz: {k['kommKz']}") + print(f" kommArt: {k['kommArt']}") + print(f" tlf: {k.get('tlf', '')[:40]}") + print(f" bemerkung: {k.get('bemerkung', '')[:40] if k.get('bemerkung') else 'null'}") + print(f" online: {k.get('online')}") + + # Prüfe auf Inkonsistenzen + if k['kommKz'] == 0 and k['kommArt'] != 0: + print(f" ⚠️ INKONSISTENZ: kommKz=0 aber kommArt={k['kommArt']}") + + print(f"\n⚠️ Test-Kommunikation {komm_id} manuell löschen!") + + return komm_id + + +async def main(): + print("\n" + "="*70) + print("TIEFENANALYSE: kommKz Feld-Verhalten") + print("="*70) + print("\nZiel: Verstehen warum GET kommKz=0 zeigt") + print("Methode: Schrittweise CREATE/PUT/GET mit detailliertem Tracking\n") + + try: + komm_id = await test_kommkz_behavior() + + print_section("FAZIT") + print("\n📌 Erkenntnisse:") + print(" 1. POST Response zeigt den gesendeten kommKz") + print(" 2. PUT Response zeigt oft den gesendeten kommKz") + print(" 3. GET Response zeigt den TATSÄCHLICH gespeicherten Wert") + print(" 4. kommKz=0 in GET deutet auf ein Problem hin") + print("\n💡 Empfehlung:") + print(" - Immer GET nach PUT für Verifizierung") + print(" - Nicht auf PUT Response verlassen") + print(" - kommKz ist definitiv READ-ONLY bei PUT") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommunikation_matching_strategy.py b/bitbylaw/scripts/test_kommunikation_matching_strategy.py new file mode 100644 index 00000000..3864b6ed --- /dev/null +++ b/bitbylaw/scripts/test_kommunikation_matching_strategy.py @@ -0,0 +1,395 @@ +""" +Matching-Strategie für Kommunikation ohne ID + +Problem: +- emailAddressData und phoneNumberData haben KEINE id-Felder +- Können keine rowId in EspoCRM speichern (keine Custom-Felder) +- Wie matchen wir Advoware ↔ EspoCRM? + +Lösungsansätze: +1. Wert-basiertes Matching (emailAddress/phoneNumber als Schlüssel) +2. Advoware als Master (One-Way-Sync mit Neuanlage bei Änderung) +3. Hash-basiertes Matching in bemerkung-Feld +4. Position-basiertes Matching (primary-Flag) +""" + +import asyncio +import json +from services.espocrm import EspoCRMAPI +from services.advoware import AdvowareAPI + +TEST_BETEILIGTE_ID = '68e4af00172be7924' +TEST_ADVOWARE_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass # Suppress debug + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_value_based_matching(): + """ + Strategie 1: Wert-basiertes Matching + + Idee: Verwende emailAddress/phoneNumber selbst als Schlüssel + + Vorteile: + - Einfach zu implementieren + - Funktioniert für Duplikats-Erkennung + + Nachteile: + - Wenn Wert ändert, verlieren wir Verbindung + - Keine Change-Detection möglich (kein Timestamp/rowId) + """ + print_section("STRATEGIE 1: Wert-basiertes Matching") + + context = SimpleContext() + espo = EspoCRMAPI(context) + advo = AdvowareAPI(context) + + # Hole Daten + espo_entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + advo_entity = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_ADVOWARE_BETNR}') + + print("\n📧 EspoCRM Emails:") + espo_emails = {e['emailAddress']: e for e in espo_entity.get('emailAddressData', [])} + for email, data in espo_emails.items(): + print(f" • {email:40s} primary={data.get('primary', False)}") + + print("\n📧 Advoware Kommunikation (Typ MailGesch=4, MailPrivat=8):") + advo_komm = advo_entity.get('kommunikation', []) + advo_emails = [k for k in advo_komm if k.get('kommKz') in [4, 8]] # Email-Typen + for k in advo_emails: + print(f" • {k.get('tlf', ''):40s} Typ={k.get('kommKz')} ID={k.get('id')} " + f"rowId={k.get('rowId')}") + + print("\n🔍 Matching-Ergebnis:") + matched = [] + unmatched_espo = [] + unmatched_advo = [] + + for advo_k in advo_emails: + email_value = advo_k.get('tlf', '').strip() + if email_value in espo_emails: + matched.append((advo_k, espo_emails[email_value])) + print(f" ✅ MATCH: {email_value}") + else: + unmatched_advo.append(advo_k) + print(f" ❌ Nur in Advoware: {email_value}") + + for email_value in espo_emails: + if not any(k.get('tlf', '').strip() == email_value for k in advo_emails): + unmatched_espo.append(espo_emails[email_value]) + print(f" ⚠️ Nur in EspoCRM: {email_value}") + + print(f"\n📊 Statistik:") + print(f" • Matched: {len(matched)}") + print(f" • Nur Advoware: {len(unmatched_advo)}") + print(f" • Nur EspoCRM: {len(unmatched_espo)}") + + # Problem-Szenario: Was wenn Email-Adresse ändert? + print("\n⚠️ PROBLEM-SZENARIO: Email-Adresse ändert") + print(" 1. Advoware: max@old.de → max@new.de (UPDATE mit gleicher ID)") + print(" 2. Wert-Matching findet max@old.de nicht mehr in EspoCRM") + print(" 3. Sync würde max@new.de NEU anlegen statt UPDATE") + print(" 4. Ergebnis: Duplikat (max@old.de + max@new.de)") + + return matched, unmatched_advo, unmatched_espo + + +async def test_advoware_master_sync(): + """ + Strategie 2: Advoware als Master (One-Way-Sync) + + Idee: + - Ignoriere EspoCRM-Änderungen + - Bei jedem Sync: Überschreibe komplette Arrays in EspoCRM + + Vorteile: + - Sehr einfach + - Keine Change-Detection nötig + - Keine Matching-Probleme + + Nachteile: + - Verliert EspoCRM-Änderungen + - Nicht bidirektional + """ + print_section("STRATEGIE 2: Advoware als Master (One-Way)") + + context = SimpleContext() + advo = AdvowareAPI(context) + + advo_entity = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_ADVOWARE_BETNR}') + advo_komm = advo_entity.get('kommunikation', []) + + print("\n📋 Sync-Ablauf:") + print(" 1. Hole alle Advoware Kommunikationen") + print(" 2. Konvertiere zu EspoCRM Format:") + + # Konvertierung + email_data = [] + phone_data = [] + + for k in advo_komm: + komm_kz = k.get('kommKz', 0) + wert = k.get('tlf', '').strip() + online = k.get('online', False) + + # Email-Typen: 4=MailGesch, 8=MailPrivat, 11=EPost + if komm_kz in [4, 8, 11] and wert: + email_data.append({ + 'emailAddress': wert, + 'lower': wert.lower(), + 'primary': online, # online=true → primary + 'optOut': False, + 'invalid': False + }) + # Phone-Typen: 1=TelGesch, 2=FaxGesch, 3=Mobil, 6=TelPrivat, 7=FaxPrivat + elif komm_kz in [1, 2, 3, 6, 7] and wert: + # Mapping kommKz → EspoCRM type + type_map = { + 1: 'Office', # TelGesch + 3: 'Mobile', # Mobil + 6: 'Home', # TelPrivat + 2: 'Fax', # FaxGesch + 7: 'Fax', # FaxPrivat + } + phone_data.append({ + 'phoneNumber': wert, + 'type': type_map.get(komm_kz, 'Other'), + 'primary': online, + 'optOut': False, + 'invalid': False + }) + + print(f"\n 📧 {len(email_data)} Emails:") + for e in email_data: + print(f" • {e['emailAddress']:40s} primary={e['primary']}") + + print(f"\n 📞 {len(phone_data)} Phones:") + for p in phone_data: + print(f" • {p['phoneNumber']:40s} type={p['type']:10s} primary={p['primary']}") + + print("\n 3. UPDATE CBeteiligte (überschreibt komplette Arrays)") + print(" → emailAddressData: [...]") + print(" → phoneNumberData: [...]") + + print("\n✅ Vorteile:") + print(" • Sehr einfach zu implementieren") + print(" • Keine Matching-Logik erforderlich") + print(" • Advoware ist immer Source of Truth") + + print("\n❌ Nachteile:") + print(" • EspoCRM-Änderungen gehen verloren") + print(" • Nicht bidirektional") + print(" • User könnten verärgert sein") + + return email_data, phone_data + + +async def test_hybrid_strategy(): + """ + Strategie 3: Hybrid - Advoware Master + EspoCRM Ergänzungen + + Idee: + - Advoware-Kommunikationen sind primary=true (wichtig, geschützt) + - EspoCRM kann zusätzliche Einträge mit primary=false hinzufügen + - Nur Advoware-Einträge werden synchronisiert + + Vorteile: + - Flexibilität für EspoCRM-User + - Advoware behält Kontrolle über wichtige Daten + + Nachteile: + - Komplexere Logik + - Braucht Markierung (primary-Flag) + """ + print_section("STRATEGIE 3: Hybrid (Advoware Primary + EspoCRM Secondary)") + + context = SimpleContext() + espo = EspoCRMAPI(context) + advo = AdvowareAPI(context) + + # Hole Daten + espo_entity = await espo.get_entity('CBeteiligte', TEST_BETEILIGTE_ID) + advo_entity = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_ADVOWARE_BETNR}') + + advo_komm = advo_entity.get('kommunikation', []) + espo_emails = espo_entity.get('emailAddressData', []) + + print("\n📋 Regel:") + print(" • primary=true → Kommt von Advoware (synchronisiert)") + print(" • primary=false → Nur in EspoCRM (wird NICHT zu Advoware)") + + print("\n📧 Aktuelle EspoCRM Emails:") + for e in espo_emails: + source = "Advoware" if e.get('primary') else "EspoCRM" + print(f" • {e['emailAddress']:40s} primary={e.get('primary')} → {source}") + + print("\n🔄 Sync-Logik:") + print(" 1. Hole Advoware Kommunikationen") + print(" 2. Konvertiere zu EspoCRM (mit primary=true)") + print(" 3. Hole aktuelle EspoCRM Einträge mit primary=false") + print(" 4. Merge: Advoware (primary) + EspoCRM (secondary)") + print(" 5. UPDATE CBeteiligte mit gemergtem Array") + + # Simulation + advo_emails = [k for k in advo_komm if k.get('kommKz') in [4, 8, 11]] + + merged_emails = [] + + # Von Advoware (primary=true) + for k in advo_emails: + merged_emails.append({ + 'emailAddress': k.get('tlf', ''), + 'lower': k.get('tlf', '').lower(), + 'primary': True, # Immer primary für Advoware + 'optOut': False, + 'invalid': False + }) + + # Von EspoCRM (nur non-primary behalten) + for e in espo_emails: + if not e.get('primary', False): + merged_emails.append(e) + + print(f"\n📊 Merge-Ergebnis: {len(merged_emails)} Emails") + for e in merged_emails: + source = "Advoware" if e.get('primary') else "EspoCRM" + print(f" • {e['emailAddress']:40s} [{source}]") + + print("\n✅ Vorteile:") + print(" • Advoware behält Kontrolle") + print(" • EspoCRM-User können ergänzen") + print(" • Kein Datenverlust") + + print("\n⚠️ Einschränkungen:") + print(" • EspoCRM kann Advoware-Daten NICHT ändern") + print(" • primary-Flag muss geschützt werden") + + return merged_emails + + +async def test_bemerkung_tracking(): + """ + Strategie 4: Tracking via bemerkung-Feld + + Idee: Speichere Advoware-ID in bemerkung + + Format: "Advoware-ID: 149331 | Tatsächliche Bemerkung" + + Vorteile: + - Stabiles Matching möglich + - Kann Änderungen tracken + + Nachteile: + - bemerkung-Feld wird "verschmutzt" + - User sichtbar + - Fragil (User könnte löschen) + """ + print_section("STRATEGIE 4: Tracking via bemerkung-Feld") + + context = SimpleContext() + advo = AdvowareAPI(context) + + advo_entity = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_ADVOWARE_BETNR}') + advo_komm = advo_entity.get('kommunikation', []) + + print("\n⚠️ PROBLEM: EspoCRM emailAddressData/phoneNumberData haben KEIN bemerkung-Feld!") + print("\nStruktur emailAddressData:") + print(" {") + print(" 'emailAddress': 'max@example.com',") + print(" 'lower': 'max@example.com',") + print(" 'primary': true,") + print(" 'optOut': false,") + print(" 'invalid': false") + print(" }") + print("\n ❌ Kein 'bemerkung' oder 'notes' Feld verfügbar") + print(" ❌ Kein Custom-Feld möglich in Standard-Arrays") + + print("\n📋 Alternative: Advoware bemerkung nutzen") + print(" → Speichere EspoCRM-Wert in Advoware bemerkung") + + for k in advo_komm[:3]: # Erste 3 als Beispiel + advo_id = k.get('id') + wert = k.get('tlf', '') + bemerkung = k.get('bemerkung', '') + + print(f"\n Advoware ID {advo_id}:") + print(f" Wert: {wert}") + print(f" Bemerkung: {bemerkung or '(leer)'}") + print(f" → Neue Bemerkung: 'EspoCRM: {wert} | {bemerkung}'") + + print("\n✅ Matching-Strategie:") + print(" 1. Parse bemerkung: Extrahiere 'EspoCRM: '") + print(" 2. Matche Advoware ↔ EspoCRM via Wert in bemerkung") + print(" 3. Wenn Wert ändert: Update bemerkung") + + print("\n❌ Nachteile:") + print(" • bemerkung für User sichtbar und änderbar") + print(" • Fragil wenn User bemerkung bearbeitet") + print(" • Komplexe Parse-Logik") + + +async def main(): + print("\n" + "="*70) + print("KOMMUNIKATION MATCHING-STRATEGIEN OHNE ID") + print("="*70) + + try: + # Test alle Strategien + await test_value_based_matching() + await test_advoware_master_sync() + await test_hybrid_strategy() + await test_bemerkung_tracking() + + print_section("EMPFEHLUNG") + + print("\n🎯 BESTE LÖSUNG: Strategie 3 (Hybrid)") + print("\n✅ Begründung:") + print(" 1. Advoware behält Kontrolle (primary=true)") + print(" 2. EspoCRM kann ergänzen (primary=false)") + print(" 3. Einfach zu implementieren") + print(" 4. Kein Datenverlust") + print(" 5. primary-Flag ist Standard in EspoCRM") + + print("\n📋 Implementation:") + print(" • Advoware → EspoCRM: Setze primary=true") + print(" • EspoCRM → Advoware: Ignoriere primary=false Einträge") + print(" • Matching: Via Wert (emailAddress/phoneNumber)") + print(" • Change Detection: rowId in Advoware (wie bei Adressen)") + + print("\n🔄 Sync-Ablauf:") + print(" 1. Webhook von Advoware") + print(" 2. Lade Advoware Kommunikationen") + print(" 3. Filter: Nur Typen die EspoCRM unterstützt") + print(" 4. Konvertiere zu emailAddressData/phoneNumberData") + print(" 5. Setze primary=true für alle") + print(" 6. Merge mit bestehenden primary=false Einträgen") + print(" 7. UPDATE CBeteiligte") + + print("\n⚠️ Einschränkungen akzeptiert:") + print(" • EspoCRM → Advoware: Nur primary=false Einträge") + print(" • Keine bidirektionale Sync für Wert-Änderungen") + print(" • Bei Wert-Änderung: Neuanlage statt Update") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommunikation_readonly.py b/bitbylaw/scripts/test_kommunikation_readonly.py new file mode 100644 index 00000000..0efb82a6 --- /dev/null +++ b/bitbylaw/scripts/test_kommunikation_readonly.py @@ -0,0 +1,350 @@ +""" +Detaillierte Analyse: Welche Felder sind bei PUT änderbar? + +Basierend auf ersten Tests: +- POST funktioniert (alle 4 Felder) +- PUT funktioniert TEILWEISE +- DELETE = 403 Forbidden (wie bei Adressen/Bankverbindungen) + +Felder laut Swagger: +- tlf (string, nullable) +- bemerkung (string, nullable) +- kommKz (enum/int) +- online (boolean) + +Response enthält zusätzlich: +- id (int) - Kommunikations-ID +- betNr (int) - Beteiligten-ID +- kommArt (int) - Scheint von kommKz generiert zu werden +- rowId (string) - Änderungserkennung +""" + +import asyncio +import json +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def test_field_mutability(): + """Teste welche Felder bei PUT änderbar sind""" + + context = SimpleContext() + advo = AdvowareAPI(context) + + # STEP 1: Erstelle Test-Kommunikation + print_section("STEP 1: Erstelle Test-Kommunikation") + + create_data = { + 'kommKz': 1, # TelGesch + 'tlf': '+49 511 000000-00', + 'bemerkung': 'TEST-READONLY: Initial', + 'online': False + } + + print(f"📤 POST Data: {json.dumps(create_data, indent=2)}") + + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen', + method='POST', + data=create_data + ) + + if isinstance(result, list) and len(result) > 0: + created = result[0] + else: + created = result + + komm_id = created['id'] + original_rowid = created['rowId'] + + print(f"\n✅ Erstellt:") + print(f" ID: {komm_id}") + print(f" rowId: {original_rowid}") + print(f" kommArt: {created['kommArt']}") + print(f"\n📋 Vollständige Response:") + print(json.dumps(created, indent=2, ensure_ascii=False)) + + # STEP 2: Teste jedes Feld einzeln + print_section("STEP 2: Teste Feld-Änderbarkeit") + + test_results = {} + + # Test 1: tlf + print("\n🔬 Test 1/4: tlf (Telefonnummer/Email)") + print(" Änderung: '+49 511 000000-00' → '+49 511 111111-11'") + + test_data = { + 'kommKz': created['kommKz'], + 'tlf': '+49 511 111111-11', # GEÄNDERT + 'bemerkung': created['bemerkung'], + 'online': created['online'] + } + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=test_data + ) + + new_rowid = result['rowId'] + rowid_changed = (new_rowid != original_rowid) + value_changed = (result['tlf'] == '+49 511 111111-11') + + print(f" ✅ PUT erfolgreich") + print(f" 📊 Wert geändert: {value_changed}") + print(f" 📊 rowId geändert: {rowid_changed}") + print(f" Alt: {original_rowid}") + print(f" Neu: {new_rowid}") + + test_results['tlf'] = { + 'writable': value_changed, + 'rowid_changed': rowid_changed, + 'status': 'WRITABLE' if value_changed else 'READ-ONLY' + } + + original_rowid = new_rowid # Update für nächsten Test + + except Exception as e: + print(f" ❌ FEHLER: {e}") + test_results['tlf'] = {'writable': False, 'status': 'ERROR', 'error': str(e)} + + # Test 2: bemerkung + print("\n🔬 Test 2/4: bemerkung") + print(" Änderung: 'TEST-READONLY: Initial' → 'TEST-READONLY: Modified'") + + test_data = { + 'kommKz': created['kommKz'], + 'tlf': result['tlf'], # Aktueller Wert + 'bemerkung': 'TEST-READONLY: Modified', # GEÄNDERT + 'online': result['online'] + } + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=test_data + ) + + new_rowid = result['rowId'] + rowid_changed = (new_rowid != original_rowid) + value_changed = (result['bemerkung'] == 'TEST-READONLY: Modified') + + print(f" ✅ PUT erfolgreich") + print(f" 📊 Wert geändert: {value_changed}") + print(f" 📊 rowId geändert: {rowid_changed}") + + test_results['bemerkung'] = { + 'writable': value_changed, + 'rowid_changed': rowid_changed, + 'status': 'WRITABLE' if value_changed else 'READ-ONLY' + } + + original_rowid = new_rowid + + except Exception as e: + print(f" ❌ FEHLER: {e}") + test_results['bemerkung'] = {'writable': False, 'status': 'ERROR', 'error': str(e)} + + # Test 3: kommKz + print("\n🔬 Test 3/4: kommKz (Kommunikationstyp)") + original_kommkz = result['kommKz'] + target_kommkz = 6 + print(f" Änderung: {original_kommkz} (TelGesch) → {target_kommkz} (TelPrivat)") + + test_data = { + 'kommKz': target_kommkz, # GEÄNDERT + 'tlf': result['tlf'], + 'bemerkung': f"TEST-READONLY: Versuch kommKz {original_kommkz}→{target_kommkz}", + 'online': result['online'] + } + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=test_data + ) + + new_rowid = result['rowId'] + rowid_changed = (new_rowid != original_rowid) + value_changed = (result['kommKz'] == target_kommkz) + + print(f" ✅ PUT erfolgreich") + print(f" 📊 PUT Response kommKz: {result['kommKz']}") + print(f" 📊 PUT Response kommArt: {result['kommArt']}") + print(f" 📊 rowId geändert: {rowid_changed}") + + # WICHTIG: Nachfolgender GET zur Verifizierung + print(f"\n 🔍 Verifizierung via GET...") + beteiligte_get = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}', + method='GET' + ) + + if isinstance(beteiligte_get, list): + beteiligte_get = beteiligte_get[0] + + kommunikationen_get = beteiligte_get.get('kommunikation', []) + verify_komm = next((k for k in kommunikationen_get if k['id'] == komm_id), None) + + if verify_komm: + print(f" 📋 GET Response kommKz: {verify_komm['kommKz']}") + print(f" 📋 GET Response kommArt: {verify_komm['kommArt']}") + print(f" 📋 GET Response bemerkung: {verify_komm['bemerkung']}") + + # Finale Bewertung basierend auf GET + actual_value_changed = (verify_komm['kommKz'] == target_kommkz) + + if actual_value_changed: + print(f" ✅ BESTÄTIGT: kommKz wurde geändert auf {target_kommkz}") + else: + print(f" ❌ BESTÄTIGT: kommKz blieb bei {verify_komm['kommKz']} (nicht geändert!)") + + test_results['kommKz'] = { + 'writable': actual_value_changed, + 'rowid_changed': rowid_changed, + 'status': 'WRITABLE' if actual_value_changed else 'READ-ONLY', + 'requested_value': target_kommkz, + 'put_response_value': result['kommKz'], + 'get_response_value': verify_komm['kommKz'], + 'note': f"PUT sagte: {result['kommKz']}, GET sagte: {verify_komm['kommKz']}" + } + else: + print(f" ⚠️ Kommunikation nicht in GET gefunden") + test_results['kommKz'] = { + 'writable': False, + 'status': 'ERROR', + 'error': 'Not found in GET' + } + + original_rowid = new_rowid + + except Exception as e: + print(f" ❌ FEHLER: {e}") + test_results['kommKz'] = {'writable': False, 'status': 'ERROR', 'error': str(e)} + + # Test 4: online + print("\n🔬 Test 4/4: online (Boolean Flag)") + print(" Änderung: False → True") + + test_data = { + 'kommKz': result['kommKz'], + 'tlf': result['tlf'], + 'bemerkung': result['bemerkung'], + 'online': True # GEÄNDERT + } + + try: + result = await advo.api_call( + f'api/v1/advonet/Beteiligte/{TEST_BETNR}/Kommunikationen/{komm_id}', + method='PUT', + data=test_data + ) + + new_rowid = result['rowId'] + rowid_changed = (new_rowid != original_rowid) + value_changed = (result['online'] == True) + + print(f" ✅ PUT erfolgreich") + print(f" 📊 Wert geändert: {value_changed}") + print(f" 📊 rowId geändert: {rowid_changed}") + + test_results['online'] = { + 'writable': value_changed, + 'rowid_changed': rowid_changed, + 'status': 'WRITABLE' if value_changed else 'READ-ONLY' + } + + except Exception as e: + print(f" ❌ FEHLER: {e}") + test_results['online'] = {'writable': False, 'status': 'ERROR', 'error': str(e)} + + # ZUSAMMENFASSUNG + print_section("ZUSAMMENFASSUNG: Feld-Status") + + print("\n📊 Ergebnisse:\n") + + for field, result in test_results.items(): + status = result['status'] + icon = "✅" if status == "WRITABLE" else "❌" if status == "READ-ONLY" else "⚠️" + + print(f" {icon} {field:15s} → {status}") + + if result.get('note'): + print(f" ℹ️ {result['note']}") + + if result.get('error'): + print(f" ⚠️ {result['error']}") + + # Count + writable = sum(1 for r in test_results.values() if r['status'] == 'WRITABLE') + readonly = sum(1 for r in test_results.values() if r['status'] == 'READ-ONLY') + + print(f"\n📈 Statistik:") + print(f" WRITABLE: {writable}/{len(test_results)} Felder") + print(f" READ-ONLY: {readonly}/{len(test_results)} Felder") + + print(f"\n⚠️ Test-Kommunikation {komm_id} manuell löschen!") + print(f" BetNr: {TEST_BETNR}") + + return test_results + + +async def main(): + print("\n" + "="*70) + print("KOMMUNIKATION API - FELDANALYSE") + print("="*70) + print("\nZiel: Herausfinden welche Felder bei PUT änderbar sind") + print("Methode: Einzelne Feldänderungen + rowId-Tracking\n") + + try: + results = await test_field_mutability() + + print_section("EMPFEHLUNG FÜR MAPPER") + + writable_fields = [f for f, r in results.items() if r['status'] == 'WRITABLE'] + readonly_fields = [f for f, r in results.items() if r['status'] == 'READ-ONLY'] + + if writable_fields: + print("\n✅ Für UPDATE (PUT) verwenden:") + for field in writable_fields: + print(f" - {field}") + + if readonly_fields: + print("\n❌ NUR bei CREATE (POST) verwenden:") + for field in readonly_fields: + print(f" - {field}") + + print("\n💡 Sync-Strategie:") + print(" - CREATE: Alle Felder") + print(" - UPDATE: Nur WRITABLE Felder") + print(" - DELETE: Notification (403 Forbidden)") + + except Exception as e: + print(f"\n❌ Fehler: {e}") + import traceback + traceback.print_exc() + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_kommunikation_sync_implementation.py b/bitbylaw/scripts/test_kommunikation_sync_implementation.py new file mode 100644 index 00000000..a10ee652 --- /dev/null +++ b/bitbylaw/scripts/test_kommunikation_sync_implementation.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +""" +Test Kommunikation Sync Implementation +Testet alle 4 Szenarien + Type Detection +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.kommunikation_mapper import ( + encode_value, decode_value, parse_marker, create_marker, create_slot_marker, + detect_kommkz, is_email_type, is_phone_type, + KOMMKZ_TEL_GESCH, KOMMKZ_MAIL_GESCH +) + + +def test_base64_encoding(): + """Test: Base64-Encoding/Decoding""" + print("\n=== TEST 1: Base64-Encoding/Decoding ===") + + # Email + value1 = "max@example.com" + encoded1 = encode_value(value1) + decoded1 = decode_value(encoded1) + print(f"✓ Email: '{value1}' → '{encoded1}' → '{decoded1}'") + assert decoded1 == value1, "Decode muss Original ergeben" + + # Phone + value2 = "+49 170 999-TEST" + encoded2 = encode_value(value2) + decoded2 = decode_value(encoded2) + print(f"✓ Phone: '{value2}' → '{encoded2}' → '{decoded2}'") + assert decoded2 == value2, "Decode muss Original ergeben" + + # Special characters + value3 = "test:special]@example.com" + encoded3 = encode_value(value3) + decoded3 = decode_value(encoded3) + print(f"✓ Special: '{value3}' → '{encoded3}' → '{decoded3}'") + assert decoded3 == value3, "Decode muss Original ergeben" + + print("✅ Base64-Encoding bidirektional funktioniert") + + +def test_marker_parsing(): + """Test: Marker-Parsing mit Base64""" + print("\n=== TEST 2: Marker-Parsing ===") + + # Standard Marker mit Base64 + value = "max@example.com" + encoded = encode_value(value) + bemerkung1 = f"[ESPOCRM:{encoded}:4] Geschäftlich" + marker1 = parse_marker(bemerkung1) + print(f"✓ Parsed: {marker1}") + assert marker1['synced_value'] == value + assert marker1['kommKz'] == 4 + assert marker1['is_slot'] == False + assert marker1['user_text'] == 'Geschäftlich' + print("✅ Standard-Marker OK") + + # Slot Marker + bemerkung2 = "[ESPOCRM-SLOT:1]" + marker2 = parse_marker(bemerkung2) + print(f"✓ Parsed Slot: {marker2}") + assert marker2['is_slot'] == True + assert marker2['kommKz'] == 1 + print("✅ Slot-Marker OK") + + # Kein Marker + bemerkung3 = "Nur normale Bemerkung" + marker3 = parse_marker(bemerkung3) + assert marker3 is None + print("✅ Nicht-Marker erkannt") + + +def test_marker_creation(): + """Test: Marker-Erstellung mit Base64""" + print("\n=== TEST 3: Marker-Erstellung ===") + + value = "max@example.com" + kommkz = 4 + user_text = "Geschäftlich" + + marker = create_marker(value, kommkz, user_text) + print(f"✓ Created Marker: {marker}") + + # Verify parsable + parsed = parse_marker(marker) + assert parsed is not None + assert parsed['synced_value'] == value + assert parsed['kommKz'] == kommkz + assert parsed['user_text'] == user_text + print("✅ Marker korrekt erstellt und parsbar") + + # Slot Marker + slot_marker = create_slot_marker(kommkz) + print(f"✓ Created Slot: {slot_marker}") + parsed_slot = parse_marker(slot_marker) + assert parsed_slot['is_slot'] == True + print("✅ Slot-Marker OK") + + +def test_type_detection_4_tiers(): + """Test: 4-Stufen Typ-Erkennung""" + print("\n=== TEST 4: 4-Stufen Typ-Erkennung ===") + + # TIER 1: Aus Marker (höchste Priorität) + value = "test@example.com" + bemerkung_with_marker = "[ESPOCRM:abc:3]" # Marker sagt Mobil (3) + beteiligte = {'emailGesch': value} # Top-Level sagt MailGesch (4) + + detected = detect_kommkz(value, beteiligte, bemerkung_with_marker) + print(f"✓ Tier 1 (Marker): {detected} (erwartet 3 = Mobil)") + assert detected == 3, "Marker sollte höchste Priorität haben" + print("✅ Tier 1 OK - Marker überschreibt alles") + + # TIER 2: Aus Top-Level Feldern + beteiligte = {'telGesch': '+49 123 456'} + detected = detect_kommkz('+49 123 456', beteiligte, None) + print(f"✓ Tier 2 (Top-Level): {detected} (erwartet 1 = TelGesch)") + assert detected == 1 + print("✅ Tier 2 OK - Top-Level Match") + + # TIER 3: Aus Wert-Pattern + email_value = "no-marker@example.com" + detected = detect_kommkz(email_value, {}, None) + print(f"✓ Tier 3 (Pattern @ = Email): {detected} (erwartet 4)") + assert detected == 4 + print("✅ Tier 3 OK - Email erkannt") + + phone_value = "+49 123" + detected = detect_kommkz(phone_value, {}, None) + print(f"✓ Tier 3 (Pattern Phone): {detected} (erwartet 1)") + assert detected == 1 + print("✅ Tier 3 OK - Phone erkannt") + + # TIER 4: Default + detected = detect_kommkz('', {}, None) + print(f"✓ Tier 4 (Default): {detected} (erwartet 0)") + assert detected == 0 + print("✅ Tier 4 OK - Default bei leerem Wert") + + +def test_type_classification(): + """Test: Email vs. Phone Klassifizierung""" + print("\n=== TEST 5: Typ-Klassifizierung ===") + + email_types = [4, 8, 11, 12] # MailGesch, MailPrivat, EPost, Bea + phone_types = [1, 2, 3, 6, 7, 9, 10] # Alle Telefon-Typen + + for kommkz in email_types: + assert is_email_type(kommkz), f"kommKz {kommkz} sollte Email sein" + assert not is_phone_type(kommkz), f"kommKz {kommkz} sollte nicht Phone sein" + print(f"✅ Email-Typen: {email_types}") + + for kommkz in phone_types: + assert is_phone_type(kommkz), f"kommKz {kommkz} sollte Phone sein" + assert not is_email_type(kommkz), f"kommKz {kommkz} sollte nicht Email sein" + print(f"✅ Phone-Typen: {phone_types}") + + +def test_integration_scenario(): + """Test: Integration Szenario mit Base64""" + print("\n=== TEST 6: Integration Szenario ===") + + # Szenario: Neue Email in EspoCRM + espo_email = "new@example.com" + + # Schritt 1: Erkenne Typ (kein Marker, keine Top-Level Match) + kommkz = detect_kommkz(espo_email, {}, None) + print(f"✓ Erkannte kommKz: {kommkz} (MailGesch)") + assert kommkz == 4 + + # Schritt 2: Erstelle Marker mit Base64 + marker = create_marker(espo_email, kommkz) + print(f"✓ Marker erstellt: {marker}") + + # Schritt 3: Simuliere späteren Lookup + parsed = parse_marker(marker) + assert parsed['synced_value'] == espo_email + print(f"✓ Value-Match: {parsed['synced_value']}") + + # Schritt 4: Simuliere Änderung in Advoware + # User ändert zu "changed@example.com" aber Marker bleibt + # → synced_value enthält noch "new@example.com" für Matching! + old_synced_value = parsed['synced_value'] + new_value = "changed@example.com" + + print(f"✓ Änderung erkannt: synced_value='{old_synced_value}' vs current='{new_value}'") + assert old_synced_value != new_value + + # Schritt 5: Nach Sync wird Marker aktualisiert + new_marker = create_marker(new_value, kommkz, "Geschäftlich") + print(f"✓ Neuer Marker nach Änderung: {new_marker}") + + # Verify User-Text erhalten + assert "Geschäftlich" in new_marker + new_parsed = parse_marker(new_marker) + assert new_parsed['synced_value'] == new_value + print("✅ Integration Szenario mit bidirektionalem Matching erfolgreich") + + +def test_top_level_priority(): + """Test: Top-Level Feld Priorität""" + print("\n=== TEST 7: Top-Level Feld Priorität ===") + + # Value matched mit Top-Level Feld + value = "+49 170 999-TEST" + beteiligte = { + 'telGesch': '+49 511 111-11', + 'mobil': '+49 170 999-TEST', # Match! + 'emailGesch': 'test@example.com' + } + + detected = detect_kommkz(value, beteiligte, None) + print(f"✓ Detected für '{value}': {detected}") + print(f" Beteiligte Top-Level: telGesch={beteiligte['telGesch']}, mobil={beteiligte['mobil']}") + assert detected == 3, "Sollte Mobil (3) erkennen via Top-Level Match" + print("✅ Top-Level Match funktioniert") + + # Kein Match → Fallback zu Pattern + value2 = "+49 999 UNKNOWN" + detected2 = detect_kommkz(value2, beteiligte, None) + print(f"✓ Detected für '{value2}' (kein Match): {detected2}") + assert detected2 == 1, "Sollte TelGesch (1) als Pattern-Fallback nehmen" + print("✅ base64_encodingern funktioniert") + + +if __name__ == '__main__': + print("=" * 60) + print("KOMMUNIKATION SYNC - IMPLEMENTATION TESTS") + print("=" * 60) + + try: + test_base64_encoding() + test_marker_parsing() + test_marker_creation() + test_type_detection_4_tiers() + test_type_classification() + test_integration_scenario() + test_top_level_priority() + + print("\n" + "=" * 60) + print("✅ ALLE TESTS ERFOLGREICH") + print("=" * 60) + + except AssertionError as e: + print(f"\n❌ TEST FAILED: {e}") + sys.exit(1) + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/bitbylaw/scripts/verify_advoware_kommunikation_ids.py b/bitbylaw/scripts/verify_advoware_kommunikation_ids.py new file mode 100644 index 00000000..5e47dad9 --- /dev/null +++ b/bitbylaw/scripts/verify_advoware_kommunikation_ids.py @@ -0,0 +1,108 @@ +""" +Verifikation: Hat Advoware eindeutige IDs für Kommunikationen? + +Prüfe: +1. Hat jede Kommunikation eine 'id'? +2. Sind die IDs eindeutig? +3. Bleibt die ID stabil bei UPDATE? +4. Was ist mit rowId? +""" + +import asyncio +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + + +class SimpleContext: + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def warning(self, msg): print(f"[WARN] {msg}") + def debug(self, msg): pass + + def __init__(self): + self.logger = self.Logger() + + +def print_section(title): + print("\n" + "="*70) + print(title) + print("="*70) + + +async def main(): + print("\n" + "="*70) + print("ADVOWARE KOMMUNIKATION IDs") + print("="*70) + + context = SimpleContext() + advo = AdvowareAPI(context) + + # Hole Beteiligte mit Kommunikationen + print_section("Aktuelle Kommunikationen") + + result = await advo.api_call(f'api/v1/advonet/Beteiligte/{TEST_BETNR}') + beteiligte = result[0] + kommunikationen = beteiligte.get('kommunikation', []) + + print(f"\n✅ {len(kommunikationen)} Kommunikationen gefunden\n") + + # Zeige alle IDs + ids = [] + row_ids = [] + + for i, k in enumerate(kommunikationen[:10], 1): # Erste 10 + komm_id = k.get('id') + row_id = k.get('rowId') + wert = k.get('tlf', '')[:40] + kommkz = k.get('kommKz') + + ids.append(komm_id) + row_ids.append(row_id) + + print(f"[{i:2d}] ID: {komm_id:8d} | rowId: {row_id:20s} | " + f"Typ: {kommkz:2d} | Wert: {wert}") + + # Analyse + print_section("ANALYSE") + + print(f"\n1️⃣ IDs vorhanden:") + print(f" • Alle haben 'id': {all(k.get('id') for k in kommunikationen)}") + print(f" • Alle haben 'rowId': {all(k.get('rowId') for k in kommunikationen)}") + + print(f"\n2️⃣ Eindeutigkeit:") + print(f" • Anzahl IDs: {len(ids)}") + print(f" • Anzahl unique IDs: {len(set(ids))}") + print(f" • ✅ IDs sind eindeutig: {len(ids) == len(set(ids))}") + + print(f"\n3️⃣ ID-Typ:") + print(f" • Beispiel-ID: {ids[0] if ids else 'N/A'}") + print(f" • Typ: {type(ids[0]).__name__ if ids else 'N/A'}") + print(f" • Format: Integer (stabil)") + + print(f"\n4️⃣ rowId-Typ:") + print(f" • Beispiel-rowId: {row_ids[0] if row_ids else 'N/A'}") + print(f" • Typ: {type(row_ids[0]).__name__ if row_ids else 'N/A'}") + print(f" • Format: Base64 String (ändert sich bei UPDATE)") + + print_section("FAZIT") + + print("\n✅ Advoware hat EINDEUTIGE IDs für Kommunikationen!") + print("\n📋 Eigenschaften:") + print(" • id: Integer, stabil, eindeutig") + print(" • rowId: String, ändert sich bei UPDATE (für Change Detection)") + + print("\n💡 Das bedeutet:") + print(" • Wir können Advoware-ID als Schlüssel nutzen") + print(" • Matching: Advoware-ID ↔ EspoCRM-Wert") + print(" • Speichere Advoware-ID irgendwo für Reverse-Lookup") + + print("\n🎯 BESSERE LÖSUNG:") + print(" Option D: Advoware-ID als Kommentar in bemerkung speichern?") + print(" Option E: Advoware-ID in Wert-Format kodieren?") + print(" Option F: Separate Mapping-Tabelle (Redis/DB)?") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/services/KOMMUNIKATION_SYNC_README.md b/bitbylaw/services/KOMMUNIKATION_SYNC_README.md new file mode 100644 index 00000000..58bacead --- /dev/null +++ b/bitbylaw/services/KOMMUNIKATION_SYNC_README.md @@ -0,0 +1,319 @@ +# Kommunikation Sync Implementation + +## Overview + +Bidirektionale Synchronisation von Email- und Telefon-Daten zwischen Advoware und EspoCRM. + +## Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Advoware ↔ EspoCRM Sync │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ADVOWARE ESPOCRM │ +│ ───────────────── ────────────────── │ +│ Beteiligte CBeteiligte │ +│ └─ kommunikation[] ├─ emailAddressData[] │ +│ ├─ id (unique int) │ └─ emailAddress │ +│ ├─ rowId (string) │ lower, primary │ +│ ├─ tlf (value) │ │ +│ ├─ bemerkung (marker!) └─ phoneNumberData[] │ +│ ├─ kommKz (1-12) └─ phoneNumber │ +│ └─ online (bool) type, primary │ +│ │ +│ MATCHING: Hash in bemerkung-Marker │ +│ [ESPOCRM:hash:kommKz] User text │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Core Features + +### 1. Base64-basiertes Matching ✅ IMPLEMENTIERT +- **Problem**: EspoCRM Arrays haben keine IDs +- **Lösung**: Base64-kodierter Wert in Advoware bemerkung +- **Format**: `[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich` +- **Vorteil**: Bidirektional! Marker enthält den tatsächlichen Wert (dekodierbar) + +**Warum Base64 statt Hash?** +```python +# Hash-Problem (alt): Nicht rückrechenbar +old_hash = hash("old@example.com") # abc12345 +# Bei Wert-Änderung in Advoware: Kein Match möglich! ❌ + +# Base64-Lösung (neu): Bidirektional +encoded = base64("old@example.com") # b2xkQGV4YW1wbGUuY29t +decoded = decode(encoded) # "old@example.com" ✅ +# Kann dekodieren → Match in EspoCRM finden! +``` + +### 2. 4-Stufen Typ-Erkennung +```python +1. Aus Marker: [ESPOCRM:hash:3] → kommKz=3 (Mobil) +2. Aus Top-Level: beteiligte.mobil → kommKz=3 +3. Aus Pattern: '@' in value → kommKz=4 (Email) +4. Default: Fallback → kommKz=1 oder 4 +``` + +### 3. Empty Slot System +- **Problem**: DELETE ist 403 Forbidden in Advoware +- **Lösung**: Leere Slots mit `[ESPOCRM-SLOT:kommKz]` +- **Wiederverwendung**: Neue Einträge reuse leere Slots + +### 4. Asymmetrischer Sync + +**Problem**: Hash-basiertes Matching funktioniert NICHT bidirektional +- Wenn Wert in Advoware ändert: Hash ändert sich → Kein Match in EspoCRM möglich + +**Lösung**: Verschiedene Strategien je Richtung + +| Richtung | Methode | Grund | +|----------|---------|-------| +| **Advoware → EspoCRM** | FULL SYNC (kompletter Overwrite) | Kein stabiles Matching möglich | +| **EspoCRM → Advoware** | INCREMENTAL SYNC (Hash-basiert) | EspoCRM-Wert bekannt → Hash berechenbar | + +**Ablauf Advoware → EspoCRM (FULL SYNC)**: +```python +1. Sammle ALLE Kommunikationen (ohne Empty Slots) +2. Setze/Update Marker für Rück-Sync +3. Ersetze KOMPLETTE emailAddressData[] und phoneNumberData[] +``` + +**Ablauf EspoCRM → Advoware (INCREMENTAL)**: +```python +1. Baue Hash-Maps von beiden Seiten +2. Vergleiche: Deleted, Changed, New +3. Apply Changes (Empty Slots, Updates, Creates) +``` + +## Module Structure + +``` +services/ +├── kommunikation_mapper.py # Datentyp-Mapping & Marker-Logik +├── advoware_service.py # Advoware API-Wrapper +└── kommunikation_sync_utils.py # Sync-Manager (bidirectional) +``` + +## Usage Example + +```python +from services.advoware_service import AdvowareService +from services.espocrm import EspoCrmService +from services.kommunikation_sync_utils import KommunikationSyncManager + +# Initialize +advo = AdvowareService() +espo = EspoCrmService() +sync_manager = KommunikationSyncManager(advo, espo) + +# Bidirectional Sync +result = sync_manager.sync_bidirectional( + beteiligte_id='espocrm-bet-id', + betnr=12345, + direction='both' # 'both', 'to_espocrm', 'to_advoware' +) + +print(result) +# { +# 'advoware_to_espocrm': { +# 'emails_synced': 3, +# 'phones_synced': 2, +# 'errors': [] +# }, +# 'espocrm_to_advoware': { +# 'created': 1, +# 'updated': 2, +# 'deleted': 0, +# 'errors': [] +# } +# } +``` + +## Field Mapping + +### kommKz Enum (Advoware) + +| kommKz | Name | EspoCRM Target | EspoCRM Type | +|--------|------|----------------|--------------| +| 1 | TelGesch | phoneNumberData | Office | +| 2 | FaxGesch | phoneNumberData | Fax | +| 3 | Mobil | phoneNumberData | Mobile | +| 4 | MailGesch | emailAddressData | - | +| 5 | Internet | *(skipped)* | - | +| 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 | - | + +**Note**: Internet (kommKz=5) wird nicht synchronisiert (unklar ob Email/Phone). + +## Sync Scenarios + +### Scenario 1: Delete in EspoCRM +``` +EspoCRM: max@example.com gelöscht +Advoware: [ESPOCRM:abc:4] max@example.com + +→ UPDATE zu Empty Slot: + tlf: '' + bemerkung: [ESPOCRM-SLOT:4] + online: False +``` + +### Scenario 2: Change in EspoCRM +``` +EspoCRM: max@old.com → max@new.com +Advoware: [ESPOCRM:oldhash:4] max@old.com + +→ UPDATE with new hash: + tlf: 'max@new.com' + bemerkung: [ESPOCRM:newhash:4] Geschäftlich + online: True +``` + +### Scenario 3: New in EspoCRM +``` +EspoCRM: Neue Email new@example.com + +→ Suche Empty Slot (kommKz=4) + IF found: REUSE (UPDATE) + ELSE: CREATE new +``` + +### Scenario 4: New in Advoware +``` +Advoware: Neue Kommunikation (kein Marker) + +→ Typ-Erkennung via Top-Level/Pattern +→ Sync zu EspoCRM +→ Marker in Advoware setzen +``` + +## API Limitations + +### Advoware API v1 +- ✅ **POST**: /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen + - Required: tlf, kommKz + - Optional: bemerkung, online + +- ✅ **PUT**: /api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{id} + - Writable: tlf, bemerkung, online + - **READ-ONLY**: kommKz (cannot change type!) + +- ❌ **DELETE**: 403 Forbidden + - Use Empty Slots instead + +- ⚠️ **BUG**: kommKz always returns 0 in GET + - Use Top-Level fields + Pattern detection + +### EspoCRM +- ✅ **emailAddressData**: Array ohne IDs +- ✅ **phoneNumberData**: Array ohne IDs +- ❌ **Kein CKommunikation Entity**: Arrays nur in CBeteiligte + +## Testing + +Run all tests: +```bash +cd /opt/motia-app/bitbylaw +python3 scripts/test_kommunikation_sync_implementation.py +``` + +**Test Coverage**: +- ✅ Hash-Berechnung und Konsistenz +- ✅ Marker-Parsing (Standard + Slot) +- ✅ Marker-Erstellung +- ✅ 4-Stufen Typ-Erkennung (alle Tiers) +- ✅ Typ-Klassifizierung (Email vs Phone) +- ✅ Integration Szenario +- ✅ Top-Level Feld Priorität + +## Change Detection + +### Advoware Webhook +```python +from services.kommunikation_sync_utils import detect_kommunikation_changes + +if detect_kommunikation_changes(old_bet, new_bet): + # rowId changed → Sync needed + sync_manager.sync_bidirectional(bet_id, betnr, direction='to_espocrm') +``` + +### EspoCRM Webhook +```python +from services.kommunikation_sync_utils import detect_espocrm_kommunikation_changes + +if detect_espocrm_kommunikation_changes(old_data, new_data): + # Array changed → Sync needed + sync_manager.sync_bidirectional(bet_id, betnr, direction='to_advoware') +``` + +## Known Limitations + +1. **FULL SYNC von Advoware → EspoCRM**: + - Arrays werden komplett überschrieben (kein Merge) + - Grund: Hash-basiertes Matching funktioniert nicht bei Wert-Änderungen in Advoware + - Risiko minimal: EspoCRM-Arrays haben keine Relationen + +2. **Empty Slots Accumulation**: + - Gelöschte Einträge werden zu leeren Slots + - Werden wiederverwendet, aber akkumulieren + - TODO: Periodic cleanup job + +3. **Partial Type Loss**: + - Advoware-Kommunikationen ohne Top-Level Match verlieren Feintyp + - Fallback: @ → Email (4), sonst Phone (1) + +4. **kommKz READ-ONLY**: + - Typ kann nach Erstellung nicht geändert werden + - Workaround: DELETE + CREATE (manuell) + +5. **Marker sichtbar**: + - `[ESPOCRM:...]` ist in Advoware UI sichtbar + - User kann Text dahinter hinzufügen + +## Documentation + +- **Vollständige Analyse**: [docs/KOMMUNIKATION_SYNC_ANALYSE.md](../docs/KOMMUNIKATION_SYNC_ANALYSE.md) +- **API Tests**: [scripts/test_kommunikation_api.py](test_kommunikation_api.py) +- **Implementation Tests**: [scripts/test_kommunikation_sync_implementation.py](test_kommunikation_sync_implementation.py) + +## Implementation Status + +✅ **COMPLETE** + +- [x] Marker-System (Hash + kommKz) +- [x] 4-Stufen Typ-Erkennung +- [x] Empty Slot System +- [x] Bidirektionale Sync-Logik +- [x] Advoware Service Wrapper +- [x] Change Detection +- [x] Test Suite +- [x] Documentation + +## Next Steps + +1. **Integration in Webhook System** + - Add kommunikation change detection to beteiligte webhooks + - Wire up sync calls + +2. **Monitoring** + - Add metrics for sync operations + - Track empty slot accumulation + +3. **Maintenance** + - Implement periodic cleanup job for old empty slots + - Add notification for type-change scenarios + +4. **Testing** + - End-to-end tests with real Advoware/EspoCRM data + - Load testing for large kommunikation arrays + +--- + +**Last Updated**: 2024-01-26 +**Status**: ✅ Implementation Complete - Ready for Integration diff --git a/bitbylaw/services/advoware_service.py b/bitbylaw/services/advoware_service.py new file mode 100644 index 00000000..192518f8 --- /dev/null +++ b/bitbylaw/services/advoware_service.py @@ -0,0 +1,121 @@ +""" +Advoware Service Wrapper für Kommunikation +Erweitert AdvowareAPI mit Kommunikation-spezifischen Methoden +""" + +import asyncio +import logging +from typing import Dict, Any, Optional +from services.advoware import AdvowareAPI + +logger = logging.getLogger(__name__) + + +class AdvowareService: + """ + Service-Layer für Advoware Kommunikation-Operations + Verwendet AdvowareAPI für API-Calls + """ + + def __init__(self, context=None): + self.api = AdvowareAPI(context) + self.context = context + + # ========== BETEILIGTE ========== + + async def get_beteiligter(self, betnr: int) -> Optional[Dict]: + """ + Lädt Beteiligten mit Kommunikationen + + Returns: + Beteiligte mit 'kommunikation' array + """ + try: + endpoint = f"api/v1/advonet/Beteiligte/{betnr}" + result = await self.api.api_call(endpoint, method='GET') + return result + except Exception as e: + logger.error(f"[ADVO] Fehler beim Laden von Beteiligte {betnr}: {e}", exc_info=True) + return None + + # ========== KOMMUNIKATION ========== + + async def create_kommunikation(self, betnr: int, data: Dict[str, Any]) -> Optional[Dict]: + """ + Erstellt neue Kommunikation + + Args: + betnr: Beteiligten-Nummer + data: { + 'tlf': str, # Required + 'bemerkung': str, # Optional + 'kommKz': int, # Required (1-12) + 'online': bool # Optional + } + + Returns: + Neue Kommunikation mit 'id' + """ + try: + endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen" + result = await self.api.api_call(endpoint, method='POST', json_data=data) + + if result: + logger.info(f"[ADVO] ✅ Created Kommunikation: betnr={betnr}, kommKz={data.get('kommKz')}") + + return result + + except Exception as e: + logger.error(f"[ADVO] Fehler beim Erstellen von Kommunikation: {e}", exc_info=True) + return None + + async def update_kommunikation(self, betnr: int, komm_id: int, data: Dict[str, Any]) -> bool: + """ + Aktualisiert bestehende Kommunikation + + Args: + betnr: Beteiligten-Nummer + komm_id: Kommunikation-ID + data: { + 'tlf': str, # Optional + 'bemerkung': str, # Optional + 'online': bool # Optional + } + + NOTE: kommKz ist READ-ONLY und kann nicht geändert werden + + Returns: + True wenn erfolgreich + """ + try: + endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}" + await self.api.api_call(endpoint, method='PUT', json_data=data) + + logger.info(f"[ADVO] ✅ Updated Kommunikation: betnr={betnr}, komm_id={komm_id}") + return True + + except Exception as e: + logger.error(f"[ADVO] Fehler beim Update von Kommunikation: {e}", exc_info=True) + return False + + def delete_kommunikation(self, betnr: int, komm_id: int) -> bool: + """ + Löscht Kommunikation (aktuell 403 Forbidden) + + NOTE: DELETE ist in Advoware API deaktiviert + Verwende stattdessen: Leere Slots mit empty_slot_marker + + Returns: + True wenn erfolgreich + """ + try: + endpoint = f"api/v1/advonet/Beteiligte/{betnr}/Kommunikationen/{komm_id}" + asyncio.run(self.api.api_call(endpoint, method='DELETE')) + + logger.info(f"[ADVO] ✅ Deleted Kommunikation: betnr={betnr}, komm_id={komm_id}") + return True + + except Exception as e: + # Expected: 403 Forbidden + logger.warning(f"[ADVO] DELETE not allowed (expected): {e}") + return False diff --git a/bitbylaw/services/beteiligte_sync_utils.py b/bitbylaw/services/beteiligte_sync_utils.py index 9afb3467..97e16d92 100644 --- a/bitbylaw/services/beteiligte_sync_utils.py +++ b/bitbylaw/services/beteiligte_sync_utils.py @@ -77,7 +77,7 @@ class BeteiligteSync: acquired = self.redis.set(lock_key, "locked", nx=True, ex=LOCK_TTL_SECONDS) if not acquired: - self._log(f"Redis lock bereits aktiv für {entity_id}", level='warning') + self._log(f"Redis lock bereits aktiv für {entity_id}", level='warn') return False # STEP 2: Update syncStatus (für UI visibility) @@ -214,7 +214,7 @@ class BeteiligteSync: return datetime.fromisoformat(ts) except Exception as e: - logger.warning(f"Konnte Timestamp nicht parsen: {ts} - {e}") + logger.warn(f"Konnte Timestamp nicht parsen: {ts} - {e}") return None return None diff --git a/bitbylaw/services/kommunikation_mapper.py b/bitbylaw/services/kommunikation_mapper.py new file mode 100644 index 00000000..a67bafff --- /dev/null +++ b/bitbylaw/services/kommunikation_mapper.py @@ -0,0 +1,336 @@ +""" +Kommunikation Mapper: Advoware ↔ EspoCRM + +Mapping-Strategie: +- Marker in Advoware bemerkung: [ESPOCRM:hash:kommKz] +- Typ-Erkennung: Marker > Top-Level > Wert > Default +- Bidirektional mit Slot-Wiederverwendung +""" + +import hashlib +import base64 +import re +from typing import Optional, Dict, Any, List, Tuple + + +# kommKz Enum +KOMMKZ_TEL_GESCH = 1 +KOMMKZ_FAX_GESCH = 2 +KOMMKZ_MOBIL = 3 +KOMMKZ_MAIL_GESCH = 4 +KOMMKZ_INTERNET = 5 +KOMMKZ_TEL_PRIVAT = 6 +KOMMKZ_FAX_PRIVAT = 7 +KOMMKZ_MAIL_PRIVAT = 8 +KOMMKZ_AUTO_TELEFON = 9 +KOMMKZ_SONSTIGE = 10 +KOMMKZ_EPOST = 11 +KOMMKZ_BEA = 12 + +# EspoCRM phone type mapping +KOMMKZ_TO_PHONE_TYPE = { + KOMMKZ_TEL_GESCH: 'Office', + KOMMKZ_FAX_GESCH: 'Fax', + KOMMKZ_MOBIL: 'Mobile', + KOMMKZ_TEL_PRIVAT: 'Home', + KOMMKZ_FAX_PRIVAT: 'Fax', + KOMMKZ_AUTO_TELEFON: 'Mobile', + KOMMKZ_SONSTIGE: 'Other', +} + +# Reverse mapping: EspoCRM phone type to kommKz +PHONE_TYPE_TO_KOMMKZ = { + 'Office': KOMMKZ_TEL_GESCH, + 'Fax': KOMMKZ_FAX_GESCH, + 'Mobile': KOMMKZ_MOBIL, + 'Home': KOMMKZ_TEL_PRIVAT, + 'Other': KOMMKZ_SONSTIGE, +} + +# Email kommKz values +EMAIL_KOMMKZ = [KOMMKZ_MAIL_GESCH, KOMMKZ_MAIL_PRIVAT, KOMMKZ_EPOST, KOMMKZ_BEA] + +# Phone kommKz values +PHONE_KOMMKZ = [KOMMKZ_TEL_GESCH, KOMMKZ_FAX_GESCH, KOMMKZ_MOBIL, + KOMMKZ_TEL_PRIVAT, KOMMKZ_FAX_PRIVAT, KOMMKZ_AUTO_TELEFON, KOMMKZ_SONSTIGE] + + +def encode_value(value: str) -> str: + """Encodiert Wert mit Base64 (URL-safe) für Marker""" + return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=') + + +def decode_value(encoded: str) -> str: + """Decodiert Base64-kodierten Wert aus Marker""" + # Add padding if needed + padding = 4 - (len(encoded) % 4) + if padding != 4: + encoded += '=' * padding + return base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8') + + +def calculate_hash(value: str) -> str: + """Legacy: Hash-Berechnung (für Rückwärtskompatibilität mit alten Markern)""" + return hashlib.sha256(value.encode()).hexdigest()[:8] + + +def parse_marker(bemerkung: str) -> Optional[Dict[str, Any]]: + """ + Parse ESPOCRM-Marker aus bemerkung + + Returns: + {'synced_value': '...', 'kommKz': 4, 'is_slot': False, 'user_text': '...'} + oder None (synced_value ist decoded, nicht base64) + """ + if not bemerkung: + return None + + # Match SLOT: [ESPOCRM-SLOT:kommKz] + slot_pattern = r'\[ESPOCRM-SLOT:(\d+)\](.*)' + slot_match = re.match(slot_pattern, bemerkung) + + if slot_match: + return { + 'synced_value': '', + 'kommKz': int(slot_match.group(1)), + 'is_slot': True, + 'user_text': slot_match.group(2).strip() + } + + # Match: [ESPOCRM:base64_value:kommKz] + pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)' + match = re.match(pattern, bemerkung) + + if not match: + return None + + encoded_value = match.group(1) + + # Decode Base64 value + try: + synced_value = decode_value(encoded_value) + except Exception as e: + # Fallback: Könnte alter Hash-Marker sein + synced_value = encoded_value + + return { + 'synced_value': synced_value, + 'kommKz': int(match.group(2)), + 'is_slot': False, + 'user_text': match.group(3).strip() + } + + +def create_marker(value: str, kommkz: int, user_text: str = '') -> str: + """Erstellt ESPOCRM-Marker mit Base64-encodiertem Wert""" + encoded = encode_value(value) + suffix = f" {user_text}" if user_text else "" + return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}" + + +def create_slot_marker(kommkz: int) -> str: + """Erstellt Slot-Marker für gelöschte Einträge""" + return f"[ESPOCRM-SLOT:{kommkz}]" + + +def detect_kommkz(value: str, beteiligte: Optional[Dict] = None, + bemerkung: Optional[str] = None, + espo_type: Optional[str] = None) -> int: + """ + Erkenne kommKz mit mehrstufiger Strategie + + Priorität: + 1. Aus bemerkung-Marker (wenn vorhanden) + 2. Aus EspoCRM type (wenn von EspoCRM kommend) + 3. Aus Top-Level Feldern in beteiligte + 4. Aus Wert (Email vs. Phone) + 5. Default + + Args: + espo_type: EspoCRM phone type ('Office', 'Mobile', 'Fax', etc.) oder 'email' + """ + # 1. Aus Marker + if bemerkung: + marker = parse_marker(bemerkung) + if marker: + import logging + logger = logging.getLogger(__name__) + logger.info(f"[KOMMKZ] Detected from marker: kommKz={marker['kommKz']}") + return marker['kommKz'] + + # 2. Aus EspoCRM type (für EspoCRM->Advoware Sync) + if espo_type: + if espo_type == 'email': + import logging + logger = logging.getLogger(__name__) + logger.info(f"[KOMMKZ] Detected from espo_type 'email': kommKz={KOMMKZ_MAIL_GESCH}") + return KOMMKZ_MAIL_GESCH + elif espo_type in PHONE_TYPE_TO_KOMMKZ: + kommkz = PHONE_TYPE_TO_KOMMKZ[espo_type] + import logging + logger = logging.getLogger(__name__) + logger.info(f"[KOMMKZ] Detected from espo_type '{espo_type}': kommKz={kommkz}") + return kommkz + + # 3. Aus Top-Level Feldern (für genau EINEN Eintrag pro Typ) + if beteiligte: + top_level_map = { + 'telGesch': KOMMKZ_TEL_GESCH, + 'faxGesch': KOMMKZ_FAX_GESCH, + 'mobil': KOMMKZ_MOBIL, + 'emailGesch': KOMMKZ_MAIL_GESCH, + 'email': KOMMKZ_MAIL_GESCH, + 'internet': KOMMKZ_INTERNET, + 'telPrivat': KOMMKZ_TEL_PRIVAT, + 'faxPrivat': KOMMKZ_FAX_PRIVAT, + 'autotelefon': KOMMKZ_AUTO_TELEFON, + 'ePost': KOMMKZ_EPOST, + 'bea': KOMMKZ_BEA, + } + + for field, kommkz in top_level_map.items(): + if beteiligte.get(field) == value: + return kommkz + + # 3. Aus Wert (Email vs. Phone) + if '@' in value: + return KOMMKZ_MAIL_GESCH # Default Email + elif value.strip(): + return KOMMKZ_TEL_GESCH # Default Phone + + return 0 + + +def is_email_type(kommkz: int) -> bool: + """Prüft ob kommKz ein Email-Typ ist""" + return kommkz in EMAIL_KOMMKZ + + +def is_phone_type(kommkz: int) -> bool: + """Prüft ob kommKz ein Telefon-Typ ist""" + return kommkz in PHONE_KOMMKZ + + +def advoware_to_espocrm_email(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]: + """ + Konvertiert Advoware Kommunikation zu EspoCRM emailAddressData + + Args: + advo_komm: Advoware Kommunikation + beteiligte: Vollständiger Beteiligte (für Top-Level Felder) + + Returns: + EspoCRM emailAddressData Element + """ + value = (advo_komm.get('tlf') or '').strip() + + return { + 'emailAddress': value, + 'lower': value.lower(), + 'primary': advo_komm.get('online', False), + 'optOut': False, + 'invalid': False + } + + +def advoware_to_espocrm_phone(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]: + """ + Konvertiert Advoware Kommunikation zu EspoCRM phoneNumberData + + Args: + advo_komm: Advoware Kommunikation + beteiligte: Vollständiger Beteiligte (für Top-Level Felder) + + Returns: + EspoCRM phoneNumberData Element + """ + value = (advo_komm.get('tlf') or '').strip() + bemerkung = advo_komm.get('bemerkung') + + # Erkenne kommKz + kommkz = detect_kommkz(value, beteiligte, bemerkung) + + # Mappe zu EspoCRM type + phone_type = KOMMKZ_TO_PHONE_TYPE.get(kommkz, 'Other') + + return { + 'phoneNumber': value, + 'type': phone_type, + 'primary': advo_komm.get('online', False), + 'optOut': False, + 'invalid': False + } + + +def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]: + """ + Findet passende Advoware-Kommunikation für EspoCRM Wert + + Matching via synced_value in bemerkung-Marker + """ + for k in advo_kommunikationen: + bemerkung = k.get('bemerkung') or '' + marker = parse_marker(bemerkung) + + if marker and not marker['is_slot'] and marker['synced_value'] == espo_value: + return k + + return None + + +def find_empty_slot(kommkz: int, advo_kommunikationen: List[Dict]) -> Optional[Dict]: + """ + Findet leeren Slot mit passendem kommKz + + Leere Slots haben: tlf='' und bemerkung='[ESPOCRM-SLOT:kommKz]' + """ + for k in advo_kommunikationen: + tlf = (k.get('tlf') or '').strip() + bemerkung = k.get('bemerkung') or '' + + if not tlf: # Leer + marker = parse_marker(bemerkung) + if marker and marker['is_slot'] and marker['kommKz'] == kommkz: + return k + + return None + + +def should_sync_to_espocrm(advo_komm: Dict) -> bool: + """ + Prüft ob Advoware-Kommunikation zu EspoCRM synchronisiert werden soll + + Nur wenn: + - Wert vorhanden + - Kein leerer Slot + """ + tlf = (advo_komm.get('tlf') or '').strip() + if not tlf: + return False + + bemerkung = advo_komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + + # Keine leeren Slots + if marker and marker['is_slot']: + return False + + return True + + +def get_user_bemerkung(advo_komm: Dict) -> str: + """Extrahiert User-Bemerkung (ohne Marker)""" + bemerkung = advo_komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + + if marker: + return marker['user_text'] + + return bemerkung + + +def set_user_bemerkung(marker: str, user_text: str) -> str: + """Fügt User-Bemerkung zu Marker hinzu""" + if user_text: + return f"{marker} {user_text}" + return marker diff --git a/bitbylaw/services/kommunikation_sync_utils.py b/bitbylaw/services/kommunikation_sync_utils.py new file mode 100644 index 00000000..8d28e34f --- /dev/null +++ b/bitbylaw/services/kommunikation_sync_utils.py @@ -0,0 +1,703 @@ +""" +Kommunikation Sync Utilities +Bidirektionale Synchronisation: Advoware ↔ EspoCRM + +Strategie: +- Emails: emailAddressData[] ↔ Advoware Kommunikationen (kommKz: 4,8,11,12) +- Phones: phoneNumberData[] ↔ Advoware Kommunikationen (kommKz: 1,2,3,6,7,9,10) +- Matching: Hash-basiert via bemerkung-Marker +- Type Detection: Marker > Top-Level > Value Pattern > Default +""" + +import logging +from typing import Dict, List, Optional, Tuple, Any +from services.kommunikation_mapper import ( + parse_marker, create_marker, create_slot_marker, + detect_kommkz, encode_value, decode_value, + is_email_type, is_phone_type, + advoware_to_espocrm_email, advoware_to_espocrm_phone, + find_matching_advoware, find_empty_slot, + should_sync_to_espocrm, get_user_bemerkung, + calculate_hash, + EMAIL_KOMMKZ, PHONE_KOMMKZ +) +from services.advoware_service import AdvowareService +from services.espocrm import EspoCRMAPI + +logger = logging.getLogger(__name__) + + +class KommunikationSyncManager: + """Manager für Kommunikation-Synchronisation""" + + def __init__(self, advoware: AdvowareService, espocrm: EspoCRMAPI, context=None): + self.advoware = advoware + self.espocrm = espocrm + self.context = context + self.logger = context.logger if context else logger + + # ========== BIDIRECTIONAL SYNC ========== + + async def sync_bidirectional(self, beteiligte_id: str, betnr: int, + direction: str = 'both') -> Dict[str, Any]: + """ + Bidirektionale Synchronisation mit intelligentem Diffing + + Optimiert: + - Lädt Daten nur 1x von jeder Seite + - Echtes 3-Way Diffing (Advoware, EspoCRM, Marker) + - Handhabt alle 6 Szenarien korrekt + + Args: + direction: 'both', 'to_espocrm', 'to_advoware' + + Returns: + Combined results mit detaillierten Änderungen + """ + result = { + 'advoware_to_espocrm': {'emails_synced': 0, 'phones_synced': 0, 'errors': []}, + 'espocrm_to_advoware': {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []}, + 'summary': {'total_changes': 0} + } + + try: + # ========== LADE DATEN NUR 1X ========== + self.logger.info(f"[KOMM] Bidirectional Sync: betnr={betnr}, bet_id={beteiligte_id}") + + # Advoware Daten + advo_result = await self.advoware.get_beteiligter(betnr) + if isinstance(advo_result, list): + advo_bet = advo_result[0] if advo_result else None + else: + advo_bet = advo_result + + if not advo_bet: + result['advoware_to_espocrm']['errors'].append("Advoware Beteiligte nicht gefunden") + result['espocrm_to_advoware']['errors'].append("Advoware Beteiligte nicht gefunden") + return result + + # EspoCRM Daten + espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id) + if not espo_bet: + result['advoware_to_espocrm']['errors'].append("EspoCRM Beteiligte nicht gefunden") + result['espocrm_to_advoware']['errors'].append("EspoCRM Beteiligte nicht gefunden") + return result + + advo_kommunikationen = advo_bet.get('kommunikation', []) + espo_emails = espo_bet.get('emailAddressData', []) + espo_phones = espo_bet.get('phoneNumberData', []) + + self.logger.info(f"[KOMM] Geladen: {len(advo_kommunikationen)} Advoware, {len(espo_emails)} EspoCRM emails, {len(espo_phones)} EspoCRM phones") + + # Check ob initialer Sync + stored_komm_hash = espo_bet.get('kommunikationHash') + is_initial_sync = not stored_komm_hash + + # ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ========== + diff = self._compute_diff(advo_kommunikationen, espo_emails, espo_phones, advo_bet, espo_bet) + + espo_wins = diff.get('espo_wins', False) + + self.logger.info(f"[KOMM] ===== DIFF RESULTS =====") + self.logger.info(f"[KOMM] Diff: {len(diff['advo_changed'])} Advoware changed, {len(diff['espo_changed'])} EspoCRM changed, " + f"{len(diff['advo_new'])} Advoware new, {len(diff['espo_new'])} EspoCRM new, " + f"{len(diff['advo_deleted'])} Advoware deleted, {len(diff['espo_deleted'])} EspoCRM deleted") + self.logger.info(f"[KOMM] ===== CONFLICT STATUS: espo_wins={espo_wins} =====") + + # ========== APPLY CHANGES ========== + + # 1. Advoware → EspoCRM (Var4: Neu in Advoware, Var6: Geändert in Advoware) + # WICHTIG: Bei Konflikt (espo_wins=true) KEINE Advoware-Änderungen übernehmen! + if direction in ['both', 'to_espocrm'] and not espo_wins: + self.logger.info(f"[KOMM] ✅ Applying Advoware→EspoCRM changes...") + espo_result = await self._apply_advoware_to_espocrm( + beteiligte_id, diff, advo_bet + ) + result['advoware_to_espocrm'] = espo_result + elif direction in ['both', 'to_espocrm'] and espo_wins: + self.logger.info(f"[KOMM] ⚠️ CONFLICT: EspoCRM wins - skipping Advoware→EspoCRM sync") + else: + self.logger.info(f"[KOMM] ℹ️ Skipping Advoware→EspoCRM (direction={direction})") + + # 2. EspoCRM → Advoware (Var1: Neu in EspoCRM, Var2: Gelöscht in EspoCRM, Var5: Geändert in EspoCRM) + if direction in ['both', 'to_advoware']: + advo_result = await self._apply_espocrm_to_advoware( + betnr, diff, advo_bet + ) + result['espocrm_to_advoware'] = advo_result + + total_changes = ( + result['advoware_to_espocrm']['emails_synced'] + + result['advoware_to_espocrm']['phones_synced'] + + result['espocrm_to_advoware']['created'] + + result['espocrm_to_advoware']['updated'] + + result['espocrm_to_advoware']['deleted'] + ) + result['summary']['total_changes'] = total_changes + + # Speichere neuen Kommunikations-Hash in EspoCRM (für nächsten Sync) + # WICHTIG: Auch beim initialen Sync oder wenn keine Änderungen + if total_changes > 0 or is_initial_sync: + # Re-berechne Hash nach allen Änderungen + advo_result_final = await self.advoware.get_beteiligter(betnr) + if isinstance(advo_result_final, list): + advo_bet_final = advo_result_final[0] + else: + advo_bet_final = advo_result_final + + import hashlib + final_kommunikationen = advo_bet_final.get('kommunikation', []) + komm_rowids = sorted([k.get('rowId', '') for k in final_kommunikationen if k.get('rowId')]) + new_komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16] + + await self.espocrm.update_entity('CBeteiligte', beteiligte_id, { + 'kommunikationHash': new_komm_hash + }) + self.logger.info(f"[KOMM] ✅ Updated kommunikationHash: {new_komm_hash}") + + self.logger.info(f"[KOMM] ✅ Bidirectional Sync complete: {total_changes} total changes") + + except Exception as e: + self.logger.error(f"[KOMM] Fehler bei Bidirectional Sync: {e}", exc_info=True) + result['advoware_to_espocrm']['errors'].append(str(e)) + result['espocrm_to_advoware']['errors'].append(str(e)) + + return result + + # ========== 3-WAY DIFFING ========== + + def _compute_diff(self, advo_kommunikationen: List[Dict], espo_emails: List[Dict], + espo_phones: List[Dict], advo_bet: Dict, espo_bet: Dict) -> Dict[str, List]: + """ + Berechnet Diff zwischen Advoware und EspoCRM mit Kommunikations-Hash-basierter Konflikt-Erkennung + + Da die Beteiligte-rowId sich NICHT bei Kommunikations-Änderungen ändert, + nutzen wir einen Hash aus allen Kommunikations-rowIds + EspoCRM modifiedAt. + + Returns: + { + 'advo_changed': [(komm, old_value, new_value)], # Var6: In Advoware geändert + 'advo_new': [komm], # Var4: Neu in Advoware (ohne Marker) + 'advo_deleted': [(value, item)], # Var3: In Advoware gelöscht (via Hash) + 'espo_changed': [(value, advo_komm)], # Var5: In EspoCRM geändert + 'espo_new': [(value, item)], # Var1: Neu in EspoCRM (via Hash) + 'espo_deleted': [advo_komm], # Var2: In EspoCRM gelöscht + 'no_change': [(value, komm, item)] # Keine Änderung + } + """ + diff = { + 'advo_changed': [], + 'advo_new': [], + 'advo_deleted': [], # NEU: Var3 + 'espo_changed': [], + 'espo_new': [], + 'espo_deleted': [], + 'no_change': [], + 'espo_wins': False # Default + } + + # Hole Sync-Metadaten für Konflikt-Erkennung + espo_modified = espo_bet.get('modifiedAt') + last_sync = espo_bet.get('advowareLastSync') + + # Berechne Hash aus Kommunikations-rowIds + import hashlib + komm_rowids = sorted([k.get('rowId', '') for k in advo_kommunikationen if k.get('rowId')]) + current_advo_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16] + stored_komm_hash = espo_bet.get('kommunikationHash') + + # Parse Timestamps + from services.beteiligte_sync_utils import BeteiligteSync + espo_modified_ts = BeteiligteSync.parse_timestamp(espo_modified) + last_sync_ts = BeteiligteSync.parse_timestamp(last_sync) + + # Bestimme wer geändert hat + espo_changed_since_sync = espo_modified_ts and last_sync_ts and espo_modified_ts > last_sync_ts + advo_changed_since_sync = stored_komm_hash and current_advo_hash != stored_komm_hash + + # Initial Sync: Wenn kein Hash gespeichert ist, behandle als "keine Änderung in Advoware" + is_initial_sync = not stored_komm_hash + + self.logger.info(f"[KOMM] 🔍 Konflikt-Check:") + self.logger.info(f"[KOMM] - EspoCRM changed: {espo_changed_since_sync} (modified={espo_modified}, lastSync={last_sync})") + self.logger.info(f"[KOMM] - Advoware changed: {advo_changed_since_sync} (stored_hash={stored_komm_hash}, current_hash={current_advo_hash})") + self.logger.info(f"[KOMM] - Initial sync: {is_initial_sync}") + self.logger.info(f"[KOMM] - Kommunikation rowIds count: {len(komm_rowids)}") + + if espo_changed_since_sync and advo_changed_since_sync: + self.logger.warning(f"[KOMM] ⚠️ KONFLIKT: Beide Seiten geändert seit letztem Sync - EspoCRM WINS") + espo_wins = True + else: + espo_wins = False + + # Speichere espo_wins im diff für spätere Verwendung + diff['espo_wins'] = espo_wins + + # Baue EspoCRM Value Map + espo_values = {} + for email in espo_emails: + val = email.get('emailAddress', '').strip() + if val: + espo_values[val] = {'value': val, 'is_email': True, 'primary': email.get('primary', False), 'type': 'email'} + + for phone in espo_phones: + val = phone.get('phoneNumber', '').strip() + if val: + espo_values[val] = {'value': val, 'is_email': False, 'primary': phone.get('primary', False), 'type': phone.get('type', 'Office')} + + # Baue Advoware Maps + advo_with_marker = {} # synced_value -> (komm, current_value) + advo_without_marker = [] # Einträge ohne Marker (von Advoware angelegt) + + for komm in advo_kommunikationen: + if not should_sync_to_espocrm(komm): + continue + + tlf = (komm.get('tlf') or '').strip() + if not tlf: # Leere Einträge ignorieren + continue + + bemerkung = komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + + if marker and not marker['is_slot']: + # Hat Marker → Von EspoCRM synchronisiert + synced_value = marker['synced_value'] + advo_with_marker[synced_value] = (komm, tlf) + else: + # Kein Marker → Von Advoware angelegt (Var4) + advo_without_marker.append(komm) + + # ========== ANALYSE ========== + + # 1. Prüfe Advoware-Einträge MIT Marker + for synced_value, (komm, current_value) in advo_with_marker.items(): + + if synced_value != current_value: + # Var6: In Advoware geändert + self.logger.info(f"[KOMM] ✏️ Var6: Changed in Advoware - synced='{synced_value[:30]}...', current='{current_value[:30]}...'") + diff['advo_changed'].append((komm, synced_value, current_value)) + + elif synced_value in espo_values: + espo_item = espo_values[synced_value] + + # Prüfe ob primary geändert wurde (Var5 könnte auch sein) + current_online = komm.get('online', False) + espo_primary = espo_item['primary'] + + if current_online != espo_primary: + # Var5: EspoCRM hat primary geändert + self.logger.info(f"[KOMM] 🔄 Var5: Primary changed in EspoCRM - value='{synced_value}', advo_online={current_online}, espo_primary={espo_primary}") + diff['espo_changed'].append((synced_value, komm, espo_item)) + else: + # Keine Änderung + self.logger.info(f"[KOMM] ✓ No change: '{synced_value[:30]}...'") + diff['no_change'].append((synced_value, komm, espo_item)) + + else: + # Eintrag war mal in EspoCRM (hat Marker), ist jetzt aber nicht mehr da + # → Var2: In EspoCRM gelöscht + self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - synced_value='{synced_value}', komm_id={komm.get('id')}") + diff['espo_deleted'].append(komm) + + # 2. Prüfe Advoware-Einträge OHNE Marker + for komm in advo_without_marker: + # Var4: Neu in Advoware angelegt + tlf = (komm.get('tlf') or '').strip() + self.logger.info(f"[KOMM] ➕ Var4: New in Advoware - value='{tlf[:30]}...', komm_id={komm.get('id')}") + diff['advo_new'].append(komm) + + # 3. Prüfe EspoCRM-Einträge die NICHT in Advoware sind (oder nur mit altem Marker) + for value, espo_item in espo_values.items(): + if value not in advo_with_marker: + # HASH-BASIERTE KONFLIKT-LOGIK: Unterscheide Var1 von Var3 + + if espo_wins or (espo_changed_since_sync and not advo_changed_since_sync): + # Var1: Neu in EspoCRM (EspoCRM geändert, Advoware nicht) + self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value}' (espo changed, advo unchanged)") + diff['espo_new'].append((value, espo_item)) + + elif advo_changed_since_sync and not espo_changed_since_sync: + # Var3: In Advoware gelöscht (Advoware geändert, EspoCRM nicht) + self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}' (advo changed, espo unchanged)") + diff['advo_deleted'].append((value, espo_item)) + + else: + # Kein klarer Hinweis - Default: Behandle als Var1 (neu in EspoCRM) + self.logger.info(f"[KOMM] Var1 (default): '{value}' - no clear indication, treating as new in EspoCRM") + diff['espo_new'].append((value, espo_item)) + + return diff + + # ========== APPLY CHANGES ========== + + async def _apply_advoware_to_espocrm(self, beteiligte_id: str, diff: Dict, + advo_bet: Dict) -> Dict[str, Any]: + """ + Wendet Advoware-Änderungen auf EspoCRM an (Var4, Var6) + """ + result = {'emails_synced': 0, 'phones_synced': 0, 'markers_updated': 0, 'errors': []} + + try: + # Lade aktuelle EspoCRM Daten + espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id) + espo_emails = list(espo_bet.get('emailAddressData', [])) + espo_phones = list(espo_bet.get('phoneNumberData', [])) + + # Var6: Advoware-Änderungen → Update Marker + Sync zu EspoCRM + for komm, old_value, new_value in diff['advo_changed']: + self.logger.info(f"[KOMM] Var6: Advoware changed '{old_value}' → '{new_value}'") + + # Update Marker in Advoware + bemerkung = komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + user_text = marker.get('user_text', '') if marker else '' + kommkz = marker['kommKz'] if marker else detect_kommkz(new_value, advo_bet) + + new_marker = create_marker(new_value, kommkz, user_text) + await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], { + 'bemerkung': new_marker + }) + result['markers_updated'] += 1 + + # Update in EspoCRM: Finde alten Wert und ersetze mit neuem + if is_email_type(kommkz): + for i, email in enumerate(espo_emails): + if email.get('emailAddress') == old_value: + espo_emails[i] = { + 'emailAddress': new_value, + 'lower': new_value.lower(), + 'primary': komm.get('online', False), + 'optOut': False, + 'invalid': False + } + result['emails_synced'] += 1 + break + else: + for i, phone in enumerate(espo_phones): + if phone.get('phoneNumber') == old_value: + type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'} + espo_phones[i] = { + 'phoneNumber': new_value, + 'type': type_map.get(kommkz, 'Other'), + 'primary': komm.get('online', False), + 'optOut': False, + 'invalid': False + } + result['phones_synced'] += 1 + break + + # Var4: Neu in Advoware → Zu EspoCRM hinzufügen + Marker setzen + for komm in diff['advo_new']: + tlf = (komm.get('tlf') or '').strip() + kommkz = detect_kommkz(tlf, advo_bet, komm.get('bemerkung')) + + self.logger.info(f"[KOMM] Var4: New in Advoware '{tlf}', syncing to EspoCRM") + + # Setze Marker in Advoware + new_marker = create_marker(tlf, kommkz) + await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], { + 'bemerkung': new_marker + }) + + # Zu EspoCRM hinzufügen + if is_email_type(kommkz): + espo_emails.append({ + 'emailAddress': tlf, + 'lower': tlf.lower(), + 'primary': komm.get('online', False), + 'optOut': False, + 'invalid': False + }) + result['emails_synced'] += 1 + else: + type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'} + espo_phones.append({ + 'phoneNumber': tlf, + 'type': type_map.get(kommkz, 'Other'), + 'primary': komm.get('online', False), + 'optOut': False, + 'invalid': False + }) + result['phones_synced'] += 1 + + # Var3: In Advoware gelöscht → Aus EspoCRM entfernen + for value, espo_item in diff.get('advo_deleted', []): + self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}', removing from EspoCRM") + + if espo_item['is_email']: + espo_emails = [e for e in espo_emails if e.get('emailAddress') != value] + result['emails_synced'] += 1 # Zählt als "synced" (gelöscht) + else: + espo_phones = [p for p in espo_phones if p.get('phoneNumber') != value] + result['phones_synced'] += 1 + + # Update EspoCRM wenn Änderungen + if result['emails_synced'] > 0 or result['phones_synced'] > 0: + await self.espocrm.update_entity('CBeteiligte', beteiligte_id, { + 'emailAddressData': espo_emails, + 'phoneNumberData': espo_phones + }) + self.logger.info(f"[KOMM] ✅ Updated EspoCRM: {result['emails_synced']} emails, {result['phones_synced']} phones") + + except Exception as e: + self.logger.error(f"[KOMM] Fehler bei Advoware→EspoCRM Apply: {e}", exc_info=True) + result['errors'].append(str(e)) + + return result + + async def _apply_espocrm_to_advoware(self, betnr: int, diff: Dict, + advo_bet: Dict) -> Dict[str, Any]: + """ + Wendet EspoCRM-Änderungen auf Advoware an (Var1, Var2, Var3, Var5) + """ + result = {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []} + + try: + advo_kommunikationen = advo_bet.get('kommunikation', []) + + # Var2: In EspoCRM gelöscht → Empty Slot in Advoware + for komm in diff['espo_deleted']: + komm_id = komm.get('id') + tlf = (komm.get('tlf') or '').strip() + self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - komm_id={komm_id}, value='{tlf[:30]}...'") + await self._create_empty_slot(betnr, komm) + self.logger.info(f"[KOMM] ✅ Empty slot created for komm_id={komm_id}") + result['deleted'] += 1 + + # Var5: In EspoCRM geändert (z.B. primary Flag) + for value, advo_komm, espo_item in diff['espo_changed']: + self.logger.info(f"[KOMM] ✏️ Var5: EspoCRM changed '{value[:30]}...', primary={espo_item.get('primary')}") + + bemerkung = advo_komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + user_text = marker.get('user_text', '') if marker else '' + + # Erkenne kommKz mit espo_type + if marker: + kommkz = marker['kommKz'] + self.logger.info(f"[KOMM] kommKz from marker: {kommkz}") + else: + espo_type = espo_item.get('type', 'email' if '@' in value else None) + kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type) + self.logger.info(f"[KOMM] kommKz detected: espo_type={espo_type}, kommKz={kommkz}") + + # Update in Advoware + await self.advoware.update_kommunikation(betnr, advo_komm['id'], { + 'tlf': value, + 'online': espo_item['primary'], + 'bemerkung': create_marker(value, kommkz, user_text) + }) + self.logger.info(f"[KOMM] ✅ Updated komm_id={advo_komm['id']}, kommKz={kommkz}") + result['updated'] += 1 + + # Var1: Neu in EspoCRM → Create oder reuse Slot in Advoware + for value, espo_item in diff['espo_new']: + self.logger.info(f"[KOMM] ➕ Var1: New in EspoCRM '{value[:30]}...', type={espo_item.get('type')}") + + # Erkenne kommKz mit espo_type + espo_type = espo_item.get('type', 'email' if '@' in value else None) + kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type) + self.logger.info(f"[KOMM] 🔍 kommKz detected: espo_type={espo_type}, kommKz={kommkz}") + + # Suche leeren Slot + empty_slot = find_empty_slot(kommkz, advo_kommunikationen) + + if empty_slot: + # Reuse Slot + self.logger.info(f"[KOMM] ♻️ Reusing empty slot: slot_id={empty_slot['id']}, kommKz={kommkz}") + await self.advoware.update_kommunikation(betnr, empty_slot['id'], { + 'tlf': value, + 'online': espo_item['primary'], + 'bemerkung': create_marker(value, kommkz) + }) + self.logger.info(f"[KOMM] ✅ Slot reused successfully") + else: + # Create new + self.logger.info(f"[KOMM] ➕ Creating new kommunikation: kommKz={kommkz}") + await self.advoware.create_kommunikation(betnr, { + 'tlf': value, + 'kommKz': kommkz, + 'online': espo_item['primary'], + 'bemerkung': create_marker(value, kommkz) + }) + self.logger.info(f"[KOMM] ✅ Created new kommunikation with kommKz={kommkz}") + + result['created'] += 1 + + except Exception as e: + self.logger.error(f"[KOMM] Fehler bei EspoCRM→Advoware Apply: {e}", exc_info=True) + result['errors'].append(str(e)) + + return result + + # ========== HELPER METHODS ========== + + async def _create_empty_slot(self, betnr: int, advo_komm: Dict) -> None: + """Erstellt leeren Slot für gelöschten Eintrag""" + try: + komm_id = advo_komm['id'] + bemerkung = advo_komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + + if not marker: + self.logger.warning(f"[KOMM] Kein Marker gefunden für gelöschten Eintrag: {komm_id}") + return + + kommkz = marker['kommKz'] + slot_marker = create_slot_marker(kommkz) + + update_data = { + 'tlf': '', + 'bemerkung': slot_marker, + 'online': False + } + + await self.advoware.update_kommunikation(betnr, komm_id, update_data) + self.logger.info(f"[KOMM] ✅ Created empty slot: komm_id={komm_id}, kommKz={kommkz}") + + except Exception as e: + self.logger.error(f"[KOMM] Fehler beim Erstellen von Empty Slot: {e}", exc_info=True) + + def _needs_update(self, advo_komm: Dict, espo_item: Dict) -> bool: + """Prüft ob Update nötig ist""" + current_value = (advo_komm.get('tlf') or '').strip() + new_value = espo_item['value'].strip() + + current_online = advo_komm.get('online', False) + new_online = espo_item.get('primary', False) + + return current_value != new_value or current_online != new_online + + async def _update_kommunikation(self, betnr: int, advo_komm: Dict, espo_item: Dict) -> None: + """Updated Advoware Kommunikation""" + try: + komm_id = advo_komm['id'] + value = espo_item['value'] + + # Erkenne kommKz (sollte aus Marker kommen) + bemerkung = advo_komm.get('bemerkung') or '' + marker = parse_marker(bemerkung) + kommkz = marker['kommKz'] if marker else detect_kommkz(value, espo_type=espo_item.get('type')) + + # Behalte User-Bemerkung + user_text = get_user_bemerkung(advo_komm) + new_marker = create_marker(value, kommkz, user_text) + + update_data = { + 'tlf': value, + 'bemerkung': new_marker, + 'online': espo_item.get('primary', False) + } + + await self.advoware.update_kommunikation(betnr, komm_id, update_data) + self.logger.info(f"[KOMM] ✅ Updated: komm_id={komm_id}, value={value[:30]}...") + + except Exception as e: + self.logger.error(f"[KOMM] Fehler beim Update: {e}", exc_info=True) + + async def _create_or_reuse_kommunikation(self, betnr: int, espo_item: Dict, + advo_kommunikationen: List[Dict]) -> bool: + """ + Erstellt neue Kommunikation oder nutzt leeren Slot + + Returns: + True wenn erfolgreich erstellt/reused + """ + try: + value = espo_item['value'] + + # Erkenne kommKz mit EspoCRM type + espo_type = espo_item.get('type', 'email' if '@' in value else None) + kommkz = detect_kommkz(value, espo_type=espo_type) + self.logger.info(f"[KOMM] 🔍 kommKz detection: value='{value[:30]}...', espo_type={espo_type}, kommKz={kommkz}") + + # Suche leeren Slot mit passendem kommKz + empty_slot = find_empty_slot(kommkz, advo_kommunikationen) + + new_marker = create_marker(value, kommkz) + + if empty_slot: + # ========== REUSE SLOT ========== + komm_id = empty_slot['id'] + self.logger.info(f"[KOMM] ♻️ Reusing empty slot: komm_id={komm_id}, kommKz={kommkz}") + update_data = { + 'tlf': value, + 'bemerkung': new_marker, + 'online': espo_item.get('primary', False) + } + + await self.advoware.update_kommunikation(betnr, komm_id, update_data) + self.logger.info(f"[KOMM] ✅ Slot reused successfully: value='{value[:30]}...'") + + else: + # ========== CREATE NEW ========== + self.logger.info(f"[KOMM] ➕ Creating new kommunikation entry: kommKz={kommkz}") + create_data = { + 'tlf': value, + 'bemerkung': new_marker, + 'kommKz': kommkz, + 'online': espo_item.get('primary', False) + } + + await self.advoware.create_kommunikation(betnr, create_data) + self.logger.info(f"[KOMM] ✅ Created new: value='{value[:30]}...', kommKz={kommkz}") + + return True + + except Exception as e: + self.logger.error(f"[KOMM] Fehler beim Erstellen/Reuse: {e}", exc_info=True) + return False + + +# ========== CHANGE DETECTION ========== + +def detect_kommunikation_changes(old_bet: Dict, new_bet: Dict) -> bool: + """ + Erkennt Änderungen in Kommunikationen via rowId + + Args: + old_bet: Alte Beteiligte-Daten (mit kommunikation[]) + new_bet: Neue Beteiligte-Daten (mit kommunikation[]) + + Returns: + True wenn Änderungen erkannt + """ + old_komm = old_bet.get('kommunikation', []) + new_komm = new_bet.get('kommunikation', []) + + # Check Count + if len(old_komm) != len(new_komm): + return True + + # Check rowIds + old_row_ids = {k.get('rowId') for k in old_komm} + new_row_ids = {k.get('rowId') for k in new_komm} + + return old_row_ids != new_row_ids + + +def detect_espocrm_kommunikation_changes(old_data: Dict, new_data: Dict) -> bool: + """ + Erkennt Änderungen in EspoCRM emailAddressData/phoneNumberData + + Returns: + True wenn Änderungen erkannt + """ + old_emails = old_data.get('emailAddressData', []) + new_emails = new_data.get('emailAddressData', []) + + old_phones = old_data.get('phoneNumberData', []) + new_phones = new_data.get('phoneNumberData', []) + + # Einfacher Vergleich: Count und Values + if len(old_emails) != len(new_emails) or len(old_phones) != len(new_phones): + return True + + old_email_values = {e.get('emailAddress') for e in old_emails} + new_email_values = {e.get('emailAddress') for e in new_emails} + + old_phone_values = {p.get('phoneNumber') for p in old_phones} + new_phone_values = {p.get('phoneNumber') for p in new_phones} + + return old_email_values != new_email_values or old_phone_values != new_phone_values diff --git a/bitbylaw/steps/vmh/beteiligte_sync_event_step.py b/bitbylaw/steps/vmh/beteiligte_sync_event_step.py index 6c857f47..bca18007 100644 --- a/bitbylaw/steps/vmh/beteiligte_sync_event_step.py +++ b/bitbylaw/steps/vmh/beteiligte_sync_event_step.py @@ -1,7 +1,13 @@ +from typing import Dict, Any, Optional from services.advoware import AdvowareAPI +from services.advoware_service import AdvowareService from services.espocrm import EspoCRMAPI from services.espocrm_mapper import BeteiligteMapper from services.beteiligte_sync_utils import BeteiligteSync +from services.kommunikation_sync_utils import ( + KommunikationSyncManager, + detect_kommunikation_changes +) import json import redis from config import Config @@ -54,6 +60,10 @@ async def handler(event_data, context): sync_utils = BeteiligteSync(espocrm, redis_client, context) mapper = BeteiligteMapper() + # Kommunikation Sync Manager + advo_service = AdvowareService(context) + komm_sync = KommunikationSyncManager(advo_service, espocrm, context) + try: # 1. ACQUIRE LOCK (verhindert parallele Syncs) lock_acquired = await sync_utils.acquire_sync_lock(entity_id) @@ -85,7 +95,7 @@ async def handler(event_data, context): # FALL B: Existiert (hat betnr) → UPDATE oder CHECK elif betnr: context.logger.info(f"♻️ Existierender Beteiligter (betNr: {betnr}) → UPDATE/CHECK") - await handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, context) + await handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, komm_sync, context) # FALL C: DELETE (TODO: Implementierung später) elif action == 'delete': @@ -112,6 +122,28 @@ async def handler(event_data, context): pass +async def run_kommunikation_sync(entity_id: str, betnr: int, komm_sync, context, direction: str = 'both') -> Dict[str, Any]: + """ + Helper: Führt Kommunikation-Sync aus mit Error-Handling + + Args: + direction: 'both' (bidirektional), 'to_advoware' (nur EspoCRM→Advoware), 'to_espocrm' (nur Advoware→EspoCRM) + + Returns: + Sync-Ergebnis oder None bei Fehler + """ + context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...") + try: + komm_result = await komm_sync.sync_bidirectional(entity_id, betnr, direction=direction) + context.logger.info(f"✅ Kommunikation synced: {komm_result}") + return komm_result + except Exception as e: + context.logger.error(f"⚠️ Kommunikation-Sync fehlgeschlagen: {e}") + import traceback + context.logger.error(traceback.format_exc()) + return None + + async def handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, context): """Erstellt neuen Beteiligten in Advoware""" try: @@ -167,7 +199,7 @@ async def handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, m await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True) -async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, context): +async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, komm_sync, context): """Synchronisiert existierenden Beteiligten""" try: context.logger.info(f"🔍 Fetch von Advoware betNr={betnr}...") @@ -204,9 +236,18 @@ async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_u context.logger.info(f"⏱️ Vergleich: {comparison}") - # KEIN SYNC NÖTIG + # KOMMUNIKATION-ÄNDERUNGSERKENNUNG (zusätzlich zu Stammdaten) + # Speichere alte Version für späteren Vergleich + old_advo_entity = advo_entity.copy() + komm_changes_detected = False + + # KEIN STAMMDATEN-SYNC NÖTIG (aber Kommunikation könnte geändert sein) if comparison == 'no_change': - context.logger.info(f"✅ Keine Änderungen, Sync übersprungen") + context.logger.info(f"✅ Keine Stammdaten-Änderungen erkannt") + + # KOMMUNIKATION SYNC: Prüfe trotzdem Kommunikationen + await run_kommunikation_sync(entity_id, betnr, komm_sync, context) + await sync_utils.release_sync_lock(entity_id, 'clean') return @@ -230,27 +271,35 @@ async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_u elif isinstance(put_result, dict): new_rowid = put_result.get('rowId') - # Release Lock + Update rowId in einem Call (effizienter!) + context.logger.info(f"✅ Advoware STAMMDATEN aktualisiert, rowId: {new_rowid[:20] if new_rowid else 'N/A'}...") + + # KOMMUNIKATION SYNC: Immer ausführen nach Stammdaten-Update + await run_kommunikation_sync(entity_id, betnr, komm_sync, context) + + # Release Lock NACH Kommunikation-Sync + Update rowId await sync_utils.release_sync_lock( entity_id, 'clean', extra_fields={'advowareRowId': new_rowid} ) - context.logger.info(f"✅ Advoware aktualisiert, rowId in EspoCRM geschrieben: {new_rowid[:20] if new_rowid else 'N/A'}...") # ADVOWARE NEUER → Update EspoCRM elif comparison == 'advoware_newer': context.logger.info(f"📥 Advoware ist neuer → Update EspoCRM STAMMDATEN") espo_data = mapper.map_advoware_to_cbeteiligte(advo_entity) - await espocrm.update_entity('CBeteiligte', entity_id, espo_data) + context.logger.info(f"✅ EspoCRM STAMMDATEN aktualisiert") + + # KOMMUNIKATION SYNC: Immer ausführen nach Stammdaten-Update + await run_kommunikation_sync(entity_id, betnr, komm_sync, context) + + # Release Lock NACH Kommunikation-Sync + Update rowId await sync_utils.release_sync_lock( entity_id, 'clean', extra_fields={'advowareRowId': advo_entity.get('rowId')} ) - context.logger.info(f"✅ EspoCRM aktualisiert") # KONFLIKT → EspoCRM WINS elif comparison == 'conflict': @@ -286,9 +335,19 @@ async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_u extra_fields={'advowareRowId': new_rowid} ) context.logger.info(f"✅ Konflikt gelöst (EspoCRM won), neue rowId: {new_rowid[:20] if new_rowid else 'N/A'}...") + + # KOMMUNIKATION SYNC: NUR EspoCRM→Advoware (EspoCRM wins!) + await run_kommunikation_sync(entity_id, betnr, komm_sync, context, direction='to_advoware') + + # Release Lock NACH Kommunikation-Sync + await sync_utils.release_sync_lock(entity_id, 'clean') except Exception as e: context.logger.error(f"❌ UPDATE fehlgeschlagen: {e}") import traceback context.logger.error(traceback.format_exc()) - await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True) \ No newline at end of file + await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True) + + +# Alias für Tests/externe Aufrufe +handle = handler \ No newline at end of file