- 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.
515 lines
19 KiB
Python
515 lines
19 KiB
Python
"""
|
|
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
|