From c770f2c8eec22e298033384d7a08cbd8a4c9da90 Mon Sep 17 00:00:00 2001 From: bitbylaw Date: Sun, 8 Feb 2026 14:29:29 +0000 Subject: [PATCH] feat: Implement address synchronization between EspoCRM and Advoware - Add AdressenMapper for transforming addresses between EspoCRM and Advoware formats. - Create AdressenSync class to handle address creation, update, and deletion synchronization. - Introduce NotificationManager for managing manual intervention notifications in case of sync issues. - Implement detailed logging for address sync operations and error handling. - Ensure READ-ONLY field changes are detected and notified for manual resolution. --- bitbylaw/docs/ADRESSEN_SYNC_ANALYSE.md | 1646 +++++++++++++++++ bitbylaw/docs/ADRESSEN_SYNC_SUMMARY.md | 254 +++ bitbylaw/scripts/test_adressen_api.py | 696 +++++++ .../test_adressen_deactivate_ordering.py | 466 +++++ .../scripts/test_adressen_delete_matching.py | 457 +++++ .../test_adressen_gueltigbis_modify.py | 468 +++++ bitbylaw/scripts/test_adressen_nullen.py | 243 +++ bitbylaw/scripts/test_adressen_sync.py | 234 +++ bitbylaw/scripts/test_find_hauptadresse.py | 189 ++ .../scripts/test_hauptadresse_explizit.py | 151 ++ bitbylaw/scripts/test_hauptadresse_logic.py | 304 +++ bitbylaw/scripts/test_put_response_detail.py | 127 ++ bitbylaw/services/adressen_mapper.py | 266 +++ bitbylaw/services/adressen_sync.py | 514 +++++ bitbylaw/services/notification_utils.py | 412 +++++ 15 files changed, 6427 insertions(+) create mode 100644 bitbylaw/docs/ADRESSEN_SYNC_ANALYSE.md create mode 100644 bitbylaw/docs/ADRESSEN_SYNC_SUMMARY.md create mode 100644 bitbylaw/scripts/test_adressen_api.py create mode 100644 bitbylaw/scripts/test_adressen_deactivate_ordering.py create mode 100644 bitbylaw/scripts/test_adressen_delete_matching.py create mode 100644 bitbylaw/scripts/test_adressen_gueltigbis_modify.py create mode 100644 bitbylaw/scripts/test_adressen_nullen.py create mode 100644 bitbylaw/scripts/test_adressen_sync.py create mode 100644 bitbylaw/scripts/test_find_hauptadresse.py create mode 100644 bitbylaw/scripts/test_hauptadresse_explizit.py create mode 100644 bitbylaw/scripts/test_hauptadresse_logic.py create mode 100644 bitbylaw/scripts/test_put_response_detail.py create mode 100644 bitbylaw/services/adressen_mapper.py create mode 100644 bitbylaw/services/adressen_sync.py create mode 100644 bitbylaw/services/notification_utils.py diff --git a/bitbylaw/docs/ADRESSEN_SYNC_ANALYSE.md b/bitbylaw/docs/ADRESSEN_SYNC_ANALYSE.md new file mode 100644 index 00000000..86902e9d --- /dev/null +++ b/bitbylaw/docs/ADRESSEN_SYNC_ANALYSE.md @@ -0,0 +1,1646 @@ +# Adressen-Synchronisation: Analyse EspoCRM ↔ Advoware + +**Datum**: 8. Februar 2026 +**Status**: ✅ Analyse abgeschlossen - Implementierung bereit +**Zweck**: Evaluierung der Sync-Möglichkeiten für Adressen zwischen EspoCRM und Advoware + +## ⚠️ KRITISCHE ERKENNTNISSE (Executive Summary) + +**Advoware API-Limitierungen (getestet):** +1. ❌ **DELETE nicht möglich** (403 Forbidden) +2. ❌ **Nur 4 Felder via PUT änderbar**: `strasse`, `plz`, `ort`, `anschrift` +3. ❌ **READ-ONLY Felder**: `land`, `postfach`, `postfachPLZ`, `standardAnschrift`, `bemerkung`, `gueltigVon`, `gueltigBis`, `reihenfolgeIndex` +4. ✅ **`bemerkung` ist stabil** (kann für EspoCRM-ID Matching genutzt werden) +5. ❌ **`id` ist immer 0** (unbrauchbar für Mapping) +6. ⚠️ **`reihenfolgeIndex` wird nach Löschung neu vergeben** (NICHT stabil, NICHT als Identifier nutzbar!) +7. ❌ **`rowId` ändert sich bei jedem PUT** (unbrauchbar für Matching) + +**Konsequenzen:** +- ✅ CREATE (POST) funktioniert mit allen Feldern +- ⚠️ UPDATE (PUT) nur für Haupt-Adressfelder +- ❌ DELETE muss manuell in Advoware erfolgen +- ❌ Soft-Delete via `gueltigBis` nicht möglich (READ-ONLY) +- ✅ **`bemerkung` ist die EINZIGE stabile Matching-Methode** ("EspoCRM-ID: {id}") +- ⚠️ `reihenfolgeIndex` für PUT-Endpoint muss VOR jedem Update via GET + Match ermittelt werden + +**Empfohlene Strategie:** +→ Siehe Abschnitt 12 "FINALE SYNC-STRATEGIE" + +**Quick Summary:** +- ✅ **CREATE**: Vollautomatisch (alle 11 Felder) +- ✅ **UPDATE**: Nur R/W Felder (strasse, plz, ort, anschrift) +- ❌ **DELETE**: Notification + manuelle Löschung +- ⚠️ **READ-ONLY Änderungen**: Notification + manuelle Aktion + +--- + +## 1. Advoware Adressen-API (Swagger-basiert) + +### 1.1 Verfügbare Endpoints + +Advoware bietet drei REST-Endpoints für Adressen-Management: + +#### GET `/api/v1/advonet/Beteiligte/{beteiligterId}/Adressen` +- **Zweck**: Liste aller Adressen eines Beteiligten abrufen +- **Auth**: AllLoggedIn +- **Response**: Array von `Adresse`-Objekten +- **Anwendungsfall**: Initiale Synchronisation, Polling für Änderungen + +#### POST `/api/v1/advonet/Beteiligte/{beteiligterId}/Adressen` +- **Zweck**: Neue Adresse für einen Beteiligten hinzufügen +- **Auth**: AllLoggedIn +- **Request Body**: `AdresseParameter` (JSON) +- **Response**: Array von `Adresse`-Objekten (201 Created) +- **Anwendungsfall**: EspoCRM → Advoware Sync beim Erstellen neuer Adressen + +#### PUT `/api/v1/advonet/Beteiligte/{beteiligterId}/Adressen/{adresseId}` +- **Zweck**: Bestehende Adresse ändern +- **Auth**: Mitarbeiter (⚠️ höhere Berechtigung!) +- **Request Body**: `AdresseParameter` (JSON) +- **Response**: Aktualisierte `Adresse` (200 OK) +- **Anwendungsfall**: EspoCRM → Advoware Sync beim Update + +**Wichtig**: Es gibt **keinen DELETE-Endpoint** in der Swagger-Definition! + +--- + +## 2. Advoware Adressen-Schema + +### 2.1 Adresse (Response-Objekt) + +Vollständiges Schema mit allen Feldern: + +```json +{ + "id": 123, // int32 - Adress-ID + "beteiligterId": 104860, // int32 - Referenz zu Beteiligter + "reihenfolgeIndex": 1, // int32 - Sortierung + "rowId": "abc123...", // string - Änderungserkennung + + "strasse": "Musterstraße 123", // string (max 50) - Straße + Hausnummer + "plz": "30159", // string (max 20) - Postleitzahl + "ort": "Hannover", // string (max 39) - Ort + "land": "DE", // string (max 3) - Ländercode (ISO 3166) + + "postfach": "10 20 30", // string (max 20) - Postfach + "postfachPLZ": "30001", // string (max 6) - Postfach PLZ + + "anschrift": "...", // string (max 250) - Formatierte Adresse + "standardAnschrift": true, // boolean - Ist Hauptadresse? + + "bemerkung": "Privat", // string (max 250) - Kommentar + "gueltigVon": "2020-01-01T00:00:00", // datetime - Gültigkeit von + "gueltigBis": "2025-12-31T00:00:00" // datetime - Gültigkeit bis +} +``` + +### 2.2 AdresseParameter (Request-Objekt) + +Für POST/PUT: + +```json +{ + "strasse": "Neue Straße 456", + "plz": "10115", + "ort": "Berlin", + "land": "DE", + "postfach": null, + "postfachPLZ": null, + "anschrift": "Neue Straße 456\n10115 Berlin", + "standardAnschrift": false, + "bemerkung": "Geschäftsadresse", + "gueltigVon": "2026-02-08T00:00:00", + "gueltigBis": null +} +``` + +**Felder-Unterschiede**: +- `id`, `beteiligterId`, `reihenfolgeIndex`, `rowId` werden **nicht** im Request mitgeschickt (server-generiert) + +--- + +## 3. EspoCRM Adressen-Konzept + +### 3.1 EspoCRM Adressmodell (✅ Verifiziert via API) + +EspoCRM hat ein **Custom Entity `CAdressen`** mit Beziehung zu `CBeteiligte`. + +**Status**: ✅ **Existiert bereits!** (5 Adressen in Datenbank) + +#### CAdressen Entity-Struktur + +```json +{ + "id": "696f71061b5cbb8ec", + "name": "Wund", + "deleted": false, + + // 📍 Adressfelder + "adresseStreet": "Ehlbeek 3", + "adresseCity": "Burgwedel", + "adressePostalCode": "30938", + "adresseCountry": "DE", + "adresseState": null, + + // 🔗 Beziehung zu CBeteiligte + "beteiligteId": "68e4aef68d2b4fb98", + "beteiligteName": "ssdasd", + + // ⚙️ Business-Felder + "isActive": true, + "syncStatus": "clean", + "description": null, + "autoapplymietverhaltnisportaluser": true, + + // 🔄 Advoware-Sync-Felder + "adressid": null, // Advoware Adressen-ID + "advowareindexid": null, // Advoware reihenfolgeIndex? + "advowareLastSync": null, + + // 📊 Metadaten + "createdAt": "2026-01-20 12:11:50", + "createdById": "68d65929f18c2afef", + "createdByName": "Admin", + "modifiedAt": "2026-01-20 12:11:59", + "modifiedById": "68d65929f18c2afef", + "modifiedByName": "Admin", + + // Teams & Followers + "teamsIds": [], + "teamsNames": {}, + "followersIds": [], + "followersNames": {}, + "assignedUserId": null, + "assignedUserName": null, + "isFollowed": false, + "streamUpdatedAt": null +} +``` + +**Vorteile**: +- ✅ Beliebig viele Adressen pro Beteiligtem +- ✅ Sync-Felder bereits vorhanden (`adressid`, `advowareLastSync`, `syncStatus`) +- ✅ Standard EspoCRM-Features (Teams, Followers, Assignments) + +#### Beziehung zu CBeteiligte + +```json +// CBeteiligte hat Link-Felder: +{ + "id": "6987b30a9bbbfefd0", + "name": "OptTest86 Musterfrau3", + "adressensIds": [], // Array von CAdressen-IDs + "adressensNames": {} // Name-Mapping +} +``` + +--- + +## 4. Mapping: EspoCRM CAdressen ↔ Advoware Adresse + +### 4.1 Feld-Mapping (✅ Verifiziert) + +| EspoCRM Feld | Advoware Feld | Mapping-Typ | Hinweise | +|--------------|---------------|-------------|----------| +| `adresseStreet` | `strasse` | ✓ Direkt | Straße + Hausnummer | +| `adresseCity` | `ort` | ✓ Direkt | Ort | +| `adressePostalCode` | `plz` | ✓ Direkt | Postleitzahl | +| `adresseCountry` | `land` | ⚠️ Transform | DE ↔ Deutschland | +| `adresseState` | - | ❌ Nicht gemappt | Bundesland (nur EspoCRM) | +| `beteiligteId` | `beteiligterId` | ✓ Direkt | Link zu Beteiligtem | +| `name` | - | ➜ EspoCRM | Adress-Bezeichnung | +| `isActive` | - | ➜ EspoCRM | Aktiv-Status | +| `description` | `bemerkung` | ⚠️ Möglich | Beschreibung/Bemerkung | +| - | `postfach` | ← Nur Advoware | Postfach | +| - | `postfachPLZ` | ← Nur Advoware | Postfach PLZ | +| - | `anschrift` | ← Nur Advoware | Formatierte Adresse | +| - | `standardAnschrift` | ← Nur Advoware | Ist Hauptadresse? | +| - | `gueltigVon` | ← Nur Advoware | Gültigkeit von | +| - | `gueltigBis` | ← Nur Advoware | Gültigkeit bis | +| - | `reihenfolgeIndex` | ← Nur Advoware | Sortierung | + +#### Sync-Felder in EspoCRM (bereits vorhanden!) + +| EspoCRM Feld | Zweck | +|--------------|-------| +| `adressid` | Advoware Adressen-ID (zum Mapping) | +| `advowareindexid` | Advoware `reihenfolgeIndex` | +| `advowareLastSync` | Timestamp der letzten Synchronisation | +| `syncStatus` | Status: `clean`, `dirty`, `error` | + +### 4.2 Herausforderungen + +#### 4.2.1 Anzahl Adressen ✅ GELÖST + +- **EspoCRM**: ✅ Beliebig viele Adressen (CAdressen-Entity) +- **Advoware**: ✅ Beliebig viele Adressen + +**Status**: Kein Problem! Beide Systeme unterstützen mehrere Adressen. + +#### 4.2.2 Ländercode-Transformation + +- **Advoware**: Erwartet 2-3 stelligen Code (z.B. `"DE"`, `"AT"`) +- **EspoCRM**: Speichert bereits Kürzel (z.B. `"DE"`) + +**Status**: ✅ Bereits kompatibel! Ggf. Fallback für alte Daten mit Vollnamen. + +**Lösung**: Transformation-Tabelle als Fallback: + +```python +COUNTRY_CODES = { + 'Deutschland': 'DE', + 'Österreich': 'AT', + 'Schweiz': 'CH', + 'DE': 'DE', # Already short form + 'AT': 'AT', + 'CH': 'CH', + # ... +} +``` + +#### 4.2.3 Hauptadresse-Flag + +- **Advoware**: `standardAnschrift: true` markiert Hauptadresse +- **EspoCRM**: Kein dediziertes Feld (nutzt `name` oder `isActive`) + +**Strategie-Optionen**: +1. Erste aktive Adresse = Hauptadresse +2. Adresse mit `name = "Hauptadresse"` +3. Neues Feld `isPrimary` in EspoCRM hinzufügen + +#### 4.2.4 Formatierte Adresse (`anschrift`) + +Advoware hat ein `anschrift`-Feld für die vollständig formatierte Adresse: + +``` +"anschrift": "Musterstraße 123\n30159 Hannover\nDeutschland" +``` + +**Lösung**: EspoCRM generiert diese aus den Einzelfeldern: + +```python +def format_anschrift(addr): + parts = [] + if addr.get('adresseStreet'): + parts.append(addr['adresseStreet']) + if addr.get('adressePostalCode') or addr.get('adresseCity'): + parts.append(f"{addr.get('adressePostalCode', '')} {addr.get('adresseCity', '')}".strip()) + if addr.get('adresseCountry'): + parts.append(addr['adresseCountry']) + return '\n'.join(parts) +``` + +#### 4.2.5 Postfach-Adressen + +- **Advoware**: Separate Felder `postfach` und `postfachPLZ` +- **EspoCRM**: Kein dediziertes Feld + +**Strategie**: +- Option A: In `description` speichern +- Option B: In `adresseStreet` mit Prefix "Postfach: ..." +- Option C: Neue Custom-Felder hinzufügen + +#### 4.2.6 Zeitliche Gültigkeit + +- **Advoware**: `gueltigVon` und `gueltigBis` (datetime) +- **EspoCRM**: Kein dediziertes Feld + +**Strategie**: +- Option A: Ignorieren (nicht kritisch für Start) +- Option B: In `description` speichern +- Option C: Neue Custom-Felder hinzufügen +- Option D: `isActive = false` wenn `gueltigBis` in Vergangenheit + +--- + +## 5. Sync-Strategien + +### 5.1 ✅ EMPFOHLEN: CAdressen-Entity (Full Sync) + +**Voraussetzung**: ✅ EspoCRM hat bereits CAdressen-Entity mit allen benötigten Feldern + +**Status**: **IDEAL** - Beide Systeme unterstützen mehrere Adressen + +#### EspoCRM → Advoware + +```python +async def sync_addresses_to_advoware(beteiligte_id: str, betnr: int): + """ + Synct alle CAdressen-Entities zu Advoware + """ + espo = EspoCRMAPI() + advo = AdvowareAPI() + + # 1. Hole alle Adressen von EspoCRM für diesen Beteiligten + addresses = await espo.list_entities( + 'CAdressen', + where=[{ + 'type': 'equals', + 'attribute': 'beteiligteId', + 'value': beteiligte_id + }] + ) + + # 2. Hole existierende Adressen von Advoware + advo_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + # 3. Sync jede Adresse + for addr in addresses['list']: + # Überspringe inaktive Adressen? + if not addr.get('isActive', True): + continue + + advo_addr_id = addr.get('adressid') # Gemappte Advoware-ID + + advo_data = { + 'strasse': addr.get('adresseStreet'), + 'plz': addr.get('adressePostalCode'), + 'ort': addr.get('adresseCity'), + 'land': addr.get('adresseCountry') or 'DE', + 'anschrift': format_anschrift(addr), + 'bemerkung': addr.get('description'), + # standardAnschrift: Bestimme via Logic + 'standardAnschrift': is_primary_address(addr, addresses['list']) + } + + if advo_addr_id: + # Update existierende Adresse + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{advo_addr_id}', + method='PUT', + json_data=advo_data + ) + else: + # Create neue Adresse + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='POST', + json_data=advo_data + ) + + # POST gibt Array zurück, nimm erste Adresse + if isinstance(result, list) and result: + new_addr = result[0] + else: + new_addr = result + + # Update EspoCRM mit Advoware-ID + await espo.update_entity('CAdressen', addr['id'], { + 'adressid': new_addr['id'], + 'advowareindexid': new_addr.get('reihenfolgeIndex'), + 'advowareLastSync': datetime.utcnow().isoformat(), + 'syncStatus': 'clean' + }) +``` + +#### Advoware → EspoCRM + +```python +async def sync_addresses_to_espocrm(betnr: int, beteiligte_id: str): + """ + Synct alle Advoware-Adressen zu EspoCRM CAdressen + """ + advo = AdvowareAPI() + espo = EspoCRMAPI() + + # 1. Hole Adressen von Advoware + advo_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + # 2. Hole existierende CAdressen-Entities + espo_addresses = await espo.list_entities( + 'CAdressen', + where=[{ + 'type': 'equals', + 'attribute': 'beteiligteId', + 'value': beteiligte_id + }] + ) + + # 3. Mappe via adressid + espo_by_advo_id = { + a['adressid']: a + for a in espo_addresses['list'] + if a.get('adressid') + } + + for advo_addr in advo_addresses: + espo_data = { + 'name': advo_addr.get('bemerkung') or f"{advo_addr.get('ort')} - {advo_addr.get('strasse')}", + 'beteiligteId': beteiligte_id, + 'adressid': advo_addr['id'], + 'advowareindexid': advo_addr.get('reihenfolgeIndex'), + 'adresseStreet': advo_addr.get('strasse'), + 'adresseCity': advo_addr.get('ort'), + 'adressePostalCode': advo_addr.get('plz'), + 'adresseCountry': advo_addr.get('land'), + 'description': advo_addr.get('bemerkung'), + 'isActive': True, # Oder basierend auf gueltigBis + 'syncStatus': 'clean', + 'advowareLastSync': datetime.utcnow().isoformat() + } + + existing_espo = espo_by_advo_id.get(advo_addr['id']) + + if existing_espo: + # Update nur wenn rowId sich geändert hat + if advo_addr.get('rowId') != existing_espo.get('advowareRowId'): + await espo.update_entity('CAdressen', existing_espo['id'], espo_data) + else: + # Create + await espo.create_entity('CAdressen', espo_data) +``` + +**Vorteile**: +- ✅ Beliebig viele Adressen +- ✅ Vollständiges Mapping aller Felder +- ✅ Sync-Felder bereits vorhanden +- ✅ Keine Datenverluste +- ✅ Entity existiert bereits! + +**Nachteile**: +- ⚠️ Postfach-Felder fehlen in EspoCRM (optional: Custom Fields) +- ⚠️ Gültigkeitsdaten fehlen in EspoCRM (optional: Custom Fields) + +--- + +## 6. Weitere Sync-Aspekte + +### 6.1 Change Detection + +**Wie erkennt man Änderungen?** + +#### Option A: rowId-Vergleich + +Advoware gibt `rowId` zurück: + +```python +def has_changed(advo_addr, espo_addr): + """Vergleich via rowId""" + return advo_addr.get('rowId') != espo_addr.get('advowareRowId') +``` + +#### Option B: Timestamp-basiert + +```python +def has_changed(advo_addr, espo_addr): + """Vergleich via lastSyncedAt""" + last_sync = espo_addr.get('lastSyncedAt') + if not last_sync: + return True + + # Advoware hat keine Timestamps, nutze rowId + return advo_addr.get('rowId') != espo_addr.get('advowareRowId') +``` + +### 6.2 Konfliktauflösung + +**Was passiert bei gleichzeitigen Änderungen?** + +#### Master-System definieren + +```python +SYNC_MASTER = 'advoware' # oder 'espocrm' + +if SYNC_MASTER == 'advoware': + # Advoware-Daten überschreiben EspoCRM + pass +elif SYNC_MASTER == 'espocrm': + # EspoCRM-Daten überschreiben Advoware + pass +else: + # Last-Write-Wins (via Timestamp) + pass +``` + +### 6.3 DELETE-Handling + +**Problem**: Advoware hat keinen DELETE-Endpoint für Adressen! + +#### Lösungen: + +1. **Soft-Delete in Advoware**: Setze `gueltigBis` auf Vergangenheit + ```python + # Statt DELETE: + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{addr_id}', + method='PUT', + json_data={'gueltigBis': '1970-01-01T00:00:00'} + ) + ``` + +2. **Ignoriere Deletes**: EspoCRM-Löschungen werden nicht zu Advoware propagiert + +3. **Marker-Feld**: `bemerkung: 'GELÖSCHT (EspoCRM)'` + +### 6.4 Validierung + +**Welche Felder sind Pflichtfelder?** + +Laut Schema sind alle Felder `nullable: true`, aber Best Practices: + +```python +def validate_address(addr_data): + """Validierung vor Sync""" + required = ['strasse', 'plz', 'ort'] + + for field in required: + if not addr_data.get(field): + raise ValueError(f"Pflichtfeld fehlt: {field}") + + # PLZ-Format (Deutschland) + if addr_data.get('land') == 'DE': + plz = addr_data.get('plz', '') + if not re.match(r'^\d{5}$', plz): + raise ValueError(f"Ungültige PLZ: {plz}") +``` + +--- + +## 7. Empfehlung + +### 7.1 ✅ EMPFEHLUNG: CAdressen Full-Sync (Strategie 5.1) + +**Status**: **OPTIMAL** - EspoCRM hat bereits die perfekte Struktur! + +**Warum:** +- ✅ Entity existiert bereits (`CAdressen`) +- ✅ Sync-Felder vorhanden (`adressid`, `advowareLastSync`, `syncStatus`) +- ✅ Beliebig viele Adressen möglich +- ✅ Minimale Code-Komplexität +- ✅ Keine Datenverluste +- ✅ Bereits 5 Adressen in Produktion + +**Implementierungs-Reihenfolge:** +1. ✅ Mapper-Klasse `AdressenMapper` erstellen (analog zu `BeteiligteMapper`) +2. ✅ EspoCRM → Advoware Sync (CREATE + UPDATE) +3. ✅ Advoware → EspoCRM Sync (CREATE + UPDATE) +4. ✅ Change Detection via `adressid` + `rowId` +5. ⚠️ DELETE-Handling (via Soft-Delete oder ignorieren) + +### 7.2 Optionale Erweiterungen + +**Falls benötigt:** +- 🔧 Custom-Felder für Postfach hinzufügen (`postfach`, `postfachPLZ`) +- 🔧 Custom-Felder für Gültigkeit hinzufügen (`validFrom`, `validUntil`) +- 🔧 Feld `isPrimary` für Hauptadresse-Flag + +**Aktuell:** +- `description` kann für Bemerkungen genutzt werden +- `isActive` kann für abgelaufene Adressen genutzt werden +- Hauptadresse via Konvention (erste Adresse oder nach Name) + +--- + +## 8. Offene Fragen + +### ✅ An EspoCRM-Team (BEANTWORTET): +1. ✅ Gibt es bereits ein Custom Entity für Adressen? → **JA: `CAdressen`** +2. ✅ Wie viele Adressen haben Beteiligte typischerweise? → **Aktuell 5 in DB** +3. ⚠️ Werden Postfach-Adressen genutzt? → **Zu klären** +4. ⚠️ Ist zeitliche Gültigkeit (`validFrom`/`validUntil`) relevant? → **Zu klären** + +### ❓ An Advoware-Team (BEANTWORTET via Tests): +1. ✅ DELETE nicht verfügbar → 403 Forbidden (bestätigt) +2. ✅ Gelöschte Adressen: Nur manuell in Web-Interface möglich +3. ❌ Webhook-Support: Nicht verfügbar +4. ✅ `reihenfolgeIndex`: System-managed, automatisch ans Ende gereiht + +--- + +## 9. FINALE SYNC-STRATEGIE (Nach umfassenden Tests) +BGESCHLOSSEN) +- [x] Swagger-Dokumentation analysiert +- [x] **EspoCRM CAdressen-Entity via API verifiziert** +- [x] Sync-Strategien evaluiert +- [x] **Empfehlung: Full-Sync mit CAdressen-Entity** + +### Phase 2: Prototyp (Code-Phase) 🚀 NEXT +- [ ] Mapper-Klasse `AdressenMapper` implementieren + - `map_cadressen_to_advoware(espo_addr) -> dict` + - `map_advoware_to_cadressen(advo_addr) -> dict` + - `get_changed_fields(espo_addr, advo_addr) -> list` +- [ ] Ländercode-Transformation +- [ ] `format_anschrift()` Helper-Funktion +- [ ] Change Detection via `adressid` + `rowId` +- [ ] Unit Tests + +### Phase 3: Integration +- [ ] Sync-Service `AdressenSyncService` erstellen +- [ ] Webhook für CAdressen-Changes +- [ ] Adressen-Sync nach Beteiligte-Sync +- [ ] Error Handling & Retry Logic +- [ ] Monitoring & Logging + +### Phase 4: Testing & Rollout +- [ ] Integration Tests mit echten Daten +- [ ] Performance-Tests (Batch-Sync) +- [ ] Dokumentation +- [ ] Staging Deployment +- [ ] Production Rollout +- [ ] Monitoring & Logging + +--- + +**Erstellt von**: GitHub Copilot +**Reviewer**: [Pending] +**Änderungslog**: +- 2026-02-08: Initial Analysis + +### 9.1 Test-Erkenntnisse (08.02.2026) + +**Durchgeführte Tests:** +1. ✅ Alle Felder POST/PUT Verifikation +2. ✅ DELETE-Funktionalität (→ 403 Forbidden) +3. ✅ `bemerkung`-basiertes Matching mit EspoCRM-IDs +4. ✅ `gueltigBis` nachträglich setzen (→ READ-ONLY!) +5. ✅ `reihenfolgeIndex` Verhalten (automatisch, READ-ONLY) +6. ✅ Detaillierte Feld-für-Feld PUT Analyse + +**Kritische Befunde:** + +#### ID-Mapping Problem +- ❌ `id` ist immer 0 (unbrauchbar) +- ✅ `bemerkung` ist stabil (READ-ONLY bei PUT) → Perfekt für Matching! +- ✅ `reihenfolgeIndex` ist stabil (automatisch vergeben, READ-ONLY) +- ❌ `rowId` ändert sich bei PUT → nicht für Matching nutzen + +#### PUT-Limitierungen +**✅ ÄNDERBAR (4 Felder):** +- `strasse`, `plz`, `ort`, `anschrift` + +**❌ READ-ONLY (8 Felder):** +- `land`, `postfach`, `postfachPLZ`, `standardAnschrift` +- `bemerkung` (gut für Matching!) +- `gueltigVon`, `gueltigBis` (keine Soft-Delete möglich) +- `reihenfolgeIndex` (system-managed) + +#### DELETE Problem +- ❌ DELETE /Adressen/{id} → 403 Forbidden +- ❌ gueltigBis nachträglich setzen → READ-ONLY +- → **Soft-Delete NICHT möglich via API!** + +### 9.2 Empfohlene Strategie: "Hybrid mit Notifications" + +**Prinzip**: Automatischer Sync wo möglich + manuelle Eingriffe bei API-Limitierungen + +**Workflow:** + +1. **CREATE (EspoCRM → Advoware)** + ```python + POST /Adressen { + "bemerkung": f"EspoCRM-ID: {espo_id}", # Matching-Key + # ... alle anderen Felder + } + → Update EspoCRM mit advowareIndexId, advowareRowId + ``` + +2. **UPDATE (EspoCRM → Advoware)** + ```python + GET /Adressen → match via bemerkung + PUT /Adressen/{reihenfolgeIndex} { + # Nur: strasse, plz, ort, anschrift + } + → Prüfe READ-ONLY Änderungen + → Notification wenn READ-ONLY geändert + ``` + +3. **DELETE (EspoCRM → Advoware)** + ```python + # API unterstützt kein DELETE! + → Notification + Task erstellen + → EspoCRM.isActive = False + → User löscht manuell in Advoware + ``` + +4. **SYNC (Advoware → EspoCRM)** + ```python + GET /Adressen → match via bemerkung + → Update/Create in EspoCRM + → Advoware = Master für Existenz + ``` + +### 9.3 Notification-Management + +**Zentrale Utility**: [`services/notification_utils.py`](../services/notification_utils.py) + +**Features:** +- ✅ Automatische Task-Erstellung in EspoCRM +- ✅ Notification an assigned User +- ✅ Detaillierte Anleitungen für manuelle Eingriffe +- ✅ Verschiedene Action-Types: + - `address_delete_required` + - `address_reactivate_required` + - `address_field_update_required` + - `readonly_field_conflict` + - `missing_in_advoware` + +**Beispiel:** +```python +from services.notification_utils import NotificationManager + +notif_mgr = NotificationManager(espocrm_api, context) + +# DELETE erforderlich +await notif_mgr.notify_manual_action_required( + entity_type='CAdressen', + entity_id=address_id, + action_type='address_delete_required', + details={'betnr': betnr, 'strasse': '...', ...} +) +# → Erstellt Task mit Schritt-für-Schritt Anleitung +``` + +### 9.4 EspoCRM Entity-Anpassungen + +**Erforderliche Felder in CAdressen:** +- `advowareIndexId` (int) - für PUT-Endpoint +- `advowareRowId` (varchar) - zur Validierung +- `advowareLastSync` (datetime) - Sync-Zeitpunkt +- `syncStatus` (enum) - synced | partial | manual_action_required | deleted_in_advoware +- `isActive` (bool) - Aktiv/Inaktiv Flag +- `manualActionNote` (text) - Bei manuellen Eingriffen + +### 9.5 Offene Punkte & Risiken + +**Risiken:** +| Risiko | Impact | Mitigation | +|--------|--------|------------| +| User ändert bemerkung | Hoch | Regex-Parse, auch in Text suchen | +| DELETE vergessen | Mittel | Task-System mit Due-Date | +| READ-ONLY Konflikte | Niedrig | Notification-System | +| Parallele Syncs | Hoch | Locking-Mechanismus | + +**Nächste Schritte:** +1. ✅ Notification-System implementiert +2. ⏳ EspoCRM CAdressen Entity validieren +3. ⏳ Mapper implementieren (`services/adressen_mapper.py`) +4. ⏳ Sync-Service implementieren +5. ⏳ Integration Tests + +--- + +**Test-Scripts:** +- [`scripts/test_adressen_api.py`](../scripts/test_adressen_api.py) - Umfassende API-Tests +- [`scripts/test_adressen_delete_matching.py`](../scripts/test_adressen_delete_matching.py) - DELETE + bemerkung-Matching +- [`scripts/test_adressen_deactivate_ordering.py`](../scripts/test_adressen_deactivate_ordering.py) - gueltigBis + reihenfolgeIndex +- [`scripts/test_adressen_gueltigbis_modify.py`](../scripts/test_adressen_gueltigbis_modify.py) - gueltigBis nachträglich ändern +- [`scripts/test_put_response_detail.py`](../scripts/test_put_response_detail.py) - PUT Feld-Analyse + + +--- + +## 10. Hauptadresse-Logik (standardAnschrift) + +### 10.1 Test-Ergebnisse (08.02.2026) + +**Durchgeführte Tests:** +- ✅ Prüfung ob neue Adressen automatisch zur Hauptadresse werden +- ✅ Explizites Setzen von `standardAnschrift` beim POST +- ✅ Verhalten bei mehreren Hauptadressen + +**Kritische Erkenntnisse:** + +#### ✅ Was funktioniert: +1. **`standardAnschrift` kann beim POST gesetzt werden** + ```python + POST /Adressen { + "standardAnschrift": true, # ← Wird akzeptiert! + ... + } + ``` + +2. **Feld wird korrekt gespeichert** + - GET zeigt `standardAnschrift: true` + - Bleibt persistent + +#### ⚠️ Was NICHT funktioniert: + +1. **Keine automatische Haupt-Adress-Logik** + - Neue Adressen werden **NICHT automatisch** zur Hauptadresse + - `standardAnschrift` ist standardmäßig `false` + - Keine "neueste Adresse = Hauptadresse" Regel + +2. **Mehrere Hauptadressen sind möglich!** + - Advoware deaktiviert **NICHT** alte Hauptadressen automatisch + - Es können **mehrere** Adressen `standardAnschrift = true` haben + - **Keine Validierung** auf "nur eine Hauptadresse" + +3. **`standardAnschrift` ist READ-ONLY bei PUT** (bereits bekannt) + - Kann nur beim POST gesetzt werden + - Kann nicht nachträglich geändert werden + +### 10.2 Konsequenzen für Sync + +**Probleme:** +``` +Szenario 1: User ändert Hauptadresse in EspoCRM +→ PUT zu Advoware → standardAnschrift bleibt unverändert (READ-ONLY) +→ Hauptadresse-Status kann nicht synchronisiert werden! + +Szenario 2: Neue Hauptadresse erstellen +→ POST mit standardAnschrift = true +→ Alte Hauptadresse bleibt AUCH true +→ Mehrere Hauptadressen existieren! + +Szenario 3: Hauptadresse in Advoware manuell geändert +→ EspoCRM weiß nicht welche die "echte" Hauptadresse ist +→ Wenn mehrere true sind +``` + +### 10.3 Empfohlene Strategie + +**Option A: EspoCRM als Master (Empfohlen)** +```python +# In EspoCRM: +- isPrimary (boolean) - Master-Feld +- advowareStandardAnschrift (boolean) - Read-Only Spiegel + +# Sync-Logik: +1. CREATE: Setze standardAnschrift = isPrimary +2. UPDATE: + - Wenn isPrimary geändert → Notification (READ-ONLY in Advoware) +3. SYNC from Advoware: + - Lese alle Adressen + - Wenn mehrere standardAnschrift = true → Warnungslog + - Update advowareStandardAnschrift (Info-Feld) + - isPrimary bleibt EspoCRM-Master +``` + +**Option B: Advoware als Master** +```python +# Sync-Logik: +1. CREATE: Verwende EspoCRM isPrimary initial +2. UPDATE: isPrimary ist READ-ONLY in EspoCRM +3. SYNC from Advoware: + - Lese standardAnschrift + - Update isPrimary in EspoCRM + - Advoware = Master + +# Problem: Mehrere Hauptadressen möglich! +→ Welche ist die "echte"? +→ Nehme erste? Letzte? Niedrigster Index? +``` + +**Option C: Manuelle Verwaltung** +```python +# Bei Hauptadresse-Änderung: +1. User ändert in EspoCRM +2. Notification erstellen +3. User ändert manuell in Advoware Web-Interface +4. Sync aktualisiert EspoCRM + +# Vorteil: Keine automatischen Konflikte +# Nachteil: Viel manuelle Arbeit +``` + +### 10.4 Implementierungs-Empfehlung + +**Hybrid-Ansatz:** + +```python +class HauptadresseSync: + """ + Hauptadresse-Sync mit Notification bei Konflikten + """ + + async def sync_hauptadresse_status(self, espo_addresses, advo_addresses): + """Sync Hauptadresse-Status von Advoware""" + + # Finde Hauptadressen in Advoware + advo_haupt = [a for a in advo_addresses if a.get('standardAnschrift')] + + if len(advo_haupt) == 0: + # Keine Hauptadresse in Advoware + logger.warning(f"Keine Hauptadresse in Advoware für BetNr {betnr}") + return + + if len(advo_haupt) > 1: + # MEHRERE Hauptadressen! + logger.error(f"MEHRERE Hauptadressen in Advoware: {len(advo_haupt)}") + + # → Notification erstellen + await notification_manager.notify_manual_action_required( + entity_type='CBeteiligte', + entity_id=espo_beteiligte_id, + action_type='general_manual_action', + details={ + 'message': f'Mehrere Hauptadressen in Advoware ({len(advo_haupt)})', + 'description': ( + f'Beteiligter hat {len(advo_haupt)} Adressen mit ' + f'standardAnschrift = true. Bitte in Advoware korrigieren, ' + f'sodass nur EINE Hauptadresse existiert.' + ), + 'priority': 'High' + } + ) + + # Nehme erste als "Haupt" (niedrigster Index) + advo_haupt = [min(advo_haupt, key=lambda a: a.get('reihenfolgeIndex', 999))] + + # Update EspoCRM + for espo_addr in espo_addresses: + espo_id = espo_addr['id'] + + # Finde korrespondierende Advoware-Adresse + bemerkung_match = f"EspoCRM-ID: {espo_id}" + advo_addr = next( + (a for a in advo_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + if advo_addr: + # Update isPrimary basierend auf Advoware + should_be_primary = advo_addr in advo_haupt + + if espo_addr.get('isPrimary') != should_be_primary: + await espocrm_api.update('CAdressen', espo_id, { + 'isPrimary': should_be_primary, + 'advowareStandardAnschrift': advo_addr.get('standardAnschrift') + }) + + logger.info( + f"Updated isPrimary: {espo_addr.get('isPrimary')} " + f"→ {should_be_primary} (EspoCRM ID: {espo_id})" + ) + + async def create_with_hauptadresse_logic(self, espo_addr, betnr): + """Erstelle Adresse mit Hauptadresse-Logik""" + + # Wenn diese Adresse Hauptadresse werden soll + if espo_addr.get('isPrimary'): + # Prüfe ob es schon eine Hauptadresse gibt + existing_addresses = await advoware_api.get(f'/Beteiligte/{betnr}/Adressen') + existing_haupt = [a for a in existing_addresses if a.get('standardAnschrift')] + + if len(existing_haupt) > 0: + # → Notification: Alte Hauptadresse manuell deaktivieren + await notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=espo_addr['id'], + action_type='general_manual_action', + details={ + 'message': 'Neue Hauptadresse erstellt - alte manuell deaktivieren', + 'description': ( + f'Neue Hauptadresse wird erstellt:\n' + f'{espo_addr["adresseStreet"]}, {espo_addr["adresseCity"]}\n\n' + f'WICHTIG: Alte Hauptadresse(n) in Advoware manuell ' + f'deaktivieren (standardAnschrift = false setzen):\n' + + '\n'.join([f"- {a.get('strasse')}" for a in existing_haupt]) + ), + 'priority': 'High' + } + ) + + # CREATE normal durchführen + await self.create_address(espo_addr, betnr) +``` + +### 10.5 EspoCRM Entity-Felder + +**Zusätzlich erforderlich:** +```javascript +{ + "isPrimary": "bool", // Master-Feld (EspoCRM managed) + "advowareStandardAnschrift": "bool" // Info-Feld (Advoware Mirror) +} +``` + +### 10.6 Zusammenfassung + +**Hauptadresse-Sync ist komplex wegen:** +1. ❌ `standardAnschrift` ist READ-ONLY bei PUT +2. ❌ Mehrere Hauptadressen in Advoware möglich +3. ❌ Keine automatische Deaktivierung alter Hauptadressen + +**Empfohlener Ansatz:** +- ✅ EspoCRM `isPrimary` als Master +- ✅ Advoware `standardAnschrift` beim POST setzen +- ✅ Notification bei Hauptadresse-Änderung (manuelle Aktion nötig) +- ✅ Sync from Advoware: Prüfe auf mehrere Hauptadressen → Warnung +- ✅ Wähle niedrigsten Index als "echte" Hauptadresse bei Konflikten + +**Test-Scripts:** +- [`scripts/test_hauptadresse_logic.py`](../scripts/test_hauptadresse_logic.py) - Hauptadresse-Logik +- [`scripts/test_hauptadresse_explizit.py`](../scripts/test_hauptadresse_explizit.py) - Explizites Setzen +- [`scripts/test_find_hauptadresse.py`](../scripts/test_find_hauptadresse.py) - Suche spezifische Adresse + +--- + +## 11. reihenfolgeIndex-Instabilität ⚠️ + +### 11.1 Test-Ergebnisse (08.02.2026) + +**Kritische Entdeckung:** +`reihenfolgeIndex` wird nach Löschungen **automatisch neu vergeben**! + +**Test:** +``` +Vorher (24 Adressen): + Index 1: Test 6667426 + Index 2: ... + Index 23: Hauptadresse Explizit Test + Index 24: Zweite Hauptadresse Test + +Benutzer löscht 23 Adressen in Advoware Web-Interface + +Nachher (1 Adresse): + Index 1: Hauptadresse Explizit Test ← War vorher Index 23! +``` + +### 11.2 Konsequenzen + +**❌ `reihenfolgeIndex` ist NICHT stabil!** + +```python +# FALSCH - Index ändert sich! +espocrm_record = { + 'advowareIndexId': 23 # ← Nach Löschung nicht mehr gültig! +} + +# RICHTIG - Nur bemerkung ist stabil +espocrm_record = { + 'bemerkung': 'EspoCRM-ID: 12345' # ← Bleibt immer gleich +} +``` + +**Implikationen:** +1. ❌ **Kann NICHT in EspoCRM gespeichert werden** als Identifier +2. ❌ **Kann NICHT für direktes Matching** genutzt werden +3. ✅ **Muss VOR jedem PUT dynamisch ermittelt werden** via: + ```python + # 1. GET alle Adressen + all_addresses = await advo.get(f'/Beteiligte/{betnr}/Adressen') + + # 2. Match via bemerkung + target = next( + (a for a in all_addresses + if f"EspoCRM-ID: {espo_id}" in (a.get('bemerkung') or '')), + None + ) + + # 3. Nutze aktuellen Index für PUT + if target: + await advo.put( + f'/Beteiligte/{betnr}/Adressen/{target["reihenfolgeIndex"]}', + data=updated_data + ) + ``` + +### 11.3 Update der Sync-Strategie + +**Korrigierte Workflow:** + +```python +class AdressenSync: + async def update_address(self, espo_addr, betnr): + """ + Update mit dynamischer Index-Ermittlung + """ + espo_id = espo_addr['id'] + + # 1. GET alle Adressen (immer!) + all_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + # 2. Match via bemerkung (EINZIGE stabile Methode) + bemerkung_match = f"EspoCRM-ID: {espo_id}" + target = next( + (a for a in all_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + if not target: + logger.error(f"Adresse nicht gefunden via bemerkung: {bemerkung_match}") + # → Erstelle neu statt Update? + return await self.create_address(espo_addr, betnr) + + # 3. Nutze AKTUELLEN reihenfolgeIndex für PUT + current_index = target['reihenfolgeIndex'] + + # 4. PUT mit aktuellem Index + updated_data = { + 'strasse': espo_addr['adresseStreet'], + 'plz': espo_addr['adressePostalCode'], + 'ort': espo_addr['adresseCity'], + 'anschrift': self.format_anschrift(espo_addr) + # Nur 4 Felder erlaubt! + } + + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{current_index}', + method='PUT', + json_data=updated_data + ) + + logger.info( + f"Updated Adresse via dynamischen Index {current_index} " + f"(Match: {bemerkung_match})" + ) + + return result +``` + +### 11.4 EspoCRM Felder - KORREKTUR + +**Alte Annahme (FALSCH):** +```javascript +{ + "advowareIndexId": "int", // ← NICHT speichern! + "advowareRowId": "varchar" +} +``` + +**Neue Empfehlung (RICHTIG):** +```javascript +{ + // KEIN advowareIndexId! (instabil) + "advowareRowId": "varchar", // Nur als Info (ändert sich bei PUT) + "bemerkung": "text" // Muss "EspoCRM-ID: {id}" enthalten! +} +``` + +### 11.5 Zusammenfassung + +**Identifier-Stabilität:** + +| Feld | Stabil? | Nutzung | +|------|---------|----------| +| `id` | ❌ Immer 0 | Unbrauchbar | +| `rowId` | ❌ Ändert sich bei PUT | Nur Change Detection | +| `reihenfolgeIndex` | ❌ Neu vergeben nach Löschung | Nur für PUT-Endpoint (dynamisch) | +| `bemerkung` | ✅ Bleibt konstant (READ-ONLY bei PUT) | **EINZIGER stabiler Identifier!** | + +**Empfohlener Workflow:** +1. **CREATE**: Setze `bemerkung = "EspoCRM-ID: {espo_id}"` +2. **UPDATE**: GET → Match via `bemerkung` → Nutze aktuellen `reihenfolgeIndex` für PUT +3. **DELETE**: Notification (kein API-DELETE) + +**Test-Scripts:** +- [`scripts/test_find_hauptadresse.py`](../scripts/test_find_hauptadresse.py) - Zeigt Index-Renummerierung nach Löschung + + +--- + +## 11. reihenfolgeIndex-Instabilität ⚠️ + +### 11.1 Test-Ergebnisse (08.02.2026) + +**Kritische Entdeckung:** +`reihenfolgeIndex` wird nach Löschungen **automatisch neu vergeben**! + +**Test:** +``` +Vorher (24 Adressen): + Index 1: Test 6667426 + Index 2: ... + Index 23: Hauptadresse Explizit Test + Index 24: Zweite Hauptadresse Test + +Benutzer löscht 23 Adressen in Advoware Web-Interface + +Nachher (1 Adresse): + Index 1: Hauptadresse Explizit Test ← War vorher Index 23! +``` + +### 11.2 Konsequenzen + +**❌ `reihenfolgeIndex` ist NICHT stabil!** + +```python +# FALSCH - Index ändert sich! +espocrm_record = { + 'advowareIndexId': 23 # ← Nach Löschung nicht mehr gültig! +} + +# RICHTIG - Nur bemerkung ist stabil +espocrm_record = { + 'bemerkung': 'EspoCRM-ID: 12345' # ← Bleibt immer gleich +} +``` + +**Implikationen:** +1. ❌ **Kann NICHT in EspoCRM gespeichert werden** als Identifier +2. ❌ **Kann NICHT für direktes Matching** genutzt werden +3. ✅ **Muss VOR jedem PUT dynamisch ermittelt werden** via: + ```python + # 1. GET alle Adressen + all_addresses = await advo.get(f'/Beteiligte/{betnr}/Adressen') + + # 2. Match via bemerkung + target = next( + (a for a in all_addresses + if f"EspoCRM-ID: {espo_id}" in (a.get('bemerkung') or '')), + None + ) + + # 3. Nutze aktuellen Index für PUT + if target: + await advo.put( + f'/Beteiligte/{betnr}/Adressen/{target["reihenfolgeIndex"]}', + data=updated_data + ) + ``` + +### 11.3 Update der Sync-Strategie + +**Korrigierter Workflow:** + +```python +class AdressenSync: + async def update_address(self, espo_addr, betnr): + """ + Update mit dynamischer Index-Ermittlung + """ + espo_id = espo_addr['id'] + + # 1. GET alle Adressen (immer!) + all_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + # 2. Match via bemerkung (EINZIGE stabile Methode) + bemerkung_match = f"EspoCRM-ID: {espo_id}" + target = next( + (a for a in all_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + if not target: + logger.error(f"Adresse nicht gefunden via bemerkung: {bemerkung_match}") + # → Erstelle neu statt Update? + return await self.create_address(espo_addr, betnr) + + # 3. Nutze AKTUELLEN reihenfolgeIndex für PUT + current_index = target['reihenfolgeIndex'] + + # 4. PUT mit aktuellem Index + updated_data = { + 'strasse': espo_addr['adresseStreet'], + 'plz': espo_addr['adressePostalCode'], + 'ort': espo_addr['adresseCity'], + 'anschrift': self.format_anschrift(espo_addr) + # Nur 4 Felder erlaubt! + } + + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{current_index}', + method='PUT', + json_data=updated_data + ) + + logger.info( + f"Updated Adresse via dynamischen Index {current_index} " + f"(Match: {bemerkung_match})" + ) + + return result +``` + +### 11.4 EspoCRM Felder - KORREKTUR + +**Alte Annahme (FALSCH):** +```javascript +{ + "advowareIndexId": "int", // ← NICHT speichern! + "advowareRowId": "varchar" +} +``` + +**Neue Empfehlung (RICHTIG):** +```javascript +{ + // KEIN advowareIndexId! (instabil) + "advowareRowId": "varchar", // Nur als Info (ändert sich bei PUT) + "bemerkung": "text" // Muss "EspoCRM-ID: {id}" enthalten! +} +``` + +### 11.5 Zusammenfassung + +**Identifier-Stabilität:** + +| Feld | Stabil? | Nutzung | +|------|---------|----------| +| `id` | ❌ Immer 0 | Unbrauchbar | +| `rowId` | ❌ Ändert sich bei PUT | Nur Change Detection | +| `reihenfolgeIndex` | ❌ Neu vergeben nach Löschung | Nur für PUT-Endpoint (dynamisch) | +| `bemerkung` | ✅ Bleibt konstant (READ-ONLY bei PUT) | **EINZIGER stabiler Identifier!** | + +**Empfohlener Workflow:** +1. **CREATE**: Setze `bemerkung = "EspoCRM-ID: {espo_id}"` +2. **UPDATE**: GET → Match via `bemerkung` → Nutze aktuellen `reihenfolgeIndex` für PUT +3. **DELETE**: Notification (kein API-DELETE) + +**Test-Scripts:** +- [`scripts/test_find_hauptadresse.py`](../scripts/test_find_hauptadresse.py) - Zeigt Index-Renummerierung nach Löschung + + +--- + +## 12. FINALE SYNC-STRATEGIE ✅ + +### 12.1 Entscheidung (08.02.2026) + +**Nach umfangreichen Tests wurde folgende finale Strategie festgelegt:** + +``` +✅ CREATE - Vollständig unterstützt (alle 11 Felder) +✅ UPDATE - Nur für R/W Felder (strasse, plz, ort, anschrift) +❌ DELETE - NICHT automatisch (nur via Notification) +⚠️ READ-ONLY Änderungen - Nur via Notification + manuelle Aktion +``` + +### 12.2 Implementierungs-Regeln + +#### 12.2.1 CREATE (EspoCRM → Advoware) + +**Vollständig automatisiert:** + +```python +async def create_address(self, espo_addr, betnr): + """ + Erstelle neue Adresse in Advoware + Alle Felder werden synchronisiert + """ + advo_data = { + # R/W Felder (via PUT änderbar) + 'strasse': espo_addr.get('adresseStreet'), + 'plz': espo_addr.get('adressePostalCode'), + 'ort': espo_addr.get('adresseCity'), + 'anschrift': self.format_anschrift(espo_addr), + + # READ-ONLY Felder (nur bei CREATE!) + 'land': espo_addr.get('adresseCountry') or 'DE', + 'postfach': espo_addr.get('postfach'), + 'postfachPLZ': espo_addr.get('postfachPLZ'), + 'standardAnschrift': espo_addr.get('isPrimary', False), + 'bemerkung': f"EspoCRM-ID: {espo_addr['id']}", # Matching! + 'gueltigVon': espo_addr.get('validFrom'), + 'gueltigBis': espo_addr.get('validUntil') + } + + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='POST', + json_data=advo_data + ) + + logger.info(f"Created address in Advoware for EspoCRM ID {espo_addr['id']}") + return result +``` + +#### 12.2.2 UPDATE (EspoCRM → Advoware) + +**Nur R/W Felder, REST über Notification:** + +```python +async def update_address(self, espo_addr, betnr): + """ + Update nur R/W Felder + Alle anderen Änderungen → Notification + """ + espo_id = espo_addr['id'] + + # 1. Finde Adresse via bemerkung + all_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + bemerkung_match = f"EspoCRM-ID: {espo_id}" + target = next( + (a for a in all_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + if not target: + logger.error(f"Address not found: {bemerkung_match}") + return await self.create_address(espo_addr, betnr) + + # 2. Update NUR R/W Felder + rw_data = { + 'strasse': espo_addr.get('adresseStreet'), + 'plz': espo_addr.get('adressePostalCode'), + 'ort': espo_addr.get('adresseCity'), + 'anschrift': self.format_anschrift(espo_addr) + } + + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{target["reihenfolgeIndex"]}', + method='PUT', + json_data=rw_data + ) + + # 3. Prüfe READ-ONLY Felder auf Änderungen + readonly_changes = self.detect_readonly_changes(espo_addr, target) + + if readonly_changes: + # → Notification für manuelle Änderung + await self.notify_readonly_changes(espo_addr, readonly_changes) + + logger.info(f"Updated address (R/W fields only) for EspoCRM ID {espo_id}") + return result + + +def detect_readonly_changes(self, espo_addr, advo_addr): + """ + Erkenne Änderungen an READ-ONLY Feldern + """ + changes = [] + + # Prüfe alle READ-ONLY Felder + readonly_mappings = { + 'adresseCountry': ('land', 'Land'), + 'postfach': ('postfach', 'Postfach'), + 'postfachPLZ': ('postfachPLZ', 'Postfach PLZ'), + 'isPrimary': ('standardAnschrift', 'Hauptadresse'), + 'validFrom': ('gueltigVon', 'Gültig von'), + 'validUntil': ('gueltigBis', 'Gültig bis') + } + + for espo_field, (advo_field, label) in readonly_mappings.items(): + espo_value = espo_addr.get(espo_field) + advo_value = advo_addr.get(advo_field) + + # Normalisiere Werte für Vergleich + if espo_field == 'isPrimary': + espo_value = bool(espo_value) + advo_value = bool(advo_value) + + if espo_value != advo_value: + changes.append({ + 'field': label, + 'espoField': espo_field, + 'advoField': advo_field, + 'espoCRM_value': espo_value, + 'advoware_value': advo_value + }) + + return changes + + +async def notify_readonly_changes(self, espo_addr, changes): + """ + Erstelle Notification für READ-ONLY Feld-Änderungen + """ + change_details = '\n'.join([ + f"- {c['field']}: EspoCRM='{c['espoCRM_value']}' → " + f"Advoware='{c['advoware_value']}'" + for c in changes + ]) + + await self.notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=espo_addr['id'], + action_type='readonly_field_conflict', + details={ + 'message': f'{len(changes)} READ-ONLY Feld(er) geändert', + 'description': ( + f'Folgende Felder wurden in EspoCRM geändert, sind aber ' + f'READ-ONLY in Advoware und können nicht automatisch ' + f'synchronisiert werden:\n\n{change_details}\n\n' + f'Bitte manuell in Advoware anpassen.' + ), + 'changes': changes, + 'address': f"{espo_addr.get('adresseStreet')}, " + f"{espo_addr.get('adresseCity')}", + 'priority': 'High' + } + ) +``` + +#### 12.2.3 DELETE (EspoCRM → Advoware) + +**NUR via Notification:** + +```python +async def handle_address_deletion(self, espo_addr, betnr): + """ + Adresse wurde in EspoCRM gelöscht + → Nur Notification (kein API-DELETE verfügbar) + """ + + # 1. Finde Adresse in Advoware + all_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + bemerkung_match = f"EspoCRM-ID: {espo_addr['id']}" + target = next( + (a for a in all_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + if not target: + logger.info(f"Address already deleted: {espo_addr['id']}") + return + + # 2. Notification erstellen + await self.notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=espo_addr['id'], + action_type='address_delete_required', + details={ + 'message': 'Adresse in Advoware löschen', + 'description': ( + f'Adresse wurde in EspoCRM gelöscht:\n' + f'{target.get("strasse")}\n' + f'{target.get("plz")} {target.get("ort")}\n\n' + f'Bitte manuell in Advoware löschen:\n' + f'1. Öffne Beteiligten {betnr} in Advoware\n' + f'2. Gehe zu Adressen-Tab\n' + f'3. Lösche Adresse (Index {target.get("reihenfolgeIndex")})\n' + f'4. Speichern' + ), + 'advowareIndex': target.get('reihenfolgeIndex'), + 'betnr': betnr, + 'address': f"{target.get('strasse')}, {target.get('ort')}", + 'priority': 'Medium' + } + ) + + logger.info(f"Created delete notification for address {espo_addr['id']}") +``` + +#### 12.2.4 SYNC (Advoware → EspoCRM) + +**Volle Synchronisation (read-only):** + +```python +async def sync_from_advoware(self, betnr, espo_beteiligte_id): + """ + Synct Adressen von Advoware zu EspoCRM + Alle Felder werden übernommen (Advoware = Master) + """ + + # 1. Hole alle Adressen von Advoware + advo_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + # 2. Hole existierende EspoCRM Adressen + espo_addresses = await self.espo.list_entities( + 'CAdressen', + where=[{ + 'type': 'equals', + 'attribute': 'beteiligteId', + 'value': espo_beteiligte_id + }] + ) + + for advo_addr in advo_addresses: + # Match via bemerkung + bemerkung = advo_addr.get('bemerkung', '') + + if 'EspoCRM-ID:' in bemerkung: + # Update existierende + espo_id = bemerkung.split('EspoCRM-ID:')[1].strip().split()[0] + await self.update_espo_address(espo_id, advo_addr) + else: + # Neue Adresse aus Advoware + await self.create_espo_address(advo_addr, espo_beteiligte_id) + + logger.info(f"Synced {len(advo_addresses)} addresses from Advoware") +``` + +### 12.3 Zusammenfassung + +**Automatische Synchronisation:** +- ✅ **CREATE**: Alle Felder (11 Felder) +- ✅ **UPDATE**: Nur R/W Felder (4 Felder: strasse, plz, ort, anschrift) +- ✅ **SYNC from Advoware**: Alle Felder (read-only) + +**Manuelle Aktionen (via Notification):** +- ⚠️ **DELETE**: Notification + manuelle Löschung in Advoware +- ⚠️ **READ-ONLY Änderungen**: Notification bei: + - `land` geändert + - `postfach`/`postfachPLZ` geändert + - `standardAnschrift` (Hauptadresse) geändert + - `gueltigVon`/`gueltigBis` geändert + - `bemerkung` geändert (würde Matching zerstören!) + +**Rationale:** +1. **Keine Datenkorruption**: Nur sichere Operationen automatisch +2. **Keine API-Limitierungen umgehen**: Respektiere READ-ONLY Status +3. **Volle Transparenz**: User sieht alle manuellen Aktionen +4. **Matching bleibt stabil**: `bemerkung` niemals ändern! + +**Test-Scripts:** +- [`scripts/test_adressen_nullen.py`](../scripts/test_adressen_nullen.py) - Zeigt dass Nullen nicht empfohlen ist + diff --git a/bitbylaw/docs/ADRESSEN_SYNC_SUMMARY.md b/bitbylaw/docs/ADRESSEN_SYNC_SUMMARY.md new file mode 100644 index 00000000..c0c134d0 --- /dev/null +++ b/bitbylaw/docs/ADRESSEN_SYNC_SUMMARY.md @@ -0,0 +1,254 @@ +# Adressen-Sync: Zusammenfassung & Implementierungsplan + +**Datum**: 8. Februar 2026 +**Status**: ✅ Analyse abgeschlossen, bereit für Implementierung + +--- + +## 📋 Executive Summary + +### ✅ Was funktioniert: +- **CREATE** (POST): Alle Felder können gesetzt werden +- **UPDATE** (PUT): 4 Haupt-Adressfelder (`strasse`, `plz`, `ort`, `anschrift`) +- **MATCHING**: Via `bemerkung`-Feld mit EspoCRM-ID (stabil, READ-ONLY) +- **SYNC from Advoware**: Vollständig möglich + +### ❌ Was nicht funktioniert: +- **DELETE**: 403 Forbidden (nicht verfügbar) +- **Soft-Delete**: `gueltigBis` ist READ-ONLY (kann nicht nachträglich gesetzt werden) +- **8 Felder READ-ONLY bei PUT**: `land`, `postfach`, `postfachPLZ`, `standardAnschrift`, `bemerkung`, `gueltigVon`, `gueltigBis`, `reihenfolgeIndex` + +### 💡 Lösung: Hybrid-Ansatz +**Automatischer Sync + Notification-System für manuelle Eingriffe** + +--- + +## 🏗️ Implementierte Komponenten + +### 1. Notification-System ✅ +**Datei**: [`services/notification_utils.py`](../services/notification_utils.py) + +**Features:** +- Zentrale `NotificationManager` Klasse +- Task-Erstellung in EspoCRM mit Schritt-für-Schritt Anleitung +- In-App Notifications an assigned Users +- 6 vordefinierte Action-Types: + - `address_delete_required` - DELETE manuell nötig + - `address_reactivate_required` - Neue Adresse erstellen + - `address_field_update_required` - READ-ONLY Felder ändern + - `readonly_field_conflict` - Sync-Konflikt + - `missing_in_advoware` - Element fehlt + - `general_manual_action` - Allgemein + +**Verwendung:** +```python +from services.notification_utils import NotificationManager + +notif_mgr = NotificationManager(espocrm_api, context) + +# DELETE erforderlich +await notif_mgr.notify_manual_action_required( + entity_type='CAdressen', + entity_id='65abc123', + action_type='address_delete_required', + details={ + 'betnr': '104860', + 'strasse': 'Teststraße 123', + 'plz': '30159', + 'ort': 'Hannover' + } +) +# → Erstellt Task + Notification mit detaillierter Anleitung +``` + +### 2. Umfassende Test-Suite ✅ +**Test-Scripts** (alle in [`scripts/`](../scripts/)): + +1. **`test_adressen_api.py`** - Haupttest (7 Tests) + - POST/PUT mit allen Feldern + - Feld-für-Feld Verifikation + - Response-Analyse + +2. **`test_adressen_delete_matching.py`** - DELETE + Matching + - DELETE-Funktionalität (→ 403) + - `bemerkung`-basiertes Matching + - Stabilität von `bemerkung` bei PUT + +3. **`test_adressen_deactivate_ordering.py`** - Deaktivierung + - `gueltigBis` nachträglich setzen (→ READ-ONLY) + - `reihenfolgeIndex` Verhalten + - Automatisches Ans-Ende-Reihen + +4. **`test_adressen_gueltigbis_modify.py`** - Soft-Delete + - `gueltigBis` ändern (→ nicht möglich) + - Verschiedene Methoden getestet + +5. **`test_put_response_detail.py`** - PUT-Analyse + - Welche Felder werden wirklich geändert + - Response vs. GET Vergleich + +### 3. Dokumentation ✅ +**Datei**: [`docs/ADRESSEN_SYNC_ANALYSE.md`](ADRESSEN_SYNC_ANALYSE.md) + +**Inhalte:** +- Swagger API-Dokumentation +- EspoCRM Entity-Struktur +- Detaillierte Test-Ergebnisse +- Sync-Strategien (3 Optionen evaluiert) +- Finale Empfehlung: Hybrid-Ansatz +- Feld-Mappings +- Risiko-Analyse +- Implementierungsplan + +--- + +## 🔑 Kritische Erkenntnisse + +### ID-Mapping +``` +❌ id = 0 → Immer 0, unbrauchbar +✅ bemerkung → Stabil (READ-ONLY), perfekt für Matching +✅ reihenfolgeIndex → Stabil, automatisch vergeben, für PUT-Endpoint +❌ rowId → Ändert sich bei PUT, nicht für Matching! +``` + +### PUT-Feldübersicht +| Feld | POST | PUT | Matching | +|------|------|-----|----------| +| `strasse` | ✅ | ✅ | - | +| `plz` | ✅ | ✅ | - | +| `ort` | ✅ | ✅ | - | +| `land` | ✅ | ❌ READ-ONLY | - | +| `postfach` | ✅ | ❌ READ-ONLY | - | +| `postfachPLZ` | ✅ | ❌ READ-ONLY | - | +| `anschrift` | ✅ | ✅ | - | +| `standardAnschrift` | ✅ | ❌ READ-ONLY | - | +| `bemerkung` | ✅ | ❌ READ-ONLY | ✅ Perfekt! | +| `gueltigVon` | ✅ | ❌ READ-ONLY | - | +| `gueltigBis` | ✅ | ❌ READ-ONLY | - | +| `reihenfolgeIndex` | - | ❌ System | ✅ Für PUT | + +--- + +## 🚀 Nächste Schritte + +### Phase 1: Validierung ⏳ +- [ ] EspoCRM CAdressen Entity prüfen + - [ ] Felder vorhanden: `advowareIndexId`, `advowareRowId`, `syncStatus`, `isActive`, `manualActionNote` + - [ ] Relation zu CBeteiligte korrekt +- [ ] Notification-System testen + - [ ] Task-Erstellung funktioniert + - [ ] Assigned Users werden benachrichtigt + +### Phase 2: Mapper ⏳ +- [ ] `services/adressen_mapper.py` erstellen + ```python + class AdressenMapper: + def map_espocrm_to_advoware(espo_addr) -> dict + def map_advoware_to_espocrm(advo_addr) -> dict + def find_by_bemerkung(addresses, espo_id) -> dict + def detect_readonly_changes(espo, advo) -> dict + ``` + +### Phase 3: Sync-Service ⏳ +- [ ] `services/adressen_sync.py` erstellen + ```python + class AdressenSyncService: + async def create_address(espo_addr) + async def update_address(espo_addr) + async def delete_address(espo_addr) # → Notification + async def sync_from_advoware(betnr, espo_beteiligte_id) + ``` + +### Phase 4: Integration ⏳ +- [ ] In bestehenden Beteiligte-Sync integrieren oder +- [ ] Eigener Adressen-Sync Step + +### Phase 5: Testing ⏳ +- [ ] Unit Tests für Mapper +- [ ] Integration Tests mit Test-Daten +- [ ] End-to-End Test: CREATE → UPDATE → DELETE +- [ ] Notification-Flow testen + +### Phase 6: Deployment ⏳ +- [ ] Staging-Test mit echten Daten +- [ ] User-Schulung: Manuelle Eingriffe +- [ ] Monitoring einrichten +- [ ] Production Rollout + +--- + +## 📝 Wichtige Hinweise für Entwickler + +### Matching-Strategie +**IMMER via `bemerkung`-Feld:** +```python +# Beim CREATE: +bemerkung = f"EspoCRM-ID: {espocrm_address_id}" + +# Beim Sync: +espocrm_id = parse_espocrm_id_from_bemerkung(advo_addr['bemerkung']) +# Robust gegen User-Änderungen: +import re +match = re.search(r'EspoCRM-ID:\s*([a-f0-9-]+)', bemerkung) +espocrm_id = match.group(1) if match else None +``` + +### Notification Trigger +**Immer Notifications erstellen bei:** +- DELETE-Request (API nicht verfügbar) +- PUT mit READ-ONLY Feldern (land, postfach, etc.) +- Reaktivierung (neue Adresse erstellen) +- Adresse direkt in Advoware erstellt (fehlende bemerkung) + +### Sync-Richtung +- **EspoCRM → Advoware**: Für CREATE/UPDATE +- **Advoware → EspoCRM**: Master für "Existenz" +- **Konflikt-Resolution**: Siehe Dokumentation + +### Aktuelle Adresse-Matching +**Wichtig**: Die "aktuelle" Adresse muss in beiden Systemen gleich sein! + +**Strategie:** +```python +# In Advoware: standardAnschrift = true (READ-ONLY!) +# In EspoCRM: isPrimary = true (eigenes Feld) + +# Sync-Logik: +if espo_addr['isPrimary']: + # Prüfe ob Advoware-Adresse standardAnschrift = true hat + if not advo_addr['standardAnschrift']: + # → Notification: Hauptadresse manuell in Advoware setzen + await notify_main_address_mismatch(...) +``` + +--- + +## 📊 Metriken & Monitoring + +**Zu überwachende KPIs:** +- Anzahl erstellter Notifications pro Tag +- Durchschnittliche Zeit bis Task-Completion +- Anzahl gescheiterter Syncs +- READ-ONLY Feld-Konflikte (Häufigkeit) +- DELETE-Requests (manuell nötig) + +**Alerts einrichten für:** +- Mehr als 5 unerledigte DELETE-Tasks pro User +- Sync-Fehlerrate > 10% +- Tasks älter als 7 Tage + +--- + +## 🔗 Referenzen + +- **Hauptdokumentation**: [`docs/ADRESSEN_SYNC_ANALYSE.md`](ADRESSEN_SYNC_ANALYSE.md) +- **Notification-Utility**: [`services/notification_utils.py`](../services/notification_utils.py) +- **Test-Scripts**: [`scripts/test_adressen_*.py`](../scripts/) +- **Swagger-Doku**: Advoware API v1 - Adressen Endpoints + +--- + +**Erstellt**: 8. Februar 2026 +**Autor**: GitHub Copilot +**Review**: Pending diff --git a/bitbylaw/scripts/test_adressen_api.py b/bitbylaw/scripts/test_adressen_api.py new file mode 100644 index 00000000..bd1714bd --- /dev/null +++ b/bitbylaw/scripts/test_adressen_api.py @@ -0,0 +1,696 @@ +""" +Advoware Adressen-API Tester + +Testet die Advoware Adressen-API umfassend, um herauszufinden: +1. Welche IDs für Mapping nutzbar sind +2. Welche Felder wirklich beschreibbar/änderbar sind +3. Wie sich die API bei mehreren Adressen verhält + +Basierend auf Erfahrungen mit Beteiligte-API, wo nur 8 von vielen Feldern funktionierten. + +Usage: + python scripts/test_adressen_api.py +""" + +import asyncio +import sys +import json +from datetime import datetime +from typing import Dict, Any, List + +sys.path.insert(0, '/opt/motia-app/bitbylaw') +from services.advoware import AdvowareAPI + +# Test-Konfiguration +TEST_BETNR = 104860 # Beteiligten-Nr für Tests + +# ANSI Color Codes +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +CYAN = '\033[96m' +RESET = '\033[0m' +BOLD = '\033[1m' + + +class SimpleContext: + """Mock context for logging""" + class Logger: + def info(self, msg): print(f"[INFO] {msg}") + def error(self, msg): print(f"[ERROR] {msg}") + def debug(self, msg): print(f"[DEBUG] {msg}") + def warning(self, msg): print(f"[WARNING] {msg}") + + def __init__(self): + self.logger = self.Logger() + + +def print_header(title: str): + """Print formatted section header""" + print(f"\n{'='*80}") + print(f"{BOLD}{CYAN}{title}{RESET}") + print(f"{'='*80}\n") + + +def print_success(msg: str): + """Print success message""" + print(f"{GREEN}✓ {msg}{RESET}") + + +def print_error(msg: str): + """Print error message""" + print(f"{RED}✗ {msg}{RESET}") + + +def print_warning(msg: str): + """Print warning message""" + print(f"{YELLOW}⚠ {msg}{RESET}") + + +def print_info(msg: str): + """Print info message""" + print(f"{BLUE}ℹ {msg}{RESET}") + + +async def test_1_get_existing_addresses(): + """Test 1: Hole bestehende Adressen und analysiere Struktur""" + print_header("TEST 1: GET Adressen - Struktur analysieren") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + endpoint = f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen' + print_info(f"GET {endpoint}") + + addresses = await advo.api_call(endpoint, method='GET') + + if not addresses: + print_warning("Keine Adressen gefunden - wird in Test 2 erstellen") + return [] + + print_success(f"Erfolgreich {len(addresses)} Adressen abgerufen") + + # Analysiere Struktur + print(f"\n{BOLD}Anzahl Adressen:{RESET} {len(addresses)}") + + for i, addr in enumerate(addresses, 1): + print(f"\n{BOLD}--- Adresse {i} ---{RESET}") + print(f" id: {addr.get('id')}") + print(f" beteiligterId: {addr.get('beteiligterId')}") + print(f" reihenfolgeIndex: {addr.get('reihenfolgeIndex')}") + print(f" rowId: {addr.get('rowId')}") + print(f" strasse: {addr.get('strasse')}") + print(f" plz: {addr.get('plz')}") + print(f" ort: {addr.get('ort')}") + print(f" land: {addr.get('land')}") + print(f" postfach: {addr.get('postfach')}") + print(f" postfachPLZ: {addr.get('postfachPLZ')}") + print(f" anschrift: {addr.get('anschrift')}") + print(f" standardAnschrift: {addr.get('standardAnschrift')}") + print(f" bemerkung: {addr.get('bemerkung')}") + print(f" gueltigVon: {addr.get('gueltigVon')}") + print(f" gueltigBis: {addr.get('gueltigBis')}") + + # ID-Analyse + print(f"\n{BOLD}ID-Analyse für Mapping:{RESET}") + print(f" - 'id' vorhanden: {all('id' in a for a in addresses)}") + print(f" - 'id' Typ: {type(addresses[0].get('id')) if addresses else 'N/A'}") + print(f" - 'id' eindeutig: {len(set(a.get('id') for a in addresses)) == len(addresses)}") + print(f" - 'rowId' vorhanden: {all('rowId' in a for a in addresses)}") + print(f" - 'rowId' eindeutig: {len(set(a.get('rowId') for a in addresses)) == len(addresses)}") + + print_success("✓ ID-Felder 'id' und 'rowId' sind nutzbar für Mapping") + + return addresses + + except Exception as e: + print_error(f"GET fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + return [] + + +async def test_2_create_test_address(): + """Test 2: Erstelle Test-Adresse mit allen Feldern""" + print_header("TEST 2: POST - Neue Adresse erstellen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Vollständige Test-Daten mit allen Feldern + test_address = { + 'strasse': 'Teststraße 123', + 'plz': '30159', + 'ort': 'Hannover', + 'land': 'DE', + 'postfach': 'PF 10 20 30', + 'postfachPLZ': '30001', + 'anschrift': 'Teststraße 123\n30159 Hannover\nDeutschland', + 'standardAnschrift': False, + 'bemerkung': f'TEST-Adresse erstellt am {datetime.now().isoformat()}', + 'gueltigVon': '2026-02-08T00:00:00', + 'gueltigBis': '2027-12-31T23:59:59' + } + + print_info("Erstelle Adresse mit allen Feldern:") + print(json.dumps(test_address, indent=2, ensure_ascii=False)) + + try: + endpoint = f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen' + print_info(f"\nPOST {endpoint}") + + result = await advo.api_call(endpoint, method='POST', json_data=test_address) + + print_success("POST erfolgreich!") + print(f"\n{BOLD}Response:{RESET}") + + # Advoware gibt Array zurück + if isinstance(result, list): + print_info(f"Response ist Array mit {len(result)} Elementen") + if result: + created_addr = result[0] + print(json.dumps(created_addr, indent=2, ensure_ascii=False)) + return created_addr + else: + print(json.dumps(result, indent=2, ensure_ascii=False)) + return result + + except Exception as e: + print_error(f"POST fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_3_verify_created_fields(created_addr: Dict): + """Test 3: Vergleiche gesendete vs. zurückgegebene Daten""" + print_header("TEST 3: Feld-Verifikation - Was wurde wirklich gespeichert?") + + if not created_addr: + print_error("Keine Adresse zum Verifizieren") + return + + # Erwartete vs. tatsächliche Werte + expected = { + 'strasse': 'Teststraße 123', + 'plz': '30159', + 'ort': 'Hannover', + 'land': 'DE', + 'postfach': 'PF 10 20 30', + 'postfachPLZ': '30001', + 'anschrift': 'Teststraße 123\n30159 Hannover\nDeutschland', + 'standardAnschrift': False, + 'bemerkung': 'TEST-Adresse', # Partial match + 'gueltigVon': '2026-02-08', # Nur Datum-Teil + 'gueltigBis': '2027-12-31' + } + + working_fields = [] + broken_fields = [] + + print(f"\n{BOLD}Feld-für-Feld-Vergleich:{RESET}\n") + + for field, expected_val in expected.items(): + actual_val = created_addr.get(field) + + # Vergleich + if field in ['bemerkung']: + # Partial match für Felder mit Timestamps + matches = expected_val in str(actual_val) if actual_val else False + elif field in ['gueltigVon', 'gueltigBis']: + # Datum-Vergleich (nur YYYY-MM-DD Teil) + actual_date = str(actual_val).split('T')[0] if actual_val else None + matches = actual_date == expected_val + else: + matches = actual_val == expected_val + + if matches: + print_success(f"{field:20} : {actual_val}") + working_fields.append(field) + else: + print_error(f"{field:20} : Expected '{expected_val}', Got '{actual_val}'") + broken_fields.append(field) + + # Zusätzliche Felder prüfen + print(f"\n{BOLD}Zusätzliche Felder:{RESET}") + extra_fields = ['id', 'beteiligterId', 'reihenfolgeIndex', 'rowId'] + for field in extra_fields: + val = created_addr.get(field) + if val is not None: + print_success(f"{field:20} : {val}") + + # Zusammenfassung + print(f"\n{BOLD}{'='*60}{RESET}") + print(f"{GREEN}✓ Funktionierende Felder ({len(working_fields)}):{RESET}") + for field in working_fields: + print(f" - {field}") + + if broken_fields: + print(f"\n{RED}✗ Nicht funktionierende Felder ({len(broken_fields)}):{RESET}") + for field in broken_fields: + print(f" - {field}") + + return created_addr + + +async def test_4_update_address_full(row_id: str): + """Test 4: Update mit allen Feldern (Read-Modify-Write Pattern)""" + print_header("TEST 4: PUT - Adresse mit allen Feldern ändern") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # 1. Lese aktuelle Adresse + print_info("Schritt 1: Lese aktuelle Adresse...") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Finde via rowId + current_addr = next((a for a in all_addresses if a.get('rowId') == row_id), None) + if not current_addr: + print_error(f"Adresse mit rowId {row_id} nicht gefunden") + return None + + addr_id = current_addr.get('reihenfolgeIndex') + print_success(f"Aktuelle Adresse geladen: {current_addr.get('strasse')} (Index: {addr_id})") + + # 2. Ändere ALLE Felder + print_info("\nSchritt 2: Ändere alle Felder...") + modified_addr = { + 'strasse': 'GEÄNDERT Neue Straße 999', + 'plz': '10115', + 'ort': 'Berlin', + 'land': 'DE', + 'postfach': 'PF 99 88 77', + 'postfachPLZ': '10001', + 'anschrift': 'GEÄNDERT Neue Straße 999\n10115 Berlin\nDeutschland', + 'standardAnschrift': True, # Toggle + 'bemerkung': f'GEÄNDERT am {datetime.now().isoformat()}', + 'gueltigVon': '2026-03-01T00:00:00', + 'gueltigBis': '2028-12-31T23:59:59' + } + + print(json.dumps(modified_addr, indent=2, ensure_ascii=False)) + + # 3. Update + print_info(f"\nSchritt 3: PUT zu Advoware (Index: {addr_id})...") + endpoint = f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{addr_id}' + result = await advo.api_call(endpoint, method='PUT', json_data=modified_addr) + + print_success("PUT erfolgreich!") + print(f"\n{BOLD}Response:{RESET}") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + return result + + except Exception as e: + print_error(f"PUT fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_5_verify_update(row_id: str): + """Test 5: Hole Adresse erneut und prüfe was wirklich geändert wurde""" + print_header("TEST 5: Update-Verifikation - Was wurde wirklich geändert?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Finde via rowId + updated_addr = next((a for a in all_addresses if a.get('rowId') == row_id), None) + if not updated_addr: + print_error(f"Adresse mit rowId {row_id} nicht gefunden") + return None + + print_success("Adresse neu geladen") + + # Erwartete geänderte Werte + expected_changes = { + 'strasse': 'GEÄNDERT Neue Straße 999', + 'plz': '10115', + 'ort': 'Berlin', + 'land': 'DE', + 'postfach': 'PF 99 88 77', + 'postfachPLZ': '10001', + 'standardAnschrift': True, + 'bemerkung': 'GEÄNDERT am', + 'gueltigVon': '2026-03-01', + 'gueltigBis': '2028-12-31' + } + + updatable_fields = [] + readonly_fields = [] + + print(f"\n{BOLD}Änderungs-Verifikation:{RESET}\n") + + for field, expected_val in expected_changes.items(): + actual_val = updated_addr.get(field) + + # Vergleich + if field == 'bemerkung': + changed = expected_val in str(actual_val) if actual_val else False + elif field in ['gueltigVon', 'gueltigBis']: + actual_date = str(actual_val).split('T')[0] if actual_val else None + changed = actual_date == expected_val + else: + changed = actual_val == expected_val + + if changed: + print_success(f"{field:20} : ✓ GEÄNDERT → {actual_val}") + updatable_fields.append(field) + else: + print_error(f"{field:20} : ✗ NICHT GEÄNDERT (ist: {actual_val})") + readonly_fields.append(field) + + # Zusammenfassung + print(f"\n{BOLD}{'='*60}{RESET}") + print(f"{GREEN}✓ Änderbare Felder ({len(updatable_fields)}):{RESET}") + for field in updatable_fields: + print(f" - {field}") + + if readonly_fields: + print(f"\n{RED}✗ Nicht änderbare Felder ({len(readonly_fields)}):{RESET}") + for field in readonly_fields: + print(f" - {field}") + + return updated_addr + + except Exception as e: + print_error(f"Verifikation fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_6_multiple_addresses_behavior(): + """Test 6: Verhalten bei mehreren Adressen""" + print_header("TEST 6: Mehrere Adressen - Verhalten testen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole alle Adressen + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Aktuelle Anzahl Adressen: {len(all_addresses)}") + + # Erstelle 2. Test-Adresse + print_info("\nErstelle 2. Test-Adresse...") + test_addr_2 = { + 'strasse': 'Zweite Straße 456', + 'plz': '20095', + 'ort': 'Hamburg', + 'land': 'DE', + 'standardAnschrift': False, + 'bemerkung': 'TEST-Adresse 2' + } + + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=test_addr_2 + ) + + if isinstance(result, list) and result: + addr_2 = result[0] + print_success(f"2. Adresse erstellt: ID {addr_2.get('id')}") + + # Hole erneut alle Adressen + all_addresses_after = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_success(f"Neue Anzahl Adressen: {len(all_addresses_after)}") + + # Analysiere reihenfolgeIndex + print(f"\n{BOLD}Reihenfolge-Analyse:{RESET}") + for addr in all_addresses_after: + print(f" ID {addr.get('id'):5} | Index: {addr.get('reihenfolgeIndex'):3} | " + f"Standard: {addr.get('standardAnschrift')} | {addr.get('ort')}") + + # Prüfe standardAnschrift Logik + standard_addrs = [a for a in all_addresses_after if a.get('standardAnschrift')] + print(f"\n{BOLD}standardAnschrift-Logik:{RESET}") + if len(standard_addrs) == 0: + print_warning("Keine Adresse als Standard markiert") + elif len(standard_addrs) == 1: + print_success(f"Genau 1 Standard-Adresse (ID: {standard_addrs[0].get('id')})") + else: + print_error(f"MEHRERE Standard-Adressen: {len(standard_addrs)}") + + return all_addresses_after + + except Exception as e: + print_error(f"Test fehlgeschlagen: {e}") + import traceback + traceback.print_exc() + return [] + + +async def test_7_field_by_field_update(row_id: str): + """Test 7: Teste jedes Feld einzeln (einzelne Updates)""" + print_header("TEST 7: Feld-für-Feld Update-Test") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Hole Index für PUT + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + test_addr = next((a for a in all_addresses if a.get('rowId') == row_id), None) + if not test_addr: + print_error("Test-Adresse nicht gefunden") + return {} + + addr_index = test_addr.get('reihenfolgeIndex') + print_info(f"Verwende Adresse mit Index: {addr_index}") + + # Test-Felder mit Werten + test_fields = { + 'strasse': 'Einzeltest Straße', + 'plz': '80331', + 'ort': 'München', + 'land': 'AT', + 'postfach': 'PF 11 22', + 'postfachPLZ': '80001', + 'anschrift': 'Formatierte Anschrift\nTest', + 'standardAnschrift': True, + 'bemerkung': 'Einzelfeld-Test', + 'gueltigVon': '2026-04-01T00:00:00', + 'gueltigBis': '2026-12-31T23:59:59' + } + + results = {} + + for field_name, test_value in test_fields.items(): + print(f"\n{BOLD}Test Feld: {field_name}{RESET}") + print_info(f"Setze auf: {test_value}") + + try: + # 1. Lese aktuelle Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + current = next((a for a in all_addresses if a.get('rowId') == row_id), None) + + if not current: + print_error(f"Adresse nicht gefunden") + results[field_name] = 'FAILED' + continue + + # 2. Update nur dieses eine Feld + update_data = { + 'strasse': current.get('strasse'), + 'plz': current.get('plz'), + 'ort': current.get('ort'), + 'land': current.get('land'), + 'standardAnschrift': current.get('standardAnschrift', False) + } + update_data[field_name] = test_value + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{addr_index}', + method='PUT', + json_data=update_data + ) + + # 3. Verifiziere + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + updated = next((a for a in all_addresses if a.get('rowId') == row_id), None) + + actual_value = updated.get(field_name) + + # Vergleich (mit Toleranz für Datumsfelder) + if field_name in ['gueltigVon', 'gueltigBis']: + expected_date = test_value.split('T')[0] + actual_date = str(actual_value).split('T')[0] if actual_value else None + success = actual_date == expected_date + else: + success = actual_value == test_value + + if success: + print_success(f"✓ FUNKTIONIERT: {actual_value}") + results[field_name] = 'WORKING' + else: + print_error(f"✗ FUNKTIONIERT NICHT: Expected '{test_value}', Got '{actual_value}'") + results[field_name] = 'BROKEN' + + except Exception as e: + print_error(f"Fehler: {e}") + results[field_name] = 'ERROR' + + await asyncio.sleep(0.5) # Rate limiting + + # Zusammenfassung + print(f"\n{BOLD}{'='*60}{RESET}") + print(f"{BOLD}FINAL RESULTS - Feld-für-Feld Test:{RESET}\n") + + working = [f for f, r in results.items() if r == 'WORKING'] + broken = [f for f, r in results.items() if r == 'BROKEN'] + errors = [f for f, r in results.items() if r == 'ERROR'] + + print(f"{GREEN}✓ WORKING ({len(working)}):{RESET}") + for f in working: + print(f" - {f}") + + if broken: + print(f"\n{RED}✗ BROKEN ({len(broken)}):{RESET}") + for f in broken: + print(f" - {f}") + + if errors: + print(f"\n{YELLOW}⚠ ERRORS ({len(errors)}):{RESET}") + for f in errors: + print(f" - {f}") + + return results + + +async def main(): + """Haupt-Test-Ablauf""" + print(f"\n{BOLD}{CYAN}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}{CYAN}║ ADVOWARE ADRESSEN-API - UMFASSENDER FUNKTIONS-TEST ║{RESET}") + print(f"{BOLD}{CYAN}╚══════════════════════════════════════════════════════════════╝{RESET}") + print(f"\n{BOLD}Test-Konfiguration:{RESET}") + print(f" BetNr: {TEST_BETNR}") + print(f" Datum: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # Test 1: GET existing + existing_addresses = await test_1_get_existing_addresses() + + # Test 2: POST new + created_addr = await test_2_create_test_address() + + if not created_addr: + print_error("\nTest abgebrochen: Konnte keine Adresse erstellen") + return + + row_id = created_addr.get('rowId') + initial_id = created_addr.get('id') + + if not row_id: + print_error("\nTest abgebrochen: Keine rowId zurückgegeben") + return + + print_warning(f"\n⚠️ KRITISCH: POST gibt id={initial_id} zurück") + print_info(f"rowId: {row_id}") + + # Hole Adressen erneut, um echte ID zu finden + print_info("\nHole Adressen erneut, um zu prüfen...") + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Finde via rowId + found_addr = next((a for a in all_addresses if a.get('rowId') == row_id), None) + if found_addr: + actual_id = found_addr.get('id') + actual_index = found_addr.get('reihenfolgeIndex') + print_success(f"✓ Adresse via rowId gefunden:") + print(f" - id: {actual_id}") + print(f" - reihenfolgeIndex: {actual_index}") + print(f" - rowId: {row_id}") + + # KRITISCHE ERKENNTNIS + if actual_id == 0: + print_error("\n❌ KRITISCH: 'id' ist immer 0 - NICHT NUTZBAR für Mapping!") + print_success(f"✓ Nur 'rowId' ist eindeutig → MUSS für Mapping verwendet werden") + print_warning(f"⚠️ 'reihenfolgeIndex' könnte als Alternative dienen: {actual_index}") + + # Verwende reihenfolgeIndex als "ID" + addr_id = actual_index + print_info(f"\n>>> Verwende reihenfolgeIndex={addr_id} für weitere Tests") + else: + addr_id = actual_id + print_info(f"\n>>> Test-Adressen-ID: {addr_id}") + else: + print_error("Konnte Adresse nicht via rowId finden") + return + except Exception as e: + print_error(f"Fehler beim Abrufen: {e}") + import traceback + traceback.print_exc() + return + + # Test 3: Verify created fields + await test_3_verify_created_fields(created_addr) + + # Test 4: Update full + await test_4_update_address_full(row_id) + + # Test 5: Verify update + await test_5_verify_update(row_id) + + # Test 6: Multiple addresses + await test_6_multiple_addresses_behavior() + + # Test 7: Field-by-field (most important!) + await test_7_field_by_field_update(row_id) + + # Final Summary + print(f"\n{BOLD}{CYAN}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}{CYAN}║ TEST ABGESCHLOSSEN ║{RESET}") + print(f"{BOLD}{CYAN}╚══════════════════════════════════════════════════════════════╝{RESET}") + + print(f"\n{BOLD}Wichtigste Erkenntnisse:{RESET}") + print(f" - Test-Adresse rowId: {row_id}") + print(f" - ❌ KRITISCH: 'id' ist immer 0 - nicht nutzbar!") + print(f" - ✓ 'rowId' ist eindeutig → MUSS für Mapping verwendet werden") + print(f" - Siehe Feld-für-Feld Ergebnisse oben") + print(f" - Dokumentation wird in ADRESSEN_SYNC_ANALYSE.md aktualisiert") + + print(f"\n{YELLOW}⚠️ ACHTUNG:{RESET} Test-Adressen wurden in Advoware erstellt!") + print(f" Diese sollten manuell gelöscht oder via Support entfernt werden.") + print(f" Test-Adressen enthalten 'TEST' oder 'GEÄNDERT' im Text.\n") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_adressen_deactivate_ordering.py b/bitbylaw/scripts/test_adressen_deactivate_ordering.py new file mode 100644 index 00000000..73353dc2 --- /dev/null +++ b/bitbylaw/scripts/test_adressen_deactivate_ordering.py @@ -0,0 +1,466 @@ +#!/usr/bin/env python3 +""" +Test: Deaktivierung via gueltigBis + reihenfolgeIndex-Verhalten +================================================================ + +Ziele: +1. Teste ob abgelaufene Adressen (gueltigBis < heute) ausgeblendet werden +2. Teste ob man reihenfolgeIndex beim POST setzen kann +3. Teste ob neue Adressen automatisch ans Ende rutschen +4. Teste ob man reihenfolgeIndex via PUT ändern kann (Sortierung) +""" + +import asyncio +import sys +import os +from datetime import datetime, timedelta + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +# Test-Konfiguration +TEST_BETNR = 104860 + +# ANSI Color codes +BOLD = '\033[1m' +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_header(text): + print(f"\n{BOLD}{'='*80}{RESET}") + print(f"{BOLD}{text}{RESET}") + print(f"{BOLD}{'='*80}{RESET}\n") + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_warning(text): + print(f"{YELLOW}⚠ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + + +class SimpleLogger: + """Minimal logger für AdvowareAPI""" + def info(self, msg): pass + def error(self, msg): print_error(msg) + def debug(self, msg): pass + def warning(self, msg): pass + +class SimpleContext: + """Minimal context für AdvowareAPI""" + def __init__(self): + self.logger = SimpleLogger() + def log_info(self, msg): pass + def log_error(self, msg): print_error(msg) + def log_debug(self, msg): pass + + +async def test_1_create_expired_address(): + """Test 1: Erstelle Adresse mit gueltigBis in der Vergangenheit""" + print_header("TEST 1: Adresse mit gueltigBis in Vergangenheit (abgelaufen)") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Datum in der Vergangenheit + expired_date = "2023-12-31T23:59:59" + + address_data = { + "strasse": "Abgelaufene Straße 99", + "plz": "99999", + "ort": "Vergangenheit", + "land": "DE", + "bemerkung": "TEST-ABGELAUFEN: Diese Adresse ist seit 2023 ungültig", + "gueltigVon": "2020-01-01T00:00:00", + "gueltigBis": expired_date # ← In der Vergangenheit! + } + + print_info(f"Erstelle Adresse mit gueltigBis: {expired_date} (vor 2+ Jahren)") + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + if result and len(result) > 0: + addr = result[0] + print_success(f"✓ Adresse erstellt: rowId={addr.get('rowId')}") + print_info(f" gueltigBis: {addr.get('gueltigBis')}") + print_info(f" reihenfolgeIndex: {addr.get('reihenfolgeIndex')}") + return addr.get('bemerkung') + else: + print_error("POST lieferte keine Response") + return None + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_2_check_if_expired_address_visible(): + """Test 2: Prüfe ob abgelaufene Adresse in GET sichtbar ist""" + print_header("TEST 2: Ist abgelaufene Adresse in GET sichtbar?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Gesamtanzahl Adressen: {len(all_addresses)}") + + # Suche abgelaufene Adresse + expired_found = None + active_count = 0 + expired_count = 0 + + today = datetime.now() + + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + gueltig_bis = addr.get('gueltigBis') + + if 'TEST-ABGELAUFEN' in bemerkung: + expired_found = addr + print_success(f"\n✓ Abgelaufene Test-Adresse gefunden!") + print_info(f" Index: {addr.get('reihenfolgeIndex')}") + print_info(f" gueltigBis: {gueltig_bis}") + print_info(f" Straße: {addr.get('strasse')}") + + # Zähle aktive vs. abgelaufene + if gueltig_bis: + try: + bis_date = datetime.fromisoformat(gueltig_bis.replace('Z', '+00:00')) + if bis_date < today: + expired_count += 1 + else: + active_count += 1 + except: + pass + + print(f"\n{BOLD}Statistik:{RESET}") + print(f" Aktive Adressen (gueltigBis > heute): {active_count}") + print(f" Abgelaufene Adressen (gueltigBis < heute): {expired_count}") + print(f" Ohne gueltigBis: {len(all_addresses) - active_count - expired_count}") + + if expired_found: + print_error("\n❌ WICHTIG: Abgelaufene Adressen werden NICHT gefiltert!") + print_warning("⚠ GET /Adressen zeigt ALLE Adressen, auch abgelaufene") + print_info("💡 Filtern nach gueltigBis muss CLIENT-seitig erfolgen") + return True + else: + print_success("\n✓ Abgelaufene Adresse nicht sichtbar (wird gefiltert)") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_3_create_with_explicit_reihenfolgeIndex(): + """Test 3: Versuche reihenfolgeIndex beim POST zu setzen""" + print_header("TEST 3: Kann man reihenfolgeIndex beim POST setzen?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Versuche mit explizitem Index + address_data = { + "reihenfolgeIndex": 999, # ← Versuche expliziten Index + "strasse": "Test Index 999", + "plz": "88888", + "ort": "Indextest", + "land": "DE", + "bemerkung": "TEST-INDEX: Versuch mit explizitem reihenfolgeIndex=999" + } + + print_info("Versuche POST mit reihenfolgeIndex=999...") + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + if result and len(result) > 0: + addr = result[0] + actual_index = addr.get('reihenfolgeIndex') + print_info(f"Response reihenfolgeIndex: {actual_index}") + + # Hole alle Adressen und prüfe wo sie gelandet ist + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + found = None + for a in all_addresses: + if (a.get('bemerkung') or '').startswith('TEST-INDEX'): + found = a + break + + if found: + real_index = found.get('reihenfolgeIndex') + print_info(f"GET zeigt reihenfolgeIndex: {real_index}") + + if real_index == 999: + print_success("\n✓ reihenfolgeIndex kann explizit gesetzt werden!") + print_warning("⚠ ABER: Das könnte bestehende Adressen verschieben!") + elif real_index == 0: + print_warning("\n⚠ POST gibt reihenfolgeIndex=0 zurück") + print_info("→ Echter Index wird erst nach GET sichtbar") + else: + print_error(f"\n❌ reihenfolgeIndex={real_index} ignoriert Vorgabe (999)") + print_success("✓ Index wird automatisch vergeben (ans Ende)") + + return real_index + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_4_create_multiple_check_ordering(): + """Test 4: Erstelle mehrere Adressen und prüfe Reihenfolge""" + print_header("TEST 4: Mehrere neue Adressen - werden sie ans Ende gereiht?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + print_info("Hole aktuelle Adressen...") + all_before = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + max_index_before = max([a.get('reihenfolgeIndex', 0) for a in all_before]) + count_before = len(all_before) + print_info(f" Anzahl vorher: {count_before}") + print_info(f" Höchster Index: {max_index_before}") + + # Erstelle 3 neue Adressen + print_info("\nErstelle 3 neue Adressen...") + created_ids = [] + + for i in range(1, 4): + address_data = { + "strasse": f"Reihenfolge-Test {i}", + "plz": f"7777{i}", + "ort": f"Stadt-{i}", + "land": "DE", + "bemerkung": f"TEST-REIHENFOLGE-{i}" + } + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + if result and len(result) > 0: + created_ids.append(f"TEST-REIHENFOLGE-{i}") + print_success(f" ✓ Adresse {i} erstellt") + except Exception as e: + print_error(f" ✗ Fehler bei Adresse {i}: {e}") + + # Hole alle Adressen erneut + print_info("\nHole Adressen erneut...") + all_after = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + count_after = len(all_after) + print_info(f" Anzahl nachher: {count_after}") + print_info(f" Neue Adressen: {count_after - count_before}") + + # Finde unsere Test-Adressen + print(f"\n{BOLD}Reihenfolge der neuen Test-Adressen:{RESET}") + test_addresses = [] + for addr in all_after: + bemerkung = addr.get('bemerkung') or '' + if 'TEST-REIHENFOLGE-' in bemerkung: + test_addresses.append({ + 'bemerkung': bemerkung, + 'index': addr.get('reihenfolgeIndex'), + 'strasse': addr.get('strasse') + }) + + test_addresses.sort(key=lambda x: x['index']) + + for t in test_addresses: + print(f" Index {t['index']:2d}: {t['bemerkung']} ({t['strasse']})") + + # Analyse + if len(test_addresses) >= 3: + indices = [t['index'] for t in test_addresses[-3:]] # Letzten 3 + if indices == sorted(indices) and indices[-1] > max_index_before: + print_success("\n✓✓✓ Neue Adressen werden automatisch ANS ENDE gereiht!") + print_success("✓ Indices sind aufsteigend und fortlaufend") + print_info(f" Neue Indices: {indices}") + else: + print_warning(f"\n⚠ Unerwartete Reihenfolge: {indices}") + + return test_addresses + + +async def test_5_try_change_reihenfolgeIndex_via_put(): + """Test 5: Versuche reihenfolgeIndex via PUT zu ändern""" + print_header("TEST 5: Kann man reihenfolgeIndex via PUT ändern?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Finde Test-Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addr = None + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if 'TEST-REIHENFOLGE-1' in bemerkung: + test_addr = addr + break + + if not test_addr: + print_error("Test-Adresse nicht gefunden") + return False + + current_index = test_addr.get('reihenfolgeIndex') + new_index = 1 # Versuche an erste Position zu setzen + + print_info(f"Aktueller Index: {current_index}") + print_info(f"Versuche Index zu ändern auf: {new_index}") + + # PUT mit neuem reihenfolgeIndex + update_data = { + "reihenfolgeIndex": new_index, + "strasse": test_addr.get('strasse'), + "plz": test_addr.get('plz'), + "ort": test_addr.get('ort'), + "land": test_addr.get('land') + } + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{current_index}', + method='PUT', + json_data=update_data + ) + + print_success("✓ PUT erfolgreich") + + # Prüfe Ergebnis + print_info("\nPrüfe neuen Index...") + all_after = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + for addr in all_after: + bemerkung = addr.get('bemerkung') or '' + if 'TEST-REIHENFOLGE-1' in bemerkung: + result_index = addr.get('reihenfolgeIndex') + print_info(f"Index nach PUT: {result_index}") + + if result_index == new_index: + print_success("\n✓✓✓ reihenfolgeIndex KANN via PUT geändert werden!") + print_warning("⚠ Das könnte andere Adressen verschieben!") + else: + print_error(f"\n❌ reihenfolgeIndex NICHT änderbar (bleibt {result_index})") + print_success("✓ Index ist READ-ONLY bei PUT") + + return result_index == new_index + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def main(): + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ Deaktivierung + reihenfolgeIndex Tests für Adressen ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"Test-Konfiguration:") + print(f" BetNr: {TEST_BETNR}") + print(f" Datum: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # Test 1: Abgelaufene Adresse erstellen + await test_1_create_expired_address() + + # Test 2: Ist abgelaufene Adresse sichtbar? + visible = await test_2_check_if_expired_address_visible() + + # Test 3: Expliziter reihenfolgeIndex + await test_3_create_with_explicit_reihenfolgeIndex() + + # Test 4: Mehrere Adressen - Reihenfolge + await test_4_create_multiple_check_ordering() + + # Test 5: reihenfolgeIndex ändern via PUT + changeable = await test_5_try_change_reihenfolgeIndex_via_put() + + # Finale Zusammenfassung + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ FINALE ERKENNTNISSE ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"{BOLD}1. Deaktivierung via gueltigBis:{RESET}") + if visible: + print_error(" ❌ Abgelaufene Adressen werden NICHT automatisch gefiltert") + print_warning(" ⚠ GET /Adressen zeigt alle Adressen (auch abgelaufen)") + print_info(" 💡 Soft-Delete via gueltigBis ist möglich") + print_info(" 💡 Aber: Filtern muss CLIENT-seitig erfolgen") + print_info(" 💡 Strategie: In EspoCRM als 'inactive' markieren wenn gueltigBis < heute") + else: + print_success(" ✓ Abgelaufene Adressen werden automatisch ausgeblendet") + print_success(" ✓ gueltigBis eignet sich perfekt für Soft-Delete") + + print(f"\n{BOLD}2. reihenfolgeIndex Verhalten:{RESET}") + print_info(" • Neue Adressen werden automatisch ans Ende gereiht") + print_info(" • Index wird vom System vergeben (fortlaufend)") + if changeable: + print_warning(" ⚠ reihenfolgeIndex kann via PUT geändert werden") + print_warning(" ⚠ Vorsicht: Könnte andere Adressen verschieben") + else: + print_success(" ✓ reihenfolgeIndex ist READ-ONLY bei PUT (stabil)") + + print(f"\n{BOLD}3. Sync-Empfehlungen:{RESET}") + print_success(" ✓ Nutze 'bemerkung' für EspoCRM-ID Matching (stabil)") + print_success(" ✓ Nutze 'gueltigBis' für Soft-Delete (setze auf gestern)") + print_success(" ✓ Nutze 'reihenfolgeIndex' nur für PUT (nicht für Matching)") + print_info(" 💡 Workflow: GET → parse bemerkung → match → PUT via Index") + + print(f"\n{YELLOW}⚠️ ACHTUNG: Test-Adressen mit 'TEST-' im bemerkung-Feld{RESET}") + print(f"{YELLOW} sollten manuell bereinigt werden.{RESET}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_adressen_delete_matching.py b/bitbylaw/scripts/test_adressen_delete_matching.py new file mode 100644 index 00000000..4ed19567 --- /dev/null +++ b/bitbylaw/scripts/test_adressen_delete_matching.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +""" +Test: DELETE + bemerkung-basiertes Matching für Adressen +========================================================== + +Ziele: +1. Teste ob DELETE funktioniert +2. Teste ob reihenfolgeIndex nach DELETE neu sortiert wird +3. Teste bemerkung als Matching-Field mit EspoCRM-ID +4. Validiere ob bemerkung stabil bleibt bei PUT +""" + +import asyncio +import sys +import os +from datetime import datetime + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +# Test-Konfiguration +TEST_BETNR = 104860 # Test Beteiligte +ESPOCRM_TEST_IDS = ["espo-001", "espo-002", "espo-003"] + +# ANSI Color codes +BOLD = '\033[1m' +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_header(text): + print(f"\n{BOLD}{'='*80}{RESET}") + print(f"{BOLD}{text}{RESET}") + print(f"{BOLD}{'='*80}{RESET}\n") + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_warning(text): + print(f"{YELLOW}⚠ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + + +class SimpleLogger: + """Minimal logger für AdvowareAPI""" + def info(self, msg): pass + def error(self, msg): print_error(msg) + def debug(self, msg): pass + def warning(self, msg): pass + +class SimpleContext: + """Minimal context für AdvowareAPI""" + def __init__(self): + self.logger = SimpleLogger() + def log_info(self, msg): pass + def log_error(self, msg): print_error(msg) + def log_debug(self, msg): pass + + +async def test_1_create_addresses_with_espocrm_ids(): + """Test 1: Erstelle 3 Adressen mit EspoCRM-IDs im bemerkung-Feld""" + print_header("TEST 1: Erstelle Adressen mit EspoCRM-IDs im bemerkung-Feld") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + created_addresses = [] + + for i, espo_id in enumerate(ESPOCRM_TEST_IDS, 1): + print_info(f"\nErstelle Adresse {i} mit EspoCRM-ID: {espo_id}") + + address_data = { + "strasse": f"Teststraße {i*10}", + "plz": f"3015{i}", + "ort": f"Testort-{i}", + "land": "DE", + "bemerkung": f"EspoCRM-ID: {espo_id}", # ← Unsere Sync-ID! + "gueltigVon": f"2026-02-0{i}T00:00:00" + } + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + if result and len(result) > 0: + addr = result[0] + created_addresses.append({ + 'espo_id': espo_id, + 'rowId': addr.get('rowId'), + 'reihenfolgeIndex': addr.get('reihenfolgeIndex'), + 'bemerkung': addr.get('bemerkung') + }) + print_success(f"✓ Erstellt: rowId={addr.get('rowId')}, Index={addr.get('reihenfolgeIndex')}") + print_info(f" bemerkung: {addr.get('bemerkung')}") + else: + print_error("POST lieferte leere Response") + + except Exception as e: + print_error(f"Fehler beim Erstellen: {e}") + import traceback + traceback.print_exc() + return None + + print_success(f"\n✓ {len(created_addresses)} Adressen erfolgreich erstellt") + return created_addresses + + +async def test_2_find_addresses_by_espocrm_id(): + """Test 2: Finde Adressen via EspoCRM-ID im bemerkung-Feld""" + print_header("TEST 2: Finde Adressen via EspoCRM-ID (bemerkung-Matching)") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Gesamtanzahl Adressen: {len(all_addresses)}") + + # Parse bemerkung und finde unsere IDs + found_mapping = {} + + for addr in all_addresses: + bemerkung = addr.get('bemerkung', '') + if bemerkung and 'EspoCRM-ID:' in bemerkung: + # Parse: "EspoCRM-ID: espo-001" → "espo-001" + espo_id = bemerkung.split('EspoCRM-ID:')[1].strip() + found_mapping[espo_id] = { + 'reihenfolgeIndex': addr.get('reihenfolgeIndex'), + 'rowId': addr.get('rowId'), + 'strasse': addr.get('strasse'), + 'bemerkung': bemerkung + } + + print_success(f"\n✓ {len(found_mapping)} Adressen mit EspoCRM-ID gefunden:") + for espo_id, data in found_mapping.items(): + print(f" {espo_id}:") + print(f" - Index: {data['reihenfolgeIndex']}") + print(f" - Straße: {data['strasse']}") + print(f" - rowId: {data['rowId']}") + + # Validierung + for test_id in ESPOCRM_TEST_IDS: + if test_id in found_mapping: + print_success(f"✓ {test_id} gefunden!") + else: + print_error(f"✗ {test_id} NICHT gefunden!") + + return found_mapping + + except Exception as e: + print_error(f"Fehler beim Abrufen: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_3_update_address_check_bemerkung_stability(): + """Test 3: Versuche bemerkung zu ändern und prüfe Stabilität""" + print_header("TEST 3: Teste ob bemerkung bei PUT stabil bleibt") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole Adressen + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Finde erste Test-Adresse + test_addr = None + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if bemerkung and 'EspoCRM-ID: espo-001' in bemerkung: + test_addr = addr + break + + if not test_addr: + print_error("Test-Adresse mit espo-001 nicht gefunden") + return False + + original_bemerkung = test_addr.get('bemerkung') + reihenfolge_index = test_addr.get('reihenfolgeIndex') + + print_info(f"Test-Adresse Index: {reihenfolge_index}") + print_info(f"Original bemerkung: {original_bemerkung}") + + # Versuche Update mit ANDERER bemerkung + print_info("\nVersuche bemerkung zu ändern via PUT...") + update_data = { + "strasse": test_addr.get('strasse'), + "plz": test_addr.get('plz'), + "ort": "GEÄNDERT-ORT", # Ändere ort + "land": test_addr.get('land'), + "bemerkung": "GEÄNDERT: Diese bemerkung sollte NICHT überschrieben werden!" + } + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{reihenfolge_index}', + method='PUT', + json_data=update_data + ) + + # Hole erneut und prüfe + print_info("\nHole Adresse erneut und prüfe bemerkung...") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == reihenfolge_index), None) + if updated_addr: + updated_bemerkung = updated_addr.get('bemerkung') + updated_ort = updated_addr.get('ort') + + print_info(f"Nach PUT bemerkung: {updated_bemerkung}") + print_info(f"Nach PUT ort: {updated_ort}") + + if updated_bemerkung == original_bemerkung: + print_success("\n✓✓✓ PERFEKT: bemerkung ist READ-ONLY bei PUT!") + print_success("✓ EspoCRM-ID bleibt stabil → Perfekt für Matching!") + return True + else: + print_warning("\n⚠ bemerkung wurde geändert - nicht stabil!") + print_error(f" Original: {original_bemerkung}") + print_error(f" Neu: {updated_bemerkung}") + return False + else: + print_error("Adresse nach PUT nicht gefunden") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return False + + +async def test_4_delete_middle_address_check_reindex(): + """Test 4: Lösche mittlere Adresse und prüfe ob Indices neu sortiert werden""" + print_header("TEST 4: DELETE - Werden reihenfolgeIndex neu sortiert?") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole aktuelle Adressen + print_info("VOR DELETE:") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Zeige nur unsere Test-Adressen + test_addresses_before = [] + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if bemerkung and 'EspoCRM-ID:' in bemerkung: + test_addresses_before.append({ + 'index': addr.get('reihenfolgeIndex'), + 'espo_id': bemerkung.split('EspoCRM-ID:')[1].strip(), + 'strasse': addr.get('strasse') + }) + print(f" Index {addr.get('reihenfolgeIndex')}: {bemerkung}") + + # Finde mittlere Adresse (espo-002) + middle_addr = None + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if bemerkung and 'EspoCRM-ID: espo-002' in bemerkung: + middle_addr = addr + break + + if not middle_addr: + print_error("Mittlere Test-Adresse (espo-002) nicht gefunden") + return False + + delete_index = middle_addr.get('reihenfolgeIndex') + print_warning(f"\nLösche Adresse mit Index: {delete_index} (espo-002)") + + # DELETE + try: + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{delete_index}', + method='DELETE' + ) + print_success("✓ DELETE erfolgreich") + except Exception as e: + print_error(f"DELETE fehlgeschlagen: {e}") + # Versuche mit anderen Index-Werten + print_info("Versuche DELETE mit rowId...") + # Note: Swagger zeigt nur reihenfolgeIndex, aber vielleicht geht rowId? + return None + + # Hole erneut und vergleiche + print_info("\nNACH DELETE:") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addresses_after = [] + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if bemerkung and 'EspoCRM-ID:' in bemerkung: + test_addresses_after.append({ + 'index': addr.get('reihenfolgeIndex'), + 'espo_id': bemerkung.split('EspoCRM-ID:')[1].strip(), + 'strasse': addr.get('strasse') + }) + print(f" Index {addr.get('reihenfolgeIndex')}: {bemerkung}") + + # Analyse + print_info("\n=== Index-Analyse ===") + print(f"Anzahl vorher: {len(test_addresses_before)}") + print(f"Anzahl nachher: {len(test_addresses_after)}") + + if len(test_addresses_after) == len(test_addresses_before) - 1: + print_success("✓ Eine Adresse wurde gelöscht") + + # Prüfe ob Indices lückenlos sind + indices_after = sorted([a['index'] for a in test_addresses_after]) + print_info(f"Indices nachher: {indices_after}") + + # Erwartung: Lückenlos von 1 aufsteigend + expected_indices = list(range(1, len(all_addresses) + 1)) + all_indices = sorted([a.get('reihenfolgeIndex') for a in all_addresses]) + + if all_indices == expected_indices: + print_success("✓✓✓ WICHTIG: Indices wurden NEU SORTIERT (lückenlos)!") + print_warning("⚠ Das bedeutet: reihenfolgeIndex ist NICHT stabil nach DELETE!") + print_success("✓ ABER: bemerkung-Matching funktioniert unabhängig davon!") + else: + print_info(f"Indices haben Lücken: {all_indices}") + + return True + else: + print_error("Unerwartete Anzahl Adressen nach DELETE") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_5_restore_deleted_address(): + """Test 5: Stelle gelöschte Adresse wieder her""" + print_header("TEST 5: Stelle gelöschte Adresse wieder her (espo-002)") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + address_data = { + "strasse": "Teststraße 20", + "plz": "30152", + "ort": "Testort-2", + "land": "DE", + "bemerkung": "EspoCRM-ID: espo-002", + "gueltigVon": "2026-02-02T00:00:00" + } + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + if result and len(result) > 0: + addr = result[0] + print_success(f"✓ Adresse wiederhergestellt: Index={addr.get('reihenfolgeIndex')}") + return True + else: + print_error("POST fehlgeschlagen") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + return False + + +async def main(): + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ DELETE + bemerkung-Matching Tests für Adressen-Sync ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"Test-Konfiguration:") + print(f" BetNr: {TEST_BETNR}") + print(f" Test-IDs: {ESPOCRM_TEST_IDS}") + print(f" Datum: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # Test 1: Erstelle Adressen mit EspoCRM-IDs + created = await test_1_create_addresses_with_espocrm_ids() + if not created: + print_error("\nTest abgebrochen: Konnte Adressen nicht erstellen") + return + + # Test 2: Finde via bemerkung + found = await test_2_find_addresses_by_espocrm_id() + if not found or len(found) != len(ESPOCRM_TEST_IDS): + print_error("\nTest abgebrochen: Matching fehlgeschlagen") + return + + # Test 3: bemerkung Stabilität + is_stable = await test_3_update_address_check_bemerkung_stability() + + # Test 4: DELETE und Re-Index + await test_4_delete_middle_address_check_reindex() + + # Test 5: Restore + await test_5_restore_deleted_address() + + # Finale Übersicht + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ FINALE ERKENNTNISSE ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + if is_stable: + print_success("✓✓✓ bemerkung-Feld ist PERFEKT für Sync-Matching:") + print_success(" 1. Kann bei POST gesetzt werden") + print_success(" 2. Ist READ-ONLY bei PUT (bleibt stabil)") + print_success(" 3. Überlebt Index-Änderungen durch DELETE") + print_success(" 4. Format: 'EspoCRM-ID: {uuid}' ist eindeutig parsebar") + print() + print_info("💡 Empfohlene Sync-Strategie:") + print_info(" - Beim Erstellen: bemerkung = 'EspoCRM-ID: {espo_address_id}'") + print_info(" - Beim Sync: GET alle Adressen, parse bemerkung, match via ID") + print_info(" - Bei DELETE in Advoware: EspoCRM-Adresse als 'deleted' markieren") + print_info(" - Bei Konflikt: bemerkung hat Vorrang vor reihenfolgeIndex") + else: + print_warning("⚠ bemerkung-Matching hat Einschränkungen - siehe Details oben") + + print(f"\n{YELLOW}⚠️ ACHTUNG: Test-Adressen mit 'EspoCRM-ID:' im bemerkung-Feld{RESET}") + print(f"{YELLOW} sollten manuell bereinigt werden.{RESET}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_adressen_gueltigbis_modify.py b/bitbylaw/scripts/test_adressen_gueltigbis_modify.py new file mode 100644 index 00000000..9fca250b --- /dev/null +++ b/bitbylaw/scripts/test_adressen_gueltigbis_modify.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +""" +Test: gueltigBis nachträglich setzen und entfernen (Soft-Delete) +================================================================== + +Ziele: +1. Teste ob gueltigBis via PUT gesetzt werden kann (Deaktivierung) +2. Teste ob gueltigBis via PUT entfernt werden kann (Reaktivierung) +3. Teste ob gueltigBis auf null/None gesetzt werden kann +""" + +import asyncio +import sys +import os +from datetime import datetime + +# Add parent directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +# Test-Konfiguration +TEST_BETNR = 104860 + +# ANSI Color codes +BOLD = '\033[1m' +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_header(text): + print(f"\n{BOLD}{'='*80}{RESET}") + print(f"{BOLD}{text}{RESET}") + print(f"{BOLD}{'='*80}{RESET}\n") + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_warning(text): + print(f"{YELLOW}⚠ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + + +class SimpleLogger: + def info(self, msg): pass + def error(self, msg): print_error(msg) + def debug(self, msg): pass + def warning(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + def log_info(self, msg): pass + def log_error(self, msg): print_error(msg) + def log_debug(self, msg): pass + + +async def test_1_create_active_address(): + """Test 1: Erstelle aktive Adresse (ohne gueltigBis)""" + print_header("TEST 1: Erstelle aktive Adresse (OHNE gueltigBis)") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + address_data = { + "strasse": "Soft-Delete Test Straße", + "plz": "66666", + "ort": "Teststadt", + "land": "DE", + "bemerkung": "TEST-SOFTDELETE: Für gueltigBis Modifikation", + "gueltigVon": "2026-01-01T00:00:00" + # KEIN gueltigBis → unbegrenzt gültig + } + + print_info("Erstelle Adresse OHNE gueltigBis (unbegrenzt aktiv)...") + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + if result and len(result) > 0: + addr = result[0] + print_success(f"✓ Adresse erstellt") + print_info(f" rowId: {addr.get('rowId')}") + print_info(f" gueltigVon: {addr.get('gueltigVon')}") + print_info(f" gueltigBis: {addr.get('gueltigBis')} (sollte None sein)") + + # Hole echten Index via GET + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + for a in all_addresses: + if (a.get('bemerkung') or '').startswith('TEST-SOFTDELETE'): + print_info(f" reihenfolgeIndex: {a.get('reihenfolgeIndex')}") + return a.get('reihenfolgeIndex') + + return None + else: + print_error("POST fehlgeschlagen") + return None + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_2_deactivate_via_gueltigbis(index): + """Test 2: Deaktiviere Adresse durch Setzen von gueltigBis""" + print_header("TEST 2: Deaktivierung - gueltigBis nachträglich setzen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole aktuelle Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if not test_addr: + print_error(f"Adresse mit Index {index} nicht gefunden") + return False + + print_info("Status VORHER:") + print(f" gueltigVon: {test_addr.get('gueltigVon')}") + print(f" gueltigBis: {test_addr.get('gueltigBis')}") + + # Setze gueltigBis auf gestern (= deaktiviert) + print_info("\nSetze gueltigBis auf 2024-12-31 (Vergangenheit = deaktiviert)...") + + update_data = { + "strasse": test_addr.get('strasse'), + "plz": test_addr.get('plz'), + "ort": test_addr.get('ort'), + "land": test_addr.get('land'), + "gueltigVon": test_addr.get('gueltigVon'), + "gueltigBis": "2024-12-31T23:59:59" # ← Vergangenheit + } + + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + print_success("✓ PUT erfolgreich") + + # Prüfe Ergebnis + print_info("\nHole Adresse erneut und prüfe gueltigBis...") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if updated_addr: + print_info("Status NACHHER:") + print(f" gueltigVon: {updated_addr.get('gueltigVon')}") + print(f" gueltigBis: {updated_addr.get('gueltigBis')}") + + if updated_addr.get('gueltigBis') == "2024-12-31T00:00:00": + print_success("\n✓✓✓ PERFEKT: gueltigBis wurde nachträglich gesetzt!") + print_success("✓ Adresse kann via PUT deaktiviert werden!") + return True + else: + print_error(f"\n❌ gueltigBis nicht korrekt: {updated_addr.get('gueltigBis')}") + return False + else: + print_error("Adresse nach PUT nicht gefunden") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return False + + +async def test_3_reactivate_set_far_future(index): + """Test 3: Reaktivierung durch Setzen auf weit in Zukunft""" + print_header("TEST 3: Reaktivierung - gueltigBis auf fernes Datum setzen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole aktuelle Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if not test_addr: + print_error(f"Adresse mit Index {index} nicht gefunden") + return False + + print_info("Status VORHER (deaktiviert):") + print(f" gueltigBis: {test_addr.get('gueltigBis')}") + + # Setze gueltigBis auf weit in Zukunft + print_info("\nSetze gueltigBis auf 2099-12-31 (weit in Zukunft = aktiv)...") + + update_data = { + "strasse": test_addr.get('strasse'), + "plz": test_addr.get('plz'), + "ort": test_addr.get('ort'), + "land": test_addr.get('land'), + "gueltigVon": test_addr.get('gueltigVon'), + "gueltigBis": "2099-12-31T23:59:59" # ← Weit in Zukunft + } + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + print_success("✓ PUT erfolgreich") + + # Prüfe Ergebnis + print_info("\nHole Adresse erneut...") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if updated_addr: + print_info("Status NACHHER (reaktiviert):") + print(f" gueltigBis: {updated_addr.get('gueltigBis')}") + + if updated_addr.get('gueltigBis') == "2099-12-31T00:00:00": + print_success("\n✓✓✓ PERFEKT: gueltigBis wurde auf Zukunft gesetzt!") + print_success("✓ Adresse ist jetzt wieder aktiv!") + return True + else: + print_error(f"\n❌ gueltigBis nicht korrekt: {updated_addr.get('gueltigBis')}") + return False + else: + print_error("Adresse nach PUT nicht gefunden") + return False + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return False + + +async def test_4_remove_gueltigbis_completely(index): + """Test 4: Entferne gueltigBis komplett (null/None)""" + print_header("TEST 4: gueltigBis komplett entfernen (null/None)") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + # Hole aktuelle Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if not test_addr: + print_error(f"Adresse mit Index {index} nicht gefunden") + return None + + print_info("Status VORHER:") + print(f" gueltigBis: {test_addr.get('gueltigBis')}") + + # Versuche 1: gueltigBis weglassen + print_info("\n=== Versuch 1: gueltigBis komplett weglassen ===") + + update_data = { + "strasse": test_addr.get('strasse'), + "plz": test_addr.get('plz'), + "ort": test_addr.get('ort'), + "land": test_addr.get('land'), + "gueltigVon": test_addr.get('gueltigVon') + # gueltigBis absichtlich weggelassen + } + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + result_1 = updated_addr.get('gueltigBis') if updated_addr else "ERROR" + print_info(f"Ergebnis: gueltigBis = {result_1}") + + if result_1 is None: + print_success("✓ Weglassen entfernt gueltigBis!") + return "omit" + + # Versuche 2: gueltigBis = None/null + print_info("\n=== Versuch 2: gueltigBis explizit auf None setzen ===") + + update_data['gueltigBis'] = None + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + result_2 = updated_addr.get('gueltigBis') if updated_addr else "ERROR" + print_info(f"Ergebnis: gueltigBis = {result_2}") + + if result_2 is None: + print_success("✓ None entfernt gueltigBis!") + return "none" + + # Versuche 3: gueltigBis = "" + print_info("\n=== Versuch 3: gueltigBis auf leeren String setzen ===") + + update_data['gueltigBis'] = "" + + try: + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + result_3 = updated_addr.get('gueltigBis') if updated_addr else "ERROR" + print_info(f"Ergebnis: gueltigBis = {result_3}") + + if result_3 is None: + print_success("✓ Leerer String entfernt gueltigBis!") + return "empty" + except Exception as e: + print_warning(f"⚠ Leerer String wird abgelehnt: {e}") + + print_warning("\n⚠ gueltigBis kann nicht komplett entfernt werden") + print_info("💡 Lösung: Setze auf weit in Zukunft (2099-12-31) für 'unbegrenzt aktiv'") + return "not_possible" + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def main(): + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ gueltigBis nachträglich ändern (Soft-Delete Tests) ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"Test-Konfiguration:") + print(f" BetNr: {TEST_BETNR}") + print(f" Datum: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + # Test 1: Erstelle aktive Adresse + index = await test_1_create_active_address() + if not index: + print_error("\nTest abgebrochen: Konnte Adresse nicht erstellen") + return + + # Test 2: Deaktiviere via gueltigBis + can_deactivate = await test_2_deactivate_via_gueltigbis(index) + + # Test 3: Reaktiviere via gueltigBis auf Zukunft + can_reactivate = await test_3_reactivate_set_far_future(index) + + # Test 4: Versuche gueltigBis zu entfernen + remove_method = await test_4_remove_gueltigbis_completely(index) + + # Finale Zusammenfassung + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ FINALE ERKENNTNISSE ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"{BOLD}Soft-Delete Funktionalität:{RESET}\n") + + if can_deactivate: + print_success("✓✓✓ DEAKTIVIERUNG funktioniert:") + print_success(" • gueltigBis kann via PUT auf Vergangenheit gesetzt werden") + print_success(" • Beispiel: gueltigBis = '2024-12-31T23:59:59'") + print_success(" • Adresse bleibt in GET sichtbar (Client-Filter nötig)") + else: + print_error("✗ DEAKTIVIERUNG funktioniert NICHT") + + print() + + if can_reactivate: + print_success("✓✓✓ REAKTIVIERUNG funktioniert:") + print_success(" • gueltigBis kann via PUT auf Zukunft gesetzt werden") + print_success(" • Beispiel: gueltigBis = '2099-12-31T23:59:59'") + print_success(" • Adresse ist damit wieder aktiv") + else: + print_error("✗ REAKTIVIERUNG funktioniert NICHT") + + print() + + if remove_method: + if remove_method in ["omit", "none", "empty"]: + print_success(f"✓ gueltigBis entfernen funktioniert (Methode: {remove_method})") + if remove_method == "omit": + print_success(" • Weglassen des Feldes entfernt gueltigBis") + elif remove_method == "none": + print_success(" • Setzen auf None/null entfernt gueltigBis") + elif remove_method == "empty": + print_success(" • Setzen auf '' entfernt gueltigBis") + else: + print_warning("⚠ gueltigBis kann NICHT komplett entfernt werden") + print_info(" • Lösung: Setze auf 2099-12-31 für 'unbegrenzt aktiv'") + + print(f"\n{BOLD}Empfohlener Workflow:{RESET}\n") + print_info("1. AKTIV (Standard):") + print_info(" → gueltigBis = '2099-12-31T23:59:59' oder None") + print_info(" → In EspoCRM: isActive = True") + print() + print_info("2. DEAKTIVIEREN (Soft-Delete):") + print_info(" → PUT mit gueltigBis = '2024-01-01T00:00:00' (Vergangenheit)") + print_info(" → In EspoCRM: isActive = False") + print() + print_info("3. REAKTIVIEREN:") + print_info(" → PUT mit gueltigBis = '2099-12-31T23:59:59' (Zukunft)") + print_info(" → In EspoCRM: isActive = True") + print() + print_info("4. SYNC LOGIC:") + print_info(" → GET /Adressen → filter wo gueltigBis > heute") + print_info(" → Sync nur aktive Adressen nach EspoCRM") + print_info(" → Update isActive basierend auf gueltigBis") + + print(f"\n{YELLOW}⚠️ ACHTUNG: Test-Adresse 'TEST-SOFTDELETE' sollte bereinigt werden.{RESET}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_adressen_nullen.py b/bitbylaw/scripts/test_adressen_nullen.py new file mode 100644 index 00000000..7bf9022e --- /dev/null +++ b/bitbylaw/scripts/test_adressen_nullen.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +""" +Test: Können wir alle Felder einer Adresse auf null/leer setzen? +================================================================= + +Teste: +1. Können wir strasse, plz, ort, anschrift auf null setzen? +2. Können wir sie auf leere Strings setzen? +3. Was passiert mit der Adresse? +""" + +import asyncio +import sys +import os +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + +BOLD = '\033[1m' +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + +def print_section(title): + print(f"\n{BOLD}{'='*70}{RESET}") + print(f"{BOLD}{title}{RESET}") + print(f"{BOLD}{'='*70}{RESET}\n") + + +async def main(): + print_section("TEST: Adresse nullen/leeren") + + api = AdvowareAPI() + + # Hole aktuelle Adressen + print_info("Hole bestehende Adressen...") + addresses = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Gefunden: {len(addresses)} Adressen\n") + + if len(addresses) == 0: + print_error("Keine Adressen vorhanden - erstelle Testadresse erst") + + # Erstelle Testadresse + new_addr = { + "strasse": "Nulltest Straße 999", + "plz": "99999", + "ort": "Nullstadt", + "land": "DE", + "anschrift": "Test\nNulltest", + "bemerkung": f"NULL-TEST: {datetime.now()}" + } + + result = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=new_addr + ) + + print_success("Testadresse erstellt") + addresses = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + # Nimm die erste Adresse + target = addresses[0] + index = target['reihenfolgeIndex'] + + print_info(f"Verwende Adresse mit Index {index}:") + print(f" Strasse: {target.get('strasse')}") + print(f" PLZ: {target.get('plz')}") + print(f" Ort: {target.get('ort')}") + anschrift = target.get('anschrift') or '' + print(f" Anschrift: {anschrift[:50] if anschrift else 'N/A'}...") + + # Test 1: Alle Felder auf null setzen + print_section("Test 1: Alle änderbaren Felder auf null") + + null_data = { + "strasse": None, + "plz": None, + "ort": None, + "anschrift": None + } + + print_info("Sende PUT mit null-Werten...") + try: + result = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=null_data + ) + + print_success("PUT erfolgreich!") + print(f"\nResponse:") + print(f" strasse: {result.get('strasse')}") + print(f" plz: {result.get('plz')}") + print(f" ort: {result.get('ort')}") + print(f" anschrift: {result.get('anschrift')}") + + if all(result.get(f) is None for f in ['strasse', 'plz', 'ort', 'anschrift']): + print_success("\n✓ Alle Felder sind null!") + elif all(result.get(f) == '' for f in ['strasse', 'plz', 'ort', 'anschrift']): + print_success("\n✓ Alle Felder sind leere Strings!") + else: + print_error("\n✗ Felder haben immer noch Werte") + + except Exception as e: + print_error(f"PUT fehlgeschlagen: {e}") + + # Test 2: Alle Felder auf leere Strings + print_section("Test 2: Alle änderbaren Felder auf leere Strings") + + empty_data = { + "strasse": "", + "plz": "", + "ort": "", + "anschrift": "" + } + + print_info("Sende PUT mit leeren Strings...") + try: + result = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=empty_data + ) + + print_success("PUT erfolgreich!") + print(f"\nResponse:") + print(f" strasse: '{result.get('strasse')}'") + print(f" plz: '{result.get('plz')}'") + print(f" ort: '{result.get('ort')}'") + print(f" anschrift: '{result.get('anschrift')}'") + + if all(result.get(f) == '' or result.get(f) is None for f in ['strasse', 'plz', 'ort', 'anschrift']): + print_success("\n✓ Alle Felder sind leer!") + else: + print_error("\n✗ Felder haben immer noch Werte") + + except Exception as e: + print_error(f"PUT fehlgeschlagen: {e}") + + # Test 3: GET und prüfen + print_section("Test 3: Finale Prüfung via GET") + + final_addresses = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + final_target = next((a for a in final_addresses if a['reihenfolgeIndex'] == index), None) + + if final_target: + print_info("Finale Werte:") + print(f" strasse: '{final_target.get('strasse')}'") + print(f" plz: '{final_target.get('plz')}'") + print(f" ort: '{final_target.get('ort')}'") + print(f" land: '{final_target.get('land')}'") + print(f" anschrift: '{final_target.get('anschrift')}'") + print(f" bemerkung: '{final_target.get('bemerkung')}'") + print(f" standardAnschrift: {final_target.get('standardAnschrift')}") + + # Prüfe ob Adresse "leer" ist + is_empty = all( + not final_target.get(f) + for f in ['strasse', 'plz', 'ort', 'anschrift'] + ) + + if is_empty: + print_success("\n✓ Adresse ist komplett geleert!") + print_info(" → Kann als Soft-Delete Alternative genutzt werden") + else: + print_error("\n✗ Adresse hat noch Daten") + else: + print_error("Adresse wurde gelöscht?!") + + # Test 4: Kann man eine komplett leere Adresse erstellen? + print_section("Test 4: Neue leere Adresse erstellen (POST)") + + empty_new = { + "strasse": "", + "plz": "", + "ort": "", + "land": "DE", + "anschrift": "", + "bemerkung": f"LEER-TEST: {datetime.now()}" + } + + print_info("Sende POST mit leeren Haupt-Feldern...") + try: + result = await api.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=empty_new + ) + + if isinstance(result, list): + result = result[0] + + print_success("POST erfolgreich!") + print(f"\nErstellte Adresse:") + print(f" Index: {result.get('reihenfolgeIndex')}") + print(f" strasse: '{result.get('strasse')}'") + print(f" plz: '{result.get('plz')}'") + print(f" ort: '{result.get('ort')}'") + print(f" anschrift: '{result.get('anschrift')}'") + + print_success("\n✓ Leere Adresse kann erstellt werden!") + + except Exception as e: + print_error(f"POST fehlgeschlagen: {e}") + print_info(" → Leere Adressen via POST nicht erlaubt") + + print_section("ZUSAMMENFASSUNG") + print_info("Adresse nullen/leeren:") + print(" 1. Via PUT auf null → Test zeigt Ergebnis") + print(" 2. Via PUT auf '' → Test zeigt Ergebnis") + print(" 3. Via POST leer → Test zeigt ob möglich") + print("\n → Könnte als Soft-Delete Alternative dienen!") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_adressen_sync.py b/bitbylaw/scripts/test_adressen_sync.py new file mode 100644 index 00000000..a2f8a912 --- /dev/null +++ b/bitbylaw/scripts/test_adressen_sync.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Test: Adressen-Sync zwischen EspoCRM und Advoware +================================================== + +Testet die AdressenSync-Implementierung: +1. CREATE: Neue Adresse von EspoCRM → Advoware +2. UPDATE: Änderung nur R/W Felder +3. READ-ONLY Detection: Notification bei READ-ONLY Änderungen +4. SYNC: Advoware → EspoCRM +""" + +import asyncio +import sys +import os +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.adressen_sync import AdressenSync +from services.espocrm import EspoCRMAPI + +BOLD = '\033[1m' +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + +def print_section(title): + print(f"\n{BOLD}{'='*70}{RESET}") + print(f"{BOLD}{title}{RESET}") + print(f"{BOLD}{'='*70}{RESET}\n") + + +class SimpleLogger: + def debug(self, msg): pass + def info(self, msg): pass + def warning(self, msg): pass + def error(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + + +async def main(): + print_section("TEST: Adressen-Sync") + + context = SimpleContext() + sync = AdressenSync(context=context) + espo = EspoCRMAPI(context=context) + + # Test-Daten + TEST_BETNR = 104860 + TEST_BETEILIGTE_ID = None # Wird ermittelt + + # 1. Finde Beteiligten in EspoCRM + print_section("1. Setup: Finde Test-Beteiligten") + + print_info("Suche Beteiligten mit BetNr 104860...") + + import json + beteiligte_result = await espo.list_entities( + 'CBeteiligte', + where=json.dumps([{ + 'type': 'equals', + 'attribute': 'betNr', + 'value': str(TEST_BETNR) + }]) + ) + + if not beteiligte_result.get('list'): + print_error("Beteiligter nicht gefunden!") + return + + TEST_BETEILIGTE_ID = beteiligte_result['list'][0]['id'] + print_success(f"Beteiligter gefunden: {TEST_BETEILIGTE_ID}") + + # 2. Test CREATE + print_section("2. Test CREATE: EspoCRM → Advoware") + + # Erstelle Test-Adresse in EspoCRM + print_info("Erstelle Test-Adresse in EspoCRM...") + + test_addr_data = { + 'name': f'SYNC-TEST Adresse {datetime.now().strftime("%H:%M:%S")}', + 'adresseStreet': 'SYNC-TEST Straße 123', + 'adressePostalCode': '10115', + 'adresseCity': 'Berlin', + 'adresseCountry': 'DE', + 'isPrimary': False, + 'isActive': True, + 'beteiligteId': TEST_BETEILIGTE_ID, + 'description': f'SYNC-TEST: {datetime.now()}' + } + + espo_addr = await espo.create_entity('CAdressen', test_addr_data) + + if not espo_addr: + print_error("Konnte EspoCRM Adresse nicht erstellen!") + return + + print_success(f"EspoCRM Adresse erstellt: {espo_addr['id']}") + + # Sync zu Advoware + print_info("\nSync zu Advoware...") + + advo_result = await sync.create_address(espo_addr, TEST_BETNR) + + if advo_result: + print_success( + f"✓ Adresse in Advoware erstellt: " + f"Index {advo_result.get('reihenfolgeIndex')}" + ) + print(f" Strasse: {advo_result.get('strasse')}") + print(f" PLZ: {advo_result.get('plz')}") + print(f" Ort: {advo_result.get('ort')}") + print(f" bemerkung: {advo_result.get('bemerkung')}") + else: + print_error("✗ CREATE fehlgeschlagen!") + return + + # 3. Test UPDATE (nur R/W Felder) + print_section("3. Test UPDATE: Nur R/W Felder") + + # Ändere Straße + print_info("Ändere Straße in EspoCRM...") + + espo_addr['adresseStreet'] = 'SYNC-TEST Neue Straße 456' + espo_addr['adresseCity'] = 'Hamburg' + + await espo.update_entity('CAdressen', espo_addr['id'], { + 'adresseStreet': espo_addr['adresseStreet'], + 'adresseCity': espo_addr['adresseCity'] + }) + + print_success("EspoCRM aktualisiert") + + # Sync zu Advoware + print_info("\nSync UPDATE zu Advoware...") + + update_result = await sync.update_address(espo_addr, TEST_BETNR) + + if update_result: + print_success("✓ Adresse in Advoware aktualisiert") + print(f" Strasse: {update_result.get('strasse')}") + print(f" Ort: {update_result.get('ort')}") + else: + print_error("✗ UPDATE fehlgeschlagen!") + + # 4. Test READ-ONLY Detection + print_section("4. Test READ-ONLY Feld-Änderung") + + print_info("Ändere READ-ONLY Feld (isPrimary) in EspoCRM...") + + espo_addr['isPrimary'] = True + + await espo.update_entity('CAdressen', espo_addr['id'], { + 'isPrimary': True + }) + + print_success("EspoCRM aktualisiert (isPrimary = true)") + + # Sync zu Advoware (sollte Notification erstellen) + print_info("\nSync zu Advoware (sollte Notification erstellen)...") + + update_result2 = await sync.update_address(espo_addr, TEST_BETNR) + + if update_result2: + print_success("✓ UPDATE erfolgreich") + print_info(" → Notification sollte erstellt worden sein!") + print_info(" → Prüfe EspoCRM Tasks/Notifications") + else: + print_error("✗ UPDATE fehlgeschlagen!") + + # 5. Test SYNC from Advoware + print_section("5. Test SYNC: Advoware → EspoCRM") + + print_info("Synct alle Adressen von Advoware...") + + stats = await sync.sync_from_advoware(TEST_BETNR, TEST_BETEILIGTE_ID) + + print_success(f"✓ Sync abgeschlossen:") + print(f" Created: {stats['created']}") + print(f" Updated: {stats['updated']}") + print(f" Errors: {stats['errors']}") + + # 6. Cleanup + print_section("6. Cleanup") + + print_info("Lösche Test-Adresse aus EspoCRM...") + + # In EspoCRM löschen + await espo.delete_entity('CAdressen', espo_addr['id']) + + print_success("EspoCRM Adresse gelöscht") + + # DELETE Handler testen + print_info("\nTestweise DELETE-Handler aufrufen...") + + delete_result = await sync.handle_address_deletion(espo_addr, TEST_BETNR) + + if delete_result: + print_success("✓ DELETE Notification erstellt") + print_info(" → Prüfe EspoCRM Tasks für manuelle Löschung") + else: + print_error("✗ DELETE Notification fehlgeschlagen!") + + print_section("ZUSAMMENFASSUNG") + + print_success("✓ CREATE: Funktioniert") + print_success("✓ UPDATE (R/W): Funktioniert") + print_success("✓ READ-ONLY Detection: Funktioniert") + print_success("✓ SYNC from Advoware: Funktioniert") + print_success("✓ DELETE Notification: Funktioniert") + + print_info("\n⚠ WICHTIG:") + print(" - Test-Adresse in Advoware manuell löschen!") + print(f" - BetNr: {TEST_BETNR}") + print(" - Suche nach: SYNC-TEST") + + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_find_hauptadresse.py b/bitbylaw/scripts/test_find_hauptadresse.py new file mode 100644 index 00000000..ac6dea97 --- /dev/null +++ b/bitbylaw/scripts/test_find_hauptadresse.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Test: Finde "Test 6667426" Adresse in API +==================================== +User sagt: In Advoware wird "Test 6667426" als Hauptadresse angezeigt +Ziel: API-Response dieser Adresse analysieren +""" + +import asyncio +import json +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +# Farben für Output +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +BOLD = '\033[1m' +RESET = '\033[0m' + +BETNR = 104860 + +class SimpleLogger: + def info(self, msg): pass + def error(self, msg): pass + def warning(self, msg): pass + def debug(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + +def print_section(title): + print(f"\n{BLUE}{BOLD}{'='*70}{RESET}") + print(f"{BLUE}{BOLD}{title}{RESET}") + print(f"{BLUE}{BOLD}{'='*70}{RESET}\n") + +def print_success(msg): + print(f"{GREEN}✓ {msg}{RESET}") + +def print_error(msg): + print(f"{RED}✗ {msg}{RESET}") + +def print_info(msg): + print(f"{YELLOW}ℹ {msg}{RESET}") + +async def main(): + print_section("Suche 'Test 6667426' Adresse in API") + + # Initialize API + context = SimpleContext() + api = AdvowareAPI(context=context) + + # Hole alle Adressen + adressen = await api.api_call( + f'/api/v1/advonet/Beteiligte/{BETNR}/Adressen', + method='GET' + ) + + if not adressen: + print_error("Keine Adressen gefunden!") + return + + print_info(f"Gefunden: {len(adressen)} Adressen") + + # Suche nach "Test 6667426" + target_addr = None + for addr in adressen: + strasse = addr.get('strasse', '') or '' + anschrift = addr.get('anschrift', '') or '' + + if '6667426' in strasse or '6667426' in anschrift: + target_addr = addr + break + + if not target_addr: + print_error("Adresse 'Test 6667426' NICHT gefunden!") + print_info("Suche nach 'Test' in Adress-Feldern...") + + # Zeige alle Adressen mit "Test" + test_adressen = [] + for addr in adressen: + strasse = addr.get('strasse', '') + if 'Test' in strasse: + test_adressen.append(addr) + + if test_adressen: + print_info(f"Gefunden: {len(test_adressen)} Adressen mit 'Test':") + for addr in test_adressen: + print(f" - Index: {addr.get('reihenfolgeIndex')}, " + f"Strasse: {addr.get('strasse')}, " + f"standardAnschrift: {addr.get('standardAnschrift')}") + + return + + # Zeige vollständige Adresse + print_section("GEFUNDEN: Test 6667426") + print(f"{BOLD}Vollständiger API-Response:{RESET}") + print(json.dumps(target_addr, indent=2, ensure_ascii=False)) + + # Analysiere wichtige Felder + print_section("Wichtige Felder") + + wichtige_felder = [ + 'id', + 'rowId', + 'reihenfolgeIndex', + 'strasse', + 'plz', + 'ort', + 'anschrift', + 'standardAnschrift', # ← Das ist der Key! + 'bemerkung', + 'gueltigVon', + 'gueltigBis' + ] + + for feld in wichtige_felder: + wert = target_addr.get(feld) + + # Highlight standardAnschrift + if feld == 'standardAnschrift': + if wert: + print(f" {GREEN}{BOLD}{feld}: {wert}{RESET} ← HAUPTADRESSE!") + else: + print(f" {RED}{BOLD}{feld}: {wert}{RESET} ← NICHT Hauptadresse!") + else: + print(f" {feld}: {wert}") + + # Vergleiche mit anderen Adressen + print_section("Vergleich mit anderen Adressen") + + hauptadressen = [a for a in adressen if a.get('standardAnschrift')] + + print_info(f"Anzahl Adressen mit standardAnschrift=true: {len(hauptadressen)}") + + if len(hauptadressen) == 0: + print_error("KEINE einzige Adresse hat standardAnschrift=true!") + print_info("Aber Advoware zeigt trotzdem eine als 'Haupt' an?") + elif len(hauptadressen) == 1: + if hauptadressen[0] == target_addr: + print_success("Test 6667426 ist die EINZIGE Hauptadresse!") + else: + print_error("Test 6667426 ist NICHT die Hauptadresse!") + print_info(f"Hauptadresse ist: {hauptadressen[0].get('strasse')}") + else: + print_error(f"MEHRERE Hauptadressen ({len(hauptadressen)})!") + for ha in hauptadressen: + marker = " ← Das ist Test 6667426!" if ha == target_addr else "" + print(f" - Index {ha.get('reihenfolgeIndex')}: {ha.get('strasse')}{marker}") + + # Prüfe ob es die neueste ist + print_section("Position/Reihenfolge") + + max_index = max(a.get('reihenfolgeIndex', 0) for a in adressen) + target_index = target_addr.get('reihenfolgeIndex') + + print_info(f"Test 6667426 hat Index: {target_index}") + print_info(f"Höchster Index: {max_index}") + + if target_index == max_index: + print_success("Test 6667426 ist die NEUESTE Adresse (höchster Index)!") + else: + print_error(f"Test 6667426 ist NICHT die neueste (Differenz: {max_index - target_index})") + + # Sortierung nach Index + sorted_adressen = sorted(adressen, key=lambda a: a.get('reihenfolgeIndex', 0)) + + print_info(f"\nAlle Adressen sortiert nach reihenfolgeIndex:") + for i, addr in enumerate(sorted_adressen[-10:]): # Zeige letzte 10 + idx = addr.get('reihenfolgeIndex') + strasse = addr.get('strasse', '')[:40] + standard = addr.get('standardAnschrift') + + marker = "" + if addr == target_addr: + marker = f" {GREEN}← Test 6667426{RESET}" + + standard_marker = f"{GREEN}[HAUPT]{RESET}" if standard else "" + + print(f" {idx:3d}: {strasse:40s} {standard_marker}{marker}") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_hauptadresse_explizit.py b/bitbylaw/scripts/test_hauptadresse_explizit.py new file mode 100644 index 00000000..a208158b --- /dev/null +++ b/bitbylaw/scripts/test_hauptadresse_explizit.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +""" +Test: Hauptadresse explizit setzen +=================================== + +Teste: +1. Kann standardAnschrift beim POST gesetzt werden? +2. Kann es mehrere Hauptadressen geben? +3. Wird alte Hauptadresse automatisch deaktiviert? +""" + +import asyncio +import sys +import os +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + +BOLD = '\033[1m' +GREEN = '\033[92m' +RED = '\033[91m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + +class SimpleLogger: + def info(self, msg): pass + def error(self, msg): pass + def debug(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + + +async def main(): + print(f"\n{BOLD}TEST: standardAnschrift explizit setzen{RESET}\n") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Test 1: Erstelle mit standardAnschrift = true + print_info("Test 1: Erstelle Adresse mit standardAnschrift = true") + + address_data = { + "strasse": "Hauptadresse Explizit Test", + "plz": "11111", + "ort": "Hauptstadt", + "land": "DE", + "standardAnschrift": True, # ← EXPLIZIT gesetzt! + "bemerkung": f"TEST-HAUPT-EXPLIZIT: {datetime.now()}" + } + + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data + ) + + created = result[0] + print(f" Response standardAnschrift: {created.get('standardAnschrift')}") + + # GET und prüfen + print_info("\nHole alle Adressen und prüfe...") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + hauptadressen = [a for a in all_addresses if a.get('standardAnschrift')] + + print(f"\n{BOLD}Ergebnis:{RESET}") + print(f" Anzahl Hauptadressen: {len(hauptadressen)}") + + if len(hauptadressen) > 0: + print_success(f"\n✓ {len(hauptadressen)} Adresse(n) mit standardAnschrift = true:") + for ha in hauptadressen: + print(f" Index {ha.get('reihenfolgeIndex')}: {ha.get('strasse')}") + print(f" bemerkung: {ha.get('bemerkung', 'N/A')[:50]}") + else: + print_error("\n✗ KEINE Hauptadresse trotz standardAnschrift = true beim POST!") + + # Test 2: Erstelle ZWEITE mit standardAnschrift = true + print(f"\n{BOLD}Test 2: Erstelle ZWEITE Adresse mit standardAnschrift = true{RESET}") + + address_data2 = { + "strasse": "Zweite Hauptadresse Test", + "plz": "22222", + "ort": "Zweitstadt", + "land": "DE", + "standardAnschrift": True, + "bemerkung": f"TEST-HAUPT-ZWEI: {datetime.now()}" + } + + await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=address_data2 + ) + + # GET erneut + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + hauptadressen = [a for a in all_addresses if a.get('standardAnschrift')] + + print(f"\n{BOLD}Ergebnis nach 2. Adresse:{RESET}") + print(f" Anzahl Hauptadressen: {len(hauptadressen)}") + + if len(hauptadressen) == 1: + print_success("\n✓ Es gibt nur EINE Hauptadresse!") + print_success("✓ Alte Hauptadresse wurde automatisch deaktiviert") + print(f" Aktuelle Hauptadresse: {hauptadressen[0].get('strasse')}") + elif len(hauptadressen) == 2: + print_error("\n✗ Es gibt ZWEI Hauptadressen!") + print_error("✗ Advoware erlaubt mehrere Hauptadressen") + for ha in hauptadressen: + print(f" - {ha.get('strasse')}") + elif len(hauptadressen) == 0: + print_error("\n✗ KEINE Hauptadresse!") + print_error("✗ standardAnschrift wird nicht gespeichert") + + print(f"\n{BOLD}FAZIT:{RESET}") + if len(hauptadressen) == 1: + print_success("✓ Advoware verwaltet automatisch EINE Hauptadresse") + print_success("✓ Neue Hauptadresse deaktiviert alte automatisch") + elif len(hauptadressen) > 1: + print_error("✗ Mehrere Hauptadressen möglich") + else: + print_error("✗ standardAnschrift ist möglicherweise READ-ONLY") + + print(f"\n{YELLOW}⚠️ Test-Adressen mit 'TEST-HAUPT' bereinigen{RESET}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_hauptadresse_logic.py b/bitbylaw/scripts/test_hauptadresse_logic.py new file mode 100644 index 00000000..032c3562 --- /dev/null +++ b/bitbylaw/scripts/test_hauptadresse_logic.py @@ -0,0 +1,304 @@ +#!/usr/bin/env python3 +""" +Test: Hauptadresse-Logik in Advoware +===================================== + +Hypothese: Die neueste Adresse wird automatisch zur Hauptadresse (standardAnschrift = true) + +Test: +1. Hole aktuelle Adressen und identifiziere Hauptadresse +2. Erstelle neue Adresse +3. Prüfe ob neue Adresse zur Hauptadresse wird +4. Prüfe ob alte Hauptadresse deaktiviert wird +""" + +import asyncio +import sys +import os +from datetime import datetime + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + +BOLD = '\033[1m' +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_header(text): + print(f"\n{BOLD}{'='*80}{RESET}") + print(f"{BOLD}{text}{RESET}") + print(f"{BOLD}{'='*80}{RESET}\n") + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_warning(text): + print(f"{YELLOW}⚠ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + + +class SimpleLogger: + def info(self, msg): pass + def error(self, msg): pass + def debug(self, msg): pass + def warning(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + + +async def test_1_check_current_hauptadresse(): + """Test 1: Welche Adresse ist aktuell die Hauptadresse?""" + print_header("TEST 1: Aktuelle Hauptadresse identifizieren") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Gesamtanzahl Adressen: {len(all_addresses)}") + + # Finde Hauptadresse + hauptadresse = None + for addr in all_addresses: + if addr.get('standardAnschrift'): + hauptadresse = addr + break + + if hauptadresse: + print_success(f"\n✓ Hauptadresse gefunden:") + print(f" Index: {hauptadresse.get('reihenfolgeIndex')}") + print(f" Straße: {hauptadresse.get('strasse')}") + print(f" Ort: {hauptadresse.get('ort')}") + print(f" standardAnschrift: {hauptadresse.get('standardAnschrift')}") + print(f" bemerkung: {hauptadresse.get('bemerkung', 'N/A')}") + + # Prüfe ob es "Test 6667426" ist + bemerkung = hauptadresse.get('bemerkung', '') + if '6667426' in str(bemerkung) or '6667426' in str(hauptadresse.get('strasse', '')): + print_success("✓ Bestätigt: 'Test 6667426' ist Hauptadresse") + + return hauptadresse + else: + print_warning("⚠ Keine Hauptadresse (standardAnschrift = true) gefunden!") + print_info("\nAlle Adressen:") + for i, addr in enumerate(all_addresses, 1): + print(f"\n Adresse {i}:") + print(f" Index: {addr.get('reihenfolgeIndex')}") + print(f" Straße: {addr.get('strasse')}") + print(f" standardAnschrift: {addr.get('standardAnschrift')}") + return None + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_2_create_new_address(): + """Test 2: Erstelle neue Adresse""" + print_header("TEST 2: Neue Adresse erstellen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + new_address_data = { + "strasse": "Neue Hauptadresse Test 999", + "plz": "12345", + "ort": "Neustadt", + "land": "DE", + "anschrift": "Neue Hauptadresse Test 999\n12345 Neustadt\nDeutschland", + "bemerkung": f"TEST-HAUPTADRESSE: Erstellt {timestamp}", + "gueltigVon": "2026-02-08T00:00:00" + # KEIN standardAnschrift gesetzt → schauen was passiert + } + + print_info("Erstelle neue Adresse OHNE standardAnschrift-Flag...") + print(f" Straße: {new_address_data['strasse']}") + print(f" Ort: {new_address_data['ort']}") + + try: + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='POST', + json_data=new_address_data + ) + + if result and len(result) > 0: + created = result[0] + print_success("\n✓ Adresse erstellt!") + print(f" rowId: {created.get('rowId')}") + print(f" standardAnschrift: {created.get('standardAnschrift')}") + print(f" reihenfolgeIndex: {created.get('reihenfolgeIndex')}") + + return created.get('rowId') + else: + print_error("POST fehlgeschlagen") + return None + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + return None + + +async def test_3_check_after_creation(old_hauptadresse, new_row_id): + """Test 3: Prüfe Hauptadresse nach Erstellung""" + print_header("TEST 3: Hauptadresse nach Erstellung prüfen") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + try: + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + print_info(f"Gesamtanzahl Adressen: {len(all_addresses)}") + + # Finde neue Adresse + new_addr = next((a for a in all_addresses if a.get('rowId') == new_row_id), None) + + # Finde alte Hauptadresse + old_hauptadresse_now = None + if old_hauptadresse: + old_row_id = old_hauptadresse.get('rowId') + old_hauptadresse_now = next((a for a in all_addresses if a.get('rowId') == old_row_id), None) + + # Finde aktuelle Hauptadresse(n) + hauptadressen = [a for a in all_addresses if a.get('standardAnschrift')] + + print(f"\n{BOLD}Ergebnis:{RESET}") + print(f" Anzahl Adressen mit standardAnschrift = true: {len(hauptadressen)}") + + if new_addr: + print(f"\n{BOLD}Neue Adresse:{RESET}") + print(f" Index: {new_addr.get('reihenfolgeIndex')}") + print(f" Straße: {new_addr.get('strasse')}") + print(f" standardAnschrift: {new_addr.get('standardAnschrift')}") + print(f" rowId: {new_addr.get('rowId')}") + + if old_hauptadresse_now: + print(f"\n{BOLD}Alte Hauptadresse (vorher):{RESET}") + print(f" Index: {old_hauptadresse_now.get('reihenfolgeIndex')}") + print(f" Straße: {old_hauptadresse_now.get('strasse')}") + print(f" standardAnschrift: {old_hauptadresse_now.get('standardAnschrift')}") + + # Analyse + print(f"\n{BOLD}{'='*80}{RESET}") + print(f"{BOLD}ANALYSE:{RESET}\n") + + if new_addr and new_addr.get('standardAnschrift'): + print_success("✓✓✓ NEUE Adresse IST jetzt Hauptadresse!") + + if old_hauptadresse_now and not old_hauptadresse_now.get('standardAnschrift'): + print_success("✓ Alte Hauptadresse wurde DEAKTIVIERT (standardAnschrift = false)") + print_info("\n💡 ERKENNTNIS: Es gibt immer nur EINE Hauptadresse") + print_info("💡 Neue Adresse wird AUTOMATISCH zur Hauptadresse") + print_info("💡 Alte Hauptadresse wird automatisch deaktiviert") + elif old_hauptadresse_now and old_hauptadresse_now.get('standardAnschrift'): + print_warning("⚠ Alte Hauptadresse ist NOCH aktiv!") + print_warning("⚠ Es gibt jetzt ZWEI Hauptadressen!") + + elif new_addr and not new_addr.get('standardAnschrift'): + print_warning("⚠ Neue Adresse ist NICHT Hauptadresse") + + if old_hauptadresse_now and old_hauptadresse_now.get('standardAnschrift'): + print_success("✓ Alte Hauptadresse ist NOCH aktiv") + print_info("\n💡 ERKENNTNIS: Neue Adresse wird NICHT automatisch zur Hauptadresse") + print_info("💡 Hauptadresse muss explizit gesetzt werden") + + # Zeige alle Hauptadressen + if len(hauptadressen) > 0: + print(f"\n{BOLD}Alle Adressen mit standardAnschrift = true:{RESET}") + for ha in hauptadressen: + print(f"\n Index {ha.get('reihenfolgeIndex')}:") + print(f" Straße: {ha.get('strasse')}") + print(f" Ort: {ha.get('ort')}") + print(f" bemerkung: {ha.get('bemerkung', 'N/A')[:50]}...") + + # Sortier-Analyse + print(f"\n{BOLD}Reihenfolge-Analyse:{RESET}") + sorted_addresses = sorted(all_addresses, key=lambda a: a.get('reihenfolgeIndex', 0)) + + print(f" Erste Adresse (Index {sorted_addresses[0].get('reihenfolgeIndex')}):") + print(f" standardAnschrift: {sorted_addresses[0].get('standardAnschrift')}") + print(f" Straße: {sorted_addresses[0].get('strasse')}") + + print(f" Letzte Adresse (Index {sorted_addresses[-1].get('reihenfolgeIndex')}):") + print(f" standardAnschrift: {sorted_addresses[-1].get('standardAnschrift')}") + print(f" Straße: {sorted_addresses[-1].get('strasse')}") + + if sorted_addresses[-1].get('standardAnschrift'): + print_success("\n✓✓✓ BESTÄTIGT: Letzte (neueste) Adresse ist Hauptadresse!") + + except Exception as e: + print_error(f"Fehler: {e}") + import traceback + traceback.print_exc() + + +async def main(): + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ Hauptadresse-Logik Test (Advoware) ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print(f"Test-Konfiguration:") + print(f" BetNr: {TEST_BETNR}") + print(f" Datum: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" Hypothese: Neueste Adresse wird automatisch zur Hauptadresse") + + # Test 1: Aktuelle Hauptadresse + old_hauptadresse = await test_1_check_current_hauptadresse() + + # Test 2: Neue Adresse erstellen + new_row_id = await test_2_create_new_address() + + if not new_row_id: + print_error("\nTest abgebrochen: Konnte keine neue Adresse erstellen") + return + + # Kurze Pause (falls Advoware Zeit braucht) + await asyncio.sleep(1) + + # Test 3: Prüfe nach Erstellung + await test_3_check_after_creation(old_hauptadresse, new_row_id) + + print(f"\n{BOLD}╔══════════════════════════════════════════════════════════════╗{RESET}") + print(f"{BOLD}║ FAZIT ║{RESET}") + print(f"{BOLD}╚══════════════════════════════════════════════════════════════╝{RESET}\n") + + print_info("Basierend auf diesem Test können wir die Hauptadresse-Logik verstehen:") + print_info("1. Gibt es immer nur EINE Hauptadresse?") + print_info("2. Wird neue Adresse AUTOMATISCH zur Hauptadresse?") + print_info("3. Wird alte Hauptadresse deaktiviert?") + print_info("4. Ist die LETZTE Adresse immer die Hauptadresse?") + print() + print_info("→ Diese Erkenntnisse sind wichtig für Sync-Strategie!") + + print(f"\n{YELLOW}⚠️ Test-Adresse 'TEST-HAUPTADRESSE' sollte bereinigt werden.{RESET}\n") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/scripts/test_put_response_detail.py b/bitbylaw/scripts/test_put_response_detail.py new file mode 100644 index 00000000..debaf96b --- /dev/null +++ b/bitbylaw/scripts/test_put_response_detail.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +Test: Welche Felder sind bei PUT wirklich änderbar? +==================================================== +""" + +import asyncio +import sys +import os +import json + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +from services.advoware import AdvowareAPI + +TEST_BETNR = 104860 + +BOLD = '\033[1m' +RED = '\033[91m' +GREEN = '\033[92m' +YELLOW = '\033[93m' +BLUE = '\033[94m' +RESET = '\033[0m' + +def print_success(text): + print(f"{GREEN}✓ {text}{RESET}") + +def print_error(text): + print(f"{RED}✗ {text}{RESET}") + +def print_info(text): + print(f"{BLUE}ℹ {text}{RESET}") + +class SimpleLogger: + def info(self, msg): pass + def error(self, msg): pass + def debug(self, msg): pass + def warning(self, msg): pass + +class SimpleContext: + def __init__(self): + self.logger = SimpleLogger() + +async def main(): + print(f"\n{BOLD}=== PUT Response Analyse ==={RESET}\n") + + context = SimpleContext() + advo = AdvowareAPI(context=context) + + # Finde Test-Adresse + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + test_addr = None + for addr in all_addresses: + bemerkung = addr.get('bemerkung') or '' + if 'TEST-SOFTDELETE' in bemerkung: + test_addr = addr + break + + if not test_addr: + print_error("Test-Adresse nicht gefunden") + return + + index = test_addr.get('reihenfolgeIndex') + print_info(f"Test-Adresse Index: {index}") + + print_info("\nVORHER:") + print(json.dumps(test_addr, indent=2, ensure_ascii=False)) + + # PUT mit ALLEN Feldern inklusive gueltigBis + print_info("\n=== Sende PUT mit ALLEN Feldern ===") + + update_data = { + "strasse": "GEÄNDERT Straße", + "plz": "11111", + "ort": "GEÄNDERT Ort", + "land": "AT", + "postfach": "PF 123", + "postfachPLZ": "11112", + "anschrift": "GEÄNDERT Anschrift", + "standardAnschrift": True, + "bemerkung": "VERSUCH: bemerkung ändern", + "gueltigVon": "2025-01-01T00:00:00", # ← GEÄNDERT + "gueltigBis": "2027-12-31T23:59:59" # ← NEU GESETZT + } + + print(json.dumps(update_data, indent=2, ensure_ascii=False)) + + result = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen/{index}', + method='PUT', + json_data=update_data + ) + + print_info("\n=== PUT Response: ===") + print(json.dumps(result, indent=2, ensure_ascii=False)) + + # GET und vergleichen + print_info("\n=== GET nach PUT: ===") + all_addresses = await advo.api_call( + f'/api/v1/advonet/Beteiligte/{TEST_BETNR}/Adressen', + method='GET' + ) + + updated_addr = next((a for a in all_addresses if a.get('reihenfolgeIndex') == index), None) + if updated_addr: + print(json.dumps(updated_addr, indent=2, ensure_ascii=False)) + + print(f"\n{BOLD}=== VERGLEICH: Was wurde wirklich geändert? ==={RESET}\n") + + fields = ['strasse', 'plz', 'ort', 'land', 'postfach', 'postfachPLZ', + 'anschrift', 'standardAnschrift', 'bemerkung', 'gueltigVon', 'gueltigBis'] + + for field in fields: + sent = update_data.get(field) + received = updated_addr.get(field) + + if sent == received: + print_success(f"{field:20s}: ✓ GEÄNDERT → {received}") + else: + print_error(f"{field:20s}: ✗ NICHT geändert (sent: {sent}, got: {received})") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bitbylaw/services/adressen_mapper.py b/bitbylaw/services/adressen_mapper.py new file mode 100644 index 00000000..7ecf9e67 --- /dev/null +++ b/bitbylaw/services/adressen_mapper.py @@ -0,0 +1,266 @@ +""" +Adressen Mapper: EspoCRM CAdressen ↔ Advoware Adressen + +Transformiert Adressen zwischen den beiden Systemen. +Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12. +""" + +from typing import Dict, Any, Optional +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +class AdressenMapper: + """Mapper für CAdressen (EspoCRM) ↔ Adressen (Advoware)""" + + @staticmethod + def map_cadressen_to_advoware_create(espo_addr: Dict[str, Any]) -> Dict[str, Any]: + """ + Transformiert EspoCRM CAdressen → Advoware Adressen Format (CREATE/POST) + + Für CREATE werden ALLE 11 Felder gemappt (inkl. READ-ONLY bei PUT). + + Args: + espo_addr: CAdressen Entity von EspoCRM + + Returns: + Dict für Advoware POST /api/v1/advonet/Beteiligte/{betnr}/Adressen + """ + logger.debug(f"Mapping EspoCRM → Advoware (CREATE): {espo_addr.get('id')}") + + # Formatiere Anschrift (mehrzeilig) + anschrift = AdressenMapper._format_anschrift(espo_addr) + + advo_data = { + # R/W Felder (via PUT änderbar) + 'strasse': espo_addr.get('adresseStreet') or '', + 'plz': espo_addr.get('adressePostalCode') or '', + 'ort': espo_addr.get('adresseCity') or '', + 'anschrift': anschrift, + + # READ-ONLY Felder (nur bei CREATE!) + 'land': espo_addr.get('adresseCountry') or 'DE', + 'postfach': espo_addr.get('postfach'), + 'postfachPLZ': espo_addr.get('postfachPLZ'), + 'standardAnschrift': bool(espo_addr.get('isPrimary', False)), + 'bemerkung': f"EspoCRM-ID: {espo_addr['id']}", # WICHTIG für Matching! + 'gueltigVon': AdressenMapper._format_datetime(espo_addr.get('validFrom')), + 'gueltigBis': AdressenMapper._format_datetime(espo_addr.get('validUntil')) + } + + return advo_data + + @staticmethod + def map_cadressen_to_advoware_update(espo_addr: Dict[str, Any]) -> Dict[str, Any]: + """ + Transformiert EspoCRM CAdressen → Advoware Adressen Format (UPDATE/PUT) + + Für UPDATE werden NUR die 4 R/W Felder gemappt! + Alle anderen Änderungen müssen über Notifications gehandelt werden. + + Args: + espo_addr: CAdressen Entity von EspoCRM + + Returns: + Dict für Advoware PUT /api/v1/advonet/Beteiligte/{betnr}/Adressen/{index} + """ + logger.debug(f"Mapping EspoCRM → Advoware (UPDATE): {espo_addr.get('id')}") + + # NUR R/W Felder! + advo_data = { + 'strasse': espo_addr.get('adresseStreet') or '', + 'plz': espo_addr.get('adressePostalCode') or '', + 'ort': espo_addr.get('adresseCity') or '', + 'anschrift': AdressenMapper._format_anschrift(espo_addr) + } + + return advo_data + + @staticmethod + def map_advoware_to_cadressen(advo_addr: Dict[str, Any], + beteiligte_id: str, + existing_espo_addr: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """ + Transformiert Advoware Adressen → EspoCRM CAdressen Format + + Args: + advo_addr: Adresse von Advoware GET + beteiligte_id: EspoCRM CBeteiligte ID (für Relation) + existing_espo_addr: Existierende EspoCRM Entity (für Update) + + Returns: + Dict für EspoCRM API + """ + logger.debug(f"Mapping Advoware → EspoCRM: Index {advo_addr.get('reihenfolgeIndex')}") + + espo_data = { + # Core Adressfelder + 'adresseStreet': advo_addr.get('strasse'), + 'adressePostalCode': advo_addr.get('plz'), + 'adresseCity': advo_addr.get('ort'), + 'adresseCountry': advo_addr.get('land') or 'DE', + + # Zusatzfelder + 'postfach': advo_addr.get('postfach'), + 'postfachPLZ': advo_addr.get('postfachPLZ'), + 'description': advo_addr.get('bemerkung'), + + # Status-Felder + 'isPrimary': bool(advo_addr.get('standardAnschrift', False)), + 'validFrom': advo_addr.get('gueltigVon'), + 'validUntil': advo_addr.get('gueltigBis'), + + # Sync-Felder + 'advowareRowId': advo_addr.get('rowId'), + 'advowareLastSync': datetime.now().isoformat(), + 'syncStatus': 'synced', + + # Relation + 'beteiligteId': beteiligte_id + } + + # Preserve existing fields when updating + if existing_espo_addr: + espo_data['id'] = existing_espo_addr['id'] + # Keep existing isActive if not changed + if 'isActive' in existing_espo_addr: + espo_data['isActive'] = existing_espo_addr['isActive'] + else: + # New address + espo_data['isActive'] = True + + return espo_data + + @staticmethod + def detect_readonly_changes(espo_addr: Dict[str, Any], + advo_addr: Dict[str, Any]) -> list[Dict[str, Any]]: + """ + Erkenne Änderungen an READ-ONLY Feldern (nicht via PUT änderbar) + + Args: + espo_addr: EspoCRM CAdressen Entity + advo_addr: Advoware Adresse + + Returns: + Liste von Änderungen mit Feldnamen und Werten + """ + changes = [] + + # Mapping: EspoCRM-Feld → (Advoware-Feld, Label) + readonly_mappings = { + 'adresseCountry': ('land', 'Land'), + 'postfach': ('postfach', 'Postfach'), + 'postfachPLZ': ('postfachPLZ', 'Postfach PLZ'), + 'isPrimary': ('standardAnschrift', 'Hauptadresse'), + 'validFrom': ('gueltigVon', 'Gültig von'), + 'validUntil': ('gueltigBis', 'Gültig bis') + } + + for espo_field, (advo_field, label) in readonly_mappings.items(): + espo_value = espo_addr.get(espo_field) + advo_value = advo_addr.get(advo_field) + + # Normalisiere Werte für Vergleich + if espo_field == 'isPrimary': + espo_value = bool(espo_value) + advo_value = bool(advo_value) + elif espo_field in ['validFrom', 'validUntil']: + # Datetime-Vergleich (nur Datum) + espo_value = AdressenMapper._normalize_date(espo_value) + advo_value = AdressenMapper._normalize_date(advo_value) + + # Vergleiche + if espo_value != advo_value: + changes.append({ + 'field': label, + 'espoField': espo_field, + 'advoField': advo_field, + 'espoCRM_value': espo_value, + 'advoware_value': advo_value + }) + + return changes + + @staticmethod + def _format_anschrift(espo_addr: Dict[str, Any]) -> str: + """ + Formatiert mehrzeilige Anschrift für Advoware + + Format: + {Firmenname oder Name} + {Strasse} + {PLZ} {Ort} + """ + parts = [] + + # Zeile 1: Name + if espo_addr.get('firmenname'): + parts.append(espo_addr['firmenname']) + elif espo_addr.get('firstName') or espo_addr.get('lastName'): + name = f"{espo_addr.get('firstName', '')} {espo_addr.get('lastName', '')}".strip() + if name: + parts.append(name) + + # Zeile 2: Straße + if espo_addr.get('adresseStreet'): + parts.append(espo_addr['adresseStreet']) + + # Zeile 3: PLZ + Ort + plz = espo_addr.get('adressePostalCode', '').strip() + ort = espo_addr.get('adresseCity', '').strip() + if plz or ort: + parts.append(f"{plz} {ort}".strip()) + + return '\n'.join(parts) + + @staticmethod + def _format_datetime(dt: Any) -> Optional[str]: + """ + Formatiert Datetime für Advoware API (ISO 8601) + + Args: + dt: datetime object, ISO string, oder None + + Returns: + ISO 8601 string oder None + """ + if not dt: + return None + + if isinstance(dt, str): + # Bereits String - prüfe ob gültig + try: + datetime.fromisoformat(dt.replace('Z', '+00:00')) + return dt + except: + return None + + if isinstance(dt, datetime): + return dt.isoformat() + + return None + + @staticmethod + def _normalize_date(dt: Any) -> Optional[str]: + """ + Normalisiert Datum für Vergleich (nur Datum, keine Zeit) + + Returns: + YYYY-MM-DD string oder None + """ + if not dt: + return None + + if isinstance(dt, str): + try: + dt_obj = datetime.fromisoformat(dt.replace('Z', '+00:00')) + return dt_obj.strftime('%Y-%m-%d') + except: + return None + + if isinstance(dt, datetime): + return dt.strftime('%Y-%m-%d') + + return None diff --git a/bitbylaw/services/adressen_sync.py b/bitbylaw/services/adressen_sync.py new file mode 100644 index 00000000..fd459e7f --- /dev/null +++ b/bitbylaw/services/adressen_sync.py @@ -0,0 +1,514 @@ +""" +Adressen Synchronization: EspoCRM ↔ Advoware + +Synchronisiert CAdressen zwischen EspoCRM und Advoware. +Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12. + +SYNC-STRATEGIE: +- CREATE: Vollautomatisch (alle 11 Felder) +- UPDATE: Nur R/W Felder (strasse, plz, ort, anschrift) +- DELETE: Nur via Notification (kein API-DELETE verfügbar) +- READ-ONLY Änderungen: Nur via Notification +""" + +from typing import Dict, Any, Optional, List +from datetime import datetime +import logging + +from services.advoware import AdvowareAPI +from services.espocrm import EspoCRMAPI +from services.adressen_mapper import AdressenMapper +from services.notification_utils import NotificationManager + +logger = logging.getLogger(__name__) + + +class AdressenSync: + """Sync-Klasse für Adressen zwischen EspoCRM und Advoware""" + + def __init__(self, context=None): + """ + Initialize AdressenSync + + Args: + context: Application context mit logger + """ + self.context = context + self.advo = AdvowareAPI(context=context) + self.espo = EspoCRMAPI(context=context) + self.mapper = AdressenMapper() + self.notification_manager = NotificationManager(espocrm_api=self.espo, context=context) + + # ======================================================================== + # CREATE: EspoCRM → Advoware + # ======================================================================== + + async def create_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]: + """ + Erstelle neue Adresse in Advoware + + Alle 11 Felder werden synchronisiert (inkl. READ-ONLY). + + Args: + espo_addr: CAdressen Entity von EspoCRM + betnr: Advoware Beteiligte-Nummer + + Returns: + Erstellte Adresse oder None bei Fehler + """ + try: + espo_id = espo_addr['id'] + logger.info(f"Creating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}") + + # Map zu Advoware Format (alle Felder) + advo_data = self.mapper.map_cadressen_to_advoware_create(espo_addr) + + # POST zu Advoware + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='POST', + json_data=advo_data + ) + + # POST gibt Array zurück, nimm erste Adresse + if isinstance(result, list) and result: + created_addr = result[0] + else: + created_addr = result + + logger.info( + f"✓ Created address in Advoware: " + f"Index {created_addr.get('reihenfolgeIndex')}, " + f"EspoCRM ID {espo_id}" + ) + + # Update EspoCRM mit Sync-Info + await self._update_espo_sync_info(espo_id, created_addr, 'synced') + + return created_addr + + except Exception as e: + logger.error(f"Failed to create address: {e}", exc_info=True) + + # Update syncStatus + await self._update_espo_sync_status(espo_addr['id'], 'error') + + return None + + # ======================================================================== + # UPDATE: EspoCRM → Advoware (nur R/W Felder) + # ======================================================================== + + async def update_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]: + """ + Update Adresse in Advoware (nur R/W Felder) + + Nur strasse, plz, ort, anschrift werden geändert. + Alle anderen Änderungen → Notification. + + Args: + espo_addr: CAdressen Entity von EspoCRM + betnr: Advoware Beteiligte-Nummer + + Returns: + Aktualisierte Adresse oder None bei Fehler + """ + try: + espo_id = espo_addr['id'] + logger.info(f"Updating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}") + + # 1. Finde Adresse in Advoware via bemerkung (EINZIGE stabile Methode) + target = await self._find_address_by_espo_id(betnr, espo_id) + + if not target: + logger.warning(f"Address not found in Advoware: {espo_id} - creating new") + return await self.create_address(espo_addr, betnr) + + # 2. Map nur R/W Felder + rw_data = self.mapper.map_cadressen_to_advoware_update(espo_addr) + + # 3. PUT mit aktuellem reihenfolgeIndex (dynamisch!) + current_index = target['reihenfolgeIndex'] + + result = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{current_index}', + method='PUT', + json_data=rw_data + ) + + logger.info( + f"✓ Updated address in Advoware (R/W fields): " + f"Index {current_index}, EspoCRM ID {espo_id}" + ) + + # 4. Prüfe READ-ONLY Feld-Änderungen + readonly_changes = self.mapper.detect_readonly_changes(espo_addr, target) + + if readonly_changes: + logger.warning( + f"⚠ READ-ONLY fields changed for {espo_id}: " + f"{len(readonly_changes)} fields" + ) + await self._notify_readonly_changes(espo_addr, betnr, readonly_changes) + + # 5. Update EspoCRM mit Sync-Info + await self._update_espo_sync_info(espo_id, result, 'synced') + + return result + + except Exception as e: + logger.error(f"Failed to update address: {e}", exc_info=True) + + # Update syncStatus + await self._update_espo_sync_status(espo_addr['id'], 'error') + + return None + + # ======================================================================== + # DELETE: EspoCRM → Advoware (nur Notification) + # ======================================================================== + + async def handle_address_deletion(self, espo_addr: Dict[str, Any], betnr: int) -> bool: + """ + Handle Adress-Löschung (nur Notification) + + Kein API-DELETE verfügbar → Manuelle Löschung erforderlich. + + Args: + espo_addr: Gelöschte CAdressen Entity von EspoCRM + betnr: Advoware Beteiligte-Nummer + + Returns: + True wenn Notification erfolgreich + """ + try: + espo_id = espo_addr['id'] + logger.info(f"Handling address deletion for EspoCRM ID {espo_id}, BetNr {betnr}") + + # 1. Finde Adresse in Advoware + target = await self._find_address_by_espo_id(betnr, espo_id) + + if not target: + logger.info(f"Address already deleted or not found: {espo_id}") + return True + + # 2. Erstelle Notification für manuelle Löschung + await self.notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=espo_id, + action_type='address_delete_required', + details={ + 'message': 'Adresse in Advoware löschen', + 'description': ( + f'Adresse wurde in EspoCRM gelöscht:\n' + f'{target.get("strasse")}\n' + f'{target.get("plz")} {target.get("ort")}\n\n' + f'Bitte manuell in Advoware löschen:\n' + f'1. Öffne Beteiligten {betnr} in Advoware\n' + f'2. Gehe zu Adressen-Tab\n' + f'3. Lösche Adresse (Index {target.get("reihenfolgeIndex")})\n' + f'4. Speichern' + ), + 'advowareIndex': target.get('reihenfolgeIndex'), + 'betnr': betnr, + 'address': f"{target.get('strasse')}, {target.get('ort')}", + 'priority': 'Medium' + } + ) + + logger.info(f"✓ Created delete notification for address {espo_id}") + return True + + except Exception as e: + logger.error(f"Failed to handle address deletion: {e}", exc_info=True) + return False + + # ======================================================================== + # SYNC: Advoware → EspoCRM (vollständig) + # ======================================================================== + + async def sync_from_advoware(self, betnr: int, espo_beteiligte_id: str) -> Dict[str, int]: + """ + Synct alle Adressen von Advoware zu EspoCRM + + Alle Felder werden übernommen (Advoware = Master). + + Args: + betnr: Advoware Beteiligte-Nummer + espo_beteiligte_id: EspoCRM CBeteiligte ID + + Returns: + Dict mit Statistiken: created, updated, unchanged + """ + stats = {'created': 0, 'updated': 0, 'unchanged': 0, 'errors': 0} + + try: + logger.info(f"Syncing addresses from Advoware BetNr {betnr} → EspoCRM {espo_beteiligte_id}") + + # 1. Hole alle Adressen von Advoware + advo_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + logger.info(f"Found {len(advo_addresses)} addresses in Advoware") + + # 2. Hole existierende EspoCRM Adressen + import json + espo_addresses = await self.espo.list_entities( + 'CAdressen', + where=json.dumps([{ + 'type': 'equals', + 'attribute': 'beteiligteId', + 'value': espo_beteiligte_id + }]) + ) + + espo_addrs_by_id = {addr['id']: addr for addr in espo_addresses.get('list', [])} + + # 3. Sync jede Adresse + for advo_addr in advo_addresses: + try: + # Match via bemerkung + bemerkung = advo_addr.get('bemerkung', '') + + if 'EspoCRM-ID:' in bemerkung: + # Existierende Adresse + espo_id = bemerkung.split('EspoCRM-ID:')[1].strip().split()[0] + + if espo_id in espo_addrs_by_id: + # Update + result = await self._update_espo_address( + espo_id, + advo_addr, + espo_beteiligte_id, + espo_addrs_by_id[espo_id] + ) + if result: + stats['updated'] += 1 + else: + stats['errors'] += 1 + else: + logger.warning(f"EspoCRM address not found: {espo_id}") + stats['errors'] += 1 + else: + # Neue Adresse aus Advoware (kein EspoCRM-ID) + result = await self._create_espo_address(advo_addr, espo_beteiligte_id) + if result: + stats['created'] += 1 + else: + stats['errors'] += 1 + + except Exception as e: + logger.error(f"Failed to sync address: {e}", exc_info=True) + stats['errors'] += 1 + + logger.info( + f"✓ Sync complete: " + f"created={stats['created']}, " + f"updated={stats['updated']}, " + f"errors={stats['errors']}" + ) + + return stats + + except Exception as e: + logger.error(f"Failed to sync from Advoware: {e}", exc_info=True) + return stats + + # ======================================================================== + # HELPER METHODS + # ======================================================================== + + async def _find_address_by_espo_id(self, betnr: int, espo_id: str) -> Optional[Dict[str, Any]]: + """ + Finde Adresse in Advoware via bemerkung-Matching + + Args: + betnr: Advoware Beteiligte-Nummer + espo_id: EspoCRM CAdressen ID + + Returns: + Advoware Adresse oder None + """ + try: + all_addresses = await self.advo.api_call( + f'/api/v1/advonet/Beteiligte/{betnr}/Adressen', + method='GET' + ) + + bemerkung_match = f"EspoCRM-ID: {espo_id}" + + target = next( + (a for a in all_addresses + if bemerkung_match in (a.get('bemerkung') or '')), + None + ) + + return target + + except Exception as e: + logger.error(f"Failed to find address: {e}", exc_info=True) + return None + + async def _update_espo_sync_info(self, espo_id: str, advo_addr: Dict[str, Any], + status: str = 'synced') -> bool: + """ + Update Sync-Info in EspoCRM CAdressen + + Args: + espo_id: EspoCRM CAdressen ID + advo_addr: Advoware Adresse (für rowId) + status: syncStatus (nicht verwendet, da EspoCRM-Feld möglicherweise nicht existiert) + + Returns: + True wenn erfolgreich + """ + try: + update_data = { + 'advowareRowId': advo_addr.get('rowId'), + 'advowareLastSync': datetime.now().isoformat() + # syncStatus removed - Feld existiert möglicherweise nicht + } + + result = await self.espo.update_entity('CAdressen', espo_id, update_data) + return bool(result) + + except Exception as e: + logger.error(f"Failed to update sync info: {e}", exc_info=True) + return False + + async def _update_espo_sync_status(self, espo_id: str, status: str) -> bool: + """ + Update nur syncStatus in EspoCRM (optional - Feld möglicherweise nicht vorhanden) + + Args: + espo_id: EspoCRM CAdressen ID + status: syncStatus ('error', 'pending', etc.) + + Returns: + True wenn erfolgreich + """ + try: + # Feld möglicherweise nicht vorhanden - ignoriere Fehler + result = await self.espo.update_entity( + 'CAdressen', + espo_id, + {'description': f'Sync-Status: {status}'} # Als Workaround in description + ) + return bool(result) + + except Exception as e: + logger.error(f"Failed to update sync status: {e}", exc_info=True) + return False + + async def _notify_readonly_changes(self, espo_addr: Dict[str, Any], betnr: int, + changes: List[Dict[str, Any]]) -> bool: + """ + Erstelle Notification für READ-ONLY Feld-Änderungen + + Args: + espo_addr: EspoCRM CAdressen Entity + betnr: Advoware Beteiligte-Nummer + changes: Liste von Änderungen + + Returns: + True wenn Notification erfolgreich + """ + try: + change_details = '\n'.join([ + f"- {c['field']}: EspoCRM='{c['espoCRM_value']}' → " + f"Advoware='{c['advoware_value']}'" + for c in changes + ]) + + await self.notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=espo_addr['id'], + action_type='readonly_field_conflict', + details={ + 'message': f'{len(changes)} READ-ONLY Feld(er) geändert', + 'description': ( + f'Folgende Felder wurden in EspoCRM geändert, sind aber ' + f'READ-ONLY in Advoware und können nicht automatisch ' + f'synchronisiert werden:\n\n{change_details}\n\n' + f'Bitte manuell in Advoware anpassen:\n' + f'1. Öffne Beteiligten {betnr} in Advoware\n' + f'2. Gehe zu Adressen-Tab\n' + f'3. Passe die Felder manuell an\n' + f'4. Speichern' + ), + 'changes': changes, + 'address': f"{espo_addr.get('adresseStreet')}, " + f"{espo_addr.get('adresseCity')}", + 'betnr': betnr, + 'priority': 'High' + } + ) + + return True + + except Exception as e: + logger.error(f"Failed to create notification: {e}", exc_info=True) + return False + + async def _create_espo_address(self, advo_addr: Dict[str, Any], + beteiligte_id: str) -> Optional[str]: + """ + Erstelle neue Adresse in EspoCRM + + Args: + advo_addr: Advoware Adresse + beteiligte_id: EspoCRM CBeteiligte ID + + Returns: + EspoCRM ID oder None + """ + try: + espo_data = self.mapper.map_advoware_to_cadressen(advo_addr, beteiligte_id) + + result = await self.espo.create_entity('CAdressen', espo_data) + + if result and 'id' in result: + logger.info(f"✓ Created address in EspoCRM: {result['id']}") + return result['id'] + + return None + + except Exception as e: + logger.error(f"Failed to create EspoCRM address: {e}", exc_info=True) + return None + + async def _update_espo_address(self, espo_id: str, advo_addr: Dict[str, Any], + beteiligte_id: str, + existing: Dict[str, Any]) -> bool: + """ + Update existierende Adresse in EspoCRM + + Args: + espo_id: EspoCRM CAdressen ID + advo_addr: Advoware Adresse + beteiligte_id: EspoCRM CBeteiligte ID + existing: Existierende EspoCRM Entity + + Returns: + True wenn erfolgreich + """ + try: + espo_data = self.mapper.map_advoware_to_cadressen( + advo_addr, + beteiligte_id, + existing + ) + + result = await self.espo.update_entity('CAdressen', espo_id, espo_data) + + if result: + logger.info(f"✓ Updated address in EspoCRM: {espo_id}") + return True + + return False + + except Exception as e: + logger.error(f"Failed to update EspoCRM address: {e}", exc_info=True) + return False diff --git a/bitbylaw/services/notification_utils.py b/bitbylaw/services/notification_utils.py new file mode 100644 index 00000000..c778b4c0 --- /dev/null +++ b/bitbylaw/services/notification_utils.py @@ -0,0 +1,412 @@ +""" +Zentrale Notification-Utilities für manuelle Eingriffe +======================================================= + +Wenn Advoware-API-Limitierungen existieren (z.B. READ-ONLY Felder), +werden Notifications in EspoCRM erstellt, damit User manuelle Eingriffe +vornehmen können. + +Features: +- Notifications an assigned Users +- Task-Erstellung für manuelle Eingriffe +- Zentrale Verwaltung aller Notification-Types +""" + +from typing import Dict, Any, Optional, Literal, List +from datetime import datetime, timedelta +import logging + + +class NotificationManager: + """ + Zentrale Klasse für Notifications bei Sync-Problemen + """ + + def __init__(self, espocrm_api, context=None): + """ + Args: + espocrm_api: EspoCRMAPI instance + context: Optional context für Logging + """ + self.espocrm = espocrm_api + self.context = context + self.logger = context.logger if context else logging.getLogger(__name__) + + async def notify_manual_action_required( + self, + entity_type: str, + entity_id: str, + action_type: Literal[ + "address_delete_required", + "address_reactivate_required", + "address_field_update_required", + "readonly_field_conflict", + "missing_in_advoware", + "general_manual_action" + ], + details: Dict[str, Any], + assigned_user_id: Optional[str] = None, + create_task: bool = True + ) -> Dict[str, str]: + """ + Erstellt Notification und optional Task für manuelle Eingriffe + + Args: + entity_type: EspoCRM Entity Type (z.B. 'CAdressen', 'CBeteiligte') + entity_id: Entity ID in EspoCRM + action_type: Art der manuellen Aktion + details: Detaillierte Informationen + assigned_user_id: User der benachrichtigt werden soll (optional) + create_task: Ob zusätzlich ein Task erstellt werden soll + + Returns: + Dict mit notification_id und optional task_id + """ + try: + # Hole Entity-Daten + entity = await self.espocrm.get_entity(entity_type, entity_id) + entity_name = entity.get('name', f"{entity_type} {entity_id}") + + # Falls kein assigned_user, versuche aus Entity zu holen + if not assigned_user_id: + assigned_user_id = entity.get('assignedUserId') + + # Erstelle Notification + notification_data = self._build_notification_message( + action_type, entity_type, entity_name, details + ) + + notification_id = await self._create_notification( + user_id=assigned_user_id, + message=notification_data['message'], + entity_type=entity_type, + entity_id=entity_id + ) + + result = {'notification_id': notification_id} + + # Optional: Task erstellen + if create_task: + task_id = await self._create_task( + name=notification_data['task_name'], + description=notification_data['task_description'], + parent_type=entity_type, + parent_id=entity_id, + assigned_user_id=assigned_user_id, + priority=notification_data['priority'] + ) + result['task_id'] = task_id + + self.logger.info( + f"Manual action notification created: {action_type} for " + f"{entity_type}/{entity_id}" + ) + + return result + + except Exception as e: + self.logger.error(f"Failed to create notification: {e}") + raise + + def _build_notification_message( + self, + action_type: str, + entity_type: str, + entity_name: str, + details: Dict[str, Any] + ) -> Dict[str, str]: + """ + Erstellt Notification-Message basierend auf Action-Type + + Returns: + Dict mit 'message', 'task_name', 'task_description', 'priority' + """ + + if action_type == "address_delete_required": + return { + 'message': ( + f"🗑️ Adresse in Advoware löschen erforderlich\n" + f"Adresse: {entity_name}\n" + f"Grund: Advoware API unterstützt kein DELETE und gueltigBis ist READ-ONLY\n" + f"Bitte manuell in Advoware löschen oder deaktivieren." + ), + 'task_name': f"Adresse in Advoware löschen: {entity_name}", + 'task_description': ( + f"MANUELLE AKTION ERFORDERLICH\n\n" + f"Adresse: {entity_name}\n" + f"BetNr: {details.get('betnr', 'N/A')}\n" + f"Adresse: {details.get('strasse', '')}, {details.get('plz', '')} {details.get('ort', '')}\n\n" + f"GRUND:\n" + f"- DELETE API nicht verfügbar (403 Forbidden)\n" + f"- gueltigBis ist READ-ONLY (kann nicht nachträglich gesetzt werden)\n\n" + f"AKTION:\n" + f"1. In Advoware Web-Interface einloggen\n" + f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n" + f"3. Adresse suchen: {details.get('strasse', '')}\n" + f"4. Adresse löschen oder deaktivieren\n\n" + f"Nach Erledigung: Task als 'Completed' markieren." + ), + 'priority': 'Normal' + } + + elif action_type == "address_reactivate_required": + return { + 'message': ( + f"♻️ Adresse-Reaktivierung in Advoware erforderlich\n" + f"Adresse: {entity_name}\n" + f"Grund: gueltigBis kann nicht nachträglich geändert werden\n" + f"Bitte neue Adresse in Advoware erstellen." + ), + 'task_name': f"Neue Adresse in Advoware erstellen: {entity_name}", + 'task_description': ( + f"MANUELLE AKTION ERFORDERLICH\n\n" + f"Adresse: {entity_name}\n" + f"BetNr: {details.get('betnr', 'N/A')}\n\n" + f"GRUND:\n" + f"Diese Adresse wurde reaktiviert, aber die alte Adresse in Advoware " + f"ist abgelaufen (gueltigBis in Vergangenheit). Da gueltigBis READ-ONLY ist, " + f"muss eine neue Adresse erstellt werden.\n\n" + f"AKTION:\n" + f"1. In Advoware Web-Interface einloggen\n" + f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n" + f"3. Neue Adresse erstellen:\n" + f" - Straße: {details.get('strasse', '')}\n" + f" - PLZ: {details.get('plz', '')}\n" + f" - Ort: {details.get('ort', '')}\n" + f" - Land: {details.get('land', '')}\n" + f" - Bemerkung: EspoCRM-ID: {details.get('espocrm_id', '')}\n" + f"4. Sync erneut durchführen, damit Mapping aktualisiert wird\n\n" + f"Nach Erledigung: Task als 'Completed' markieren." + ), + 'priority': 'Normal' + } + + elif action_type == "address_field_update_required": + readonly_fields = details.get('readonly_fields', []) + return { + 'message': ( + f"⚠️ Adressfelder in Advoware können nicht aktualisiert werden\n" + f"Adresse: {entity_name}\n" + f"READ-ONLY Felder: {', '.join(readonly_fields)}\n" + f"Bitte manuell in Advoware ändern." + ), + 'task_name': f"Adressfelder in Advoware aktualisieren: {entity_name}", + 'task_description': ( + f"MANUELLE AKTION ERFORDERLICH\n\n" + f"Adresse: {entity_name}\n" + f"BetNr: {details.get('betnr', 'N/A')}\n\n" + f"GRUND:\n" + f"Folgende Felder sind in Advoware API READ-ONLY und können nicht " + f"via PUT geändert werden:\n" + f"- {', '.join(readonly_fields)}\n\n" + f"GEWÜNSCHTE ÄNDERUNGEN:\n" + + '\n'.join([f" - {k}: {v}" for k, v in details.get('changes', {}).items()]) + + f"\n\nAKTION:\n" + f"1. In Advoware Web-Interface einloggen\n" + f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n" + f"3. Adresse suchen und obige Felder manuell ändern\n" + f"4. Sync erneut durchführen zur Bestätigung\n\n" + f"Nach Erledigung: Task als 'Completed' markieren." + ), + 'priority': 'Low' + } + + elif action_type == "readonly_field_conflict": + return { + 'message': ( + f"⚠️ Sync-Konflikt bei READ-ONLY Feldern\n" + f"{entity_type}: {entity_name}\n" + f"Änderungen konnten nicht synchronisiert werden." + ), + 'task_name': f"Sync-Konflikt prüfen: {entity_name}", + 'task_description': ( + f"SYNC-KONFLIKT\n\n" + f"{entity_type}: {entity_name}\n\n" + f"PROBLEM:\n" + f"Felder wurden in EspoCRM geändert, sind aber in Advoware READ-ONLY.\n\n" + f"BETROFFENE FELDER:\n" + + '\n'.join([f" - {k}: {v}" for k, v in details.get('conflicts', {}).items()]) + + f"\n\nOPTIONEN:\n" + f"1. Änderungen in EspoCRM rückgängig machen (Advoware = Master)\n" + f"2. Änderungen manuell in Advoware vornehmen\n" + f"3. Feld als 'nicht synchronisiert' akzeptieren\n\n" + f"Nach Entscheidung: Task als 'Completed' markieren." + ), + 'priority': 'Normal' + } + + elif action_type == "missing_in_advoware": + return { + 'message': ( + f"❓ Element fehlt in Advoware\n" + f"{entity_type}: {entity_name}\n" + f"Bitte manuell in Advoware erstellen." + ), + 'task_name': f"In Advoware erstellen: {entity_name}", + 'task_description': ( + f"MANUELLE AKTION ERFORDERLICH\n\n" + f"{entity_type}: {entity_name}\n\n" + f"GRUND:\n" + f"Dieses Element existiert in EspoCRM, aber nicht in Advoware.\n" + f"Möglicherweise wurde es direkt in EspoCRM erstellt.\n\n" + f"DATEN:\n" + + '\n'.join([f" - {k}: {v}" for k, v in details.items() if k != 'espocrm_id']) + + f"\n\nAKTION:\n" + f"1. In Advoware Web-Interface einloggen\n" + f"2. Element mit obigen Daten manuell erstellen\n" + f"3. Sync erneut durchführen für Mapping\n\n" + f"Nach Erledigung: Task als 'Completed' markieren." + ), + 'priority': 'Normal' + } + + else: # general_manual_action + return { + 'message': ( + f"🔧 Manuelle Aktion erforderlich\n" + f"{entity_type}: {entity_name}\n" + f"{details.get('message', 'Bitte prüfen.')}" + ), + 'task_name': f"Manuelle Aktion: {entity_name}", + 'task_description': ( + f"MANUELLE AKTION ERFORDERLICH\n\n" + f"{entity_type}: {entity_name}\n\n" + f"{details.get('description', 'Keine Details verfügbar.')}" + ), + 'priority': details.get('priority', 'Normal') + } + + async def _create_notification( + self, + user_id: Optional[str], + message: str, + entity_type: str, + entity_id: str + ) -> str: + """ + Erstellt EspoCRM Notification (In-App) + + Returns: + notification_id + """ + if not user_id: + self.logger.warning("No user assigned - notification not created") + return None + + notification_data = { + 'type': 'Message', + 'message': message, + 'userId': user_id, + 'relatedType': entity_type, + 'relatedId': entity_id, + 'read': False + } + + try: + result = await self.espocrm.create_entity('Notification', notification_data) + return result.get('id') + except Exception as e: + self.logger.error(f"Failed to create notification: {e}") + return None + + async def _create_task( + self, + name: str, + description: str, + parent_type: str, + parent_id: str, + assigned_user_id: Optional[str], + priority: str = 'Normal' + ) -> str: + """ + Erstellt EspoCRM Task + + Returns: + task_id + """ + # Due Date: 7 Tage in Zukunft + due_date = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d') + + task_data = { + 'name': name, + 'description': description, + 'status': 'Not Started', + 'priority': priority, + 'dateEnd': due_date, + 'parentType': parent_type, + 'parentId': parent_id, + 'assignedUserId': assigned_user_id + } + + try: + result = await self.espocrm.create_entity('Task', task_data) + return result.get('id') + except Exception as e: + self.logger.error(f"Failed to create task: {e}") + return None + + async def resolve_task(self, task_id: str) -> bool: + """ + Markiert Task als erledigt + + Args: + task_id: Task ID + + Returns: + True wenn erfolgreich + """ + try: + await self.espocrm.update_entity('Task', task_id, { + 'status': 'Completed' + }) + return True + except Exception as e: + self.logger.error(f"Failed to complete task {task_id}: {e}") + return False + + +# Helper-Funktionen für häufige Use-Cases + +async def notify_address_delete_required( + notification_manager: NotificationManager, + address_entity_id: str, + betnr: str, + address_data: Dict[str, Any] +) -> Dict[str, str]: + """ + Shortcut: Notification für Adresse löschen + """ + return await notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=address_entity_id, + action_type='address_delete_required', + details={ + 'betnr': betnr, + 'strasse': address_data.get('adresseStreet'), + 'plz': address_data.get('adressePostalCode'), + 'ort': address_data.get('adresseCity'), + 'espocrm_id': address_entity_id + } + ) + + +async def notify_address_readonly_fields( + notification_manager: NotificationManager, + address_entity_id: str, + betnr: str, + readonly_fields: List[str], + changes: Dict[str, Any] +) -> Dict[str, str]: + """ + Shortcut: Notification für READ-ONLY Felder + """ + return await notification_manager.notify_manual_action_required( + entity_type='CAdressen', + entity_id=address_entity_id, + action_type='address_field_update_required', + details={ + 'betnr': betnr, + 'readonly_fields': readonly_fields, + 'changes': changes + } + )