""" 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