- 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.
1647 lines
51 KiB
Markdown
1647 lines
51 KiB
Markdown
# 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
|
|
|