- 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.
267 lines
9.2 KiB
Python
267 lines
9.2 KiB
Python
"""
|
|
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
|