""" 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 KONFLIKT-BEHANDLUNG (wie bei Beteiligten): - rowId-basierte Änderungserkennung (Advoware rowId ändert sich bei jedem PUT) - Timestamp-Vergleich für EspoCRM (modifiedAt vs advowareLastSync) - Bei Konflikt (beide geändert): EspoCRM GEWINNT IMMER! - Notification bei Konflikt mit Details """ 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 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) # ======================================================================== # KONFLIKT-ERKENNUNG # ======================================================================== def compare_addresses(self, espo_addr: Dict[str, Any], advo_addr: Dict[str, Any]) -> str: """ Vergleicht Änderungen zwischen EspoCRM und Advoware Adresse Nutzt die gleiche Strategie wie bei Beteiligten: - PRIMÄR: rowId-Vergleich (Advoware rowId ändert sich bei jedem PUT) - FALLBACK: Keine Änderung wenn rowId gleich Args: espo_addr: EspoCRM CAdressen Entity advo_addr: Advoware Adresse Returns: "espocrm_newer": EspoCRM wurde seit letztem Sync geändert "advoware_newer": Advoware wurde seit letztem Sync geändert "conflict": Beide wurden seit letztem Sync geändert (EspoCRM gewinnt!) "no_change": Keine Änderungen """ espo_rowid = espo_addr.get('advowareRowId') advo_rowid = advo_addr.get('rowId') last_sync = espo_addr.get('advowareLastSync') espo_modified = espo_addr.get('modifiedAt') # SPECIAL CASE: Kein lastSync → Initial Sync (EspoCRM bevorzugen) if not last_sync: logger.debug("Initial Sync (kein lastSync) → EspoCRM bevorzugt") return 'espocrm_newer' if espo_rowid and advo_rowid: # Prüfe ob Advoware geändert wurde (rowId) advo_changed = (espo_rowid != advo_rowid) # Prüfe ob EspoCRM auch geändert wurde (seit letztem Sync) espo_changed = False if espo_modified and last_sync: try: # Parse Timestamps (ISO 8601 Format) espo_ts = datetime.fromisoformat(espo_modified.replace('Z', '+00:00')) if isinstance(espo_modified, str) else espo_modified sync_ts = datetime.fromisoformat(last_sync.replace('Z', '+00:00')) if isinstance(last_sync, str) else last_sync espo_changed = (espo_ts > sync_ts) except Exception as e: logger.debug(f"Timestamp-Parse-Fehler: {e}") # Konfliktlogik: Beide geändert seit letztem Sync? if advo_changed and espo_changed: logger.warning("🚨 KONFLIKT: Beide Seiten haben Adresse geändert seit letztem Sync") return 'conflict' elif advo_changed: logger.info(f"Advoware rowId geändert: {espo_rowid[:20]}... → {advo_rowid[:20]}...") return 'advoware_newer' elif espo_changed: logger.info("EspoCRM neuer (modifiedAt > lastSync)") return 'espocrm_newer' else: # Keine Änderungen logger.debug("Keine Änderungen (rowId identisch)") return 'no_change' # FALLBACK: Kein rowId vorhanden → konservativ EspoCRM bevorzugen logger.debug("rowId nicht verfügbar → EspoCRM bevorzugt") return 'espocrm_newer' # ======================================================================== # 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. Mit Konflikt-Erkennung: Wenn beide Seiten geändert → EspoCRM gewinnt! 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. KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden comparison = self.compare_addresses(espo_addr, target) if comparison == 'no_change': logger.info(f"⏭ No changes detected, skipping update: {espo_id}") return target if comparison == 'advoware_newer': logger.info(f"⬇ Advoware neuer, sync Advoware → EspoCRM statt Update") # Advoware hat sich geändert, aber EspoCRM nicht # → Sync in andere Richtung (aktualisiere EspoCRM) await self._update_espo_address( espo_id, target, espo_addr.get('beteiligteId'), espo_addr ) return target # comparison ist 'espocrm_newer' oder 'conflict' # In beiden Fällen: EspoCRM gewinnt! if comparison == 'conflict': logger.warning( f"⚠️ KONFLIKT erkannt für Adresse {espo_id} - EspoCRM gewinnt! " f"Überschreibe Advoware." ) # 3. Map nur R/W Felder rw_data = self.mapper.map_cadressen_to_advoware_update(espo_addr) # 4. 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 ) # Extrahiere neue rowId aus Response new_rowid = None if isinstance(result, list) and result: new_rowid = result[0].get('rowId') elif isinstance(result, dict): new_rowid = result.get('rowId') logger.info( f"✓ Updated address in Advoware (R/W fields): " f"Index {current_index}, EspoCRM ID {espo_id}, neue rowId: {new_rowid[:20] if new_rowid else 'N/A'}..." ) # 5. Bei Konflikt: Notification erstellen if comparison == 'conflict': await self._notify_conflict( espo_addr, betnr, target, f"Advoware rowId: {target.get('rowId', 'N/A')[:20]}..., " f"EspoCRM modifiedAt: {espo_addr.get('modifiedAt', 'N/A')}" ) # 6. 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) # 7. Update EspoCRM mit neuer Sync-Info (inkl. neuer rowId!) result_with_rowid = result[0] if isinstance(result, list) and result else result if new_rowid and isinstance(result_with_rowid, dict): result_with_rowid['rowId'] = new_rowid await self._update_espo_sync_info(espo_id, result_with_rowid, 'synced') return result_with_rowid 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: espo_addr = espo_addrs_by_id[espo_id] # KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden comparison = self.compare_addresses(espo_addr, advo_addr) if comparison == 'no_change': logger.debug(f"⏭ No changes detected, skipping: {espo_id}") stats['unchanged'] += 1 elif comparison == 'espocrm_newer': # EspoCRM ist neuer → Skip (wird von EspoCRM Webhook behandelt) logger.info(f"⬆ EspoCRM neuer, skip sync: {espo_id}") stats['unchanged'] += 1 elif comparison == 'conflict': # Konflikt: EspoCRM gewinnt → Skip Update (EspoCRM bleibt Master) logger.warning( f"⚠️ KONFLIKT: EspoCRM ist Master, skip Advoware→EspoCRM update: {espo_id}" ) stats['unchanged'] += 1 else: # comparison == 'advoware_newer': Update EspoCRM result = await self._update_espo_address( espo_id, advo_addr, espo_beteiligte_id, espo_addr ) 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_conflict(self, espo_addr: Dict[str, Any], betnr: int, advo_addr: Dict[str, Any], conflict_details: str) -> bool: """ Erstelle Notification für Adress-Konflikt Args: espo_addr: EspoCRM CAdressen Entity betnr: Advoware Beteiligte-Nummer advo_addr: Advoware Adresse (für Details) conflict_details: Details zum Konflikt Returns: True wenn Notification erfolgreich """ try: await self.notification_manager.notify_manual_action_required( entity_type='CAdressen', entity_id=espo_addr['id'], action_type='address_sync_conflict', details={ 'message': 'Sync-Konflikt bei Adresse (EspoCRM hat gewonnen)', 'description': ( f'Sowohl EspoCRM als auch Advoware haben diese Adresse seit ' f'dem letzten Sync geändert.\n\n' f'EspoCRM hat Vorrang - Änderungen wurden nach Advoware übertragen.\n\n' f'Details:\n{conflict_details}\n\n' f'Bitte prüfen Sie die Daten in Advoware und stellen Sie sicher, ' f'dass keine wichtigen Änderungen verloren gegangen sind.' ), 'address': f"{espo_addr.get('adresseStreet')}, {espo_addr.get('adresseCity')}", 'betnr': betnr, 'priority': 'High' } ) return True except Exception as e: logger.error(f"Failed to create conflict notification: {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