from services.advoware import AdvowareAPI from services.espocrm import EspoCRMAPI from services.espocrm_mapper import BeteiligteMapper from services.beteiligte_sync_utils import BeteiligteSync import json import redis from config import Config config = { 'type': 'event', 'name': 'VMH Beteiligte Sync Handler', 'description': 'Zentraler Sync-Handler für Beteiligte (Webhooks + Cron Events)', 'subscribes': [ 'vmh.beteiligte.create', 'vmh.beteiligte.update', 'vmh.beteiligte.delete', 'vmh.beteiligte.sync_check' # Von Cron ], 'flows': ['vmh'], 'emits': [] } async def handler(event_data, context): """ Zentraler Sync-Handler für Beteiligte Verarbeitet: - vmh.beteiligte.create: Neu in EspoCRM → Create in Advoware - vmh.beteiligte.update: Geändert in EspoCRM → Update in Advoware - vmh.beteiligte.delete: Gelöscht in EspoCRM → Delete in Advoware - vmh.beteiligte.sync_check: Cron-Check → Sync wenn nötig """ entity_id = event_data.get('entity_id') action = event_data.get('action', 'sync_check') source = event_data.get('source', 'unknown') if not entity_id: context.logger.error("Keine entity_id im Event gefunden") return context.logger.info(f"🔄 Sync-Handler gestartet: {action.upper()} | Entity: {entity_id} | Source: {source}") # Redis für Queue-Management redis_client = redis.Redis( host=Config.REDIS_HOST, port=int(Config.REDIS_PORT), db=int(Config.REDIS_DB_ADVOWARE_CACHE), decode_responses=True ) # APIs initialisieren espocrm = EspoCRMAPI() advoware = AdvowareAPI(context) sync_utils = BeteiligteSync(espocrm, context) mapper = BeteiligteMapper() try: # 1. ACQUIRE LOCK (verhindert parallele Syncs) lock_acquired = await sync_utils.acquire_sync_lock(entity_id) if not lock_acquired: context.logger.warning(f"⏸️ Sync bereits aktiv für {entity_id}, überspringe") return # 2. FETCH ENTITY VON ESPOCRM try: espo_entity = await espocrm.get_entity('CBeteiligte', entity_id) except Exception as e: context.logger.error(f"❌ Fehler beim Laden von EspoCRM Entity: {e}") await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True) return context.logger.info(f"📋 Entity geladen: {espo_entity.get('name')} (betnr: {espo_entity.get('betnr')})") betnr = espo_entity.get('betnr') sync_status = espo_entity.get('syncStatus', 'pending_sync') # 3. BESTIMME SYNC-AKTION # FALL A: Neu (kein betnr) → CREATE in Advoware if not betnr and action in ['create', 'sync_check']: context.logger.info(f"🆕 Neuer Beteiligter → CREATE in Advoware") await handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, context) # FALL B: Existiert (hat betnr) → UPDATE oder CHECK elif betnr: context.logger.info(f"♻️ Existierender Beteiligter (betNr: {betnr}) → UPDATE/CHECK") await handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, context) # FALL C: DELETE (TODO: Implementierung später) elif action == 'delete': context.logger.warning(f"🗑️ DELETE noch nicht implementiert für {entity_id}") await sync_utils.release_sync_lock(entity_id, 'failed', 'Delete-Operation nicht implementiert') else: context.logger.warning(f"⚠️ Unbekannte Kombination: action={action}, betnr={betnr}") await sync_utils.release_sync_lock(entity_id, 'failed', f'Unbekannte Aktion: {action}') # Redis Queue Cleanup pending_key = f'vmh:beteiligte:{action}_pending' redis_client.srem(pending_key, entity_id) except Exception as e: context.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}") import traceback context.logger.error(traceback.format_exc()) try: await sync_utils.release_sync_lock( entity_id, 'failed', f'Unerwarteter Fehler: {str(e)[:1900]}', increment_retry=True ) except: pass async def handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, context): """Erstellt neuen Beteiligten in Advoware""" try: context.logger.info(f"🔨 CREATE in Advoware...") # Transform zu Advoware Format advo_data = mapper.map_cbeteiligte_to_advoware(espo_entity) context.logger.info(f"📤 Sende an Advoware: {json.dumps(advo_data, ensure_ascii=False)[:200]}...") # POST zu Advoware result = await advoware.api_call( 'api/v1/advonet/Beteiligte', method='POST', data=advo_data ) # Extrahiere betNr aus Response new_betnr = result.get('betNr') if isinstance(result, dict) else None if not new_betnr: raise Exception(f"Keine betNr in Advoware Response: {result}") context.logger.info(f"✅ In Advoware erstellt: betNr={new_betnr}") # Update EspoCRM mit neuer betNr await sync_utils.release_sync_lock(entity_id, 'clean', error_message=None) await espocrm.update_entity('CBeteiligte', entity_id, { 'betnr': new_betnr }) context.logger.info(f"✅ CREATE erfolgreich: {entity_id} → betNr {new_betnr}") except Exception as e: context.logger.error(f"❌ CREATE fehlgeschlagen: {e}") await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True) async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_utils, mapper, context): """Synchronisiert existierenden Beteiligten""" try: context.logger.info(f"🔍 Fetch von Advoware betNr={betnr}...") # Fetch von Advoware try: advo_result = await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}', method='GET' ) # Advoware gibt manchmal Listen zurück if isinstance(advo_result, list): advo_entity = advo_result[0] if advo_result else None else: advo_entity = advo_result if not advo_entity: raise Exception(f"Beteiligter betNr={betnr} nicht gefunden") except Exception as e: # 404 oder anderer Fehler → Beteiligter wurde in Advoware gelöscht if '404' in str(e) or 'nicht gefunden' in str(e).lower(): context.logger.warning(f"🗑️ Beteiligter in Advoware gelöscht: betNr={betnr}") await sync_utils.handle_advoware_deleted(entity_id, str(e)) return else: raise context.logger.info(f"📥 Von Advoware geladen: {advo_entity.get('name')}") # TIMESTAMP-VERGLEICH comparison = sync_utils.compare_timestamps( espo_entity.get('modifiedAt'), advo_entity.get('geaendertAm'), espo_entity.get('advowareLastSync') ) context.logger.info(f"⏱️ Timestamp-Vergleich: {comparison}") # SPECIAL: Wenn LastSync null → immer von EspoCRM syncen (initial sync) if not espo_entity.get('advowareLastSync'): context.logger.info(f"📤 Initial Sync → EspoCRM STAMMDATEN zu Advoware") # WICHTIG: Advoware benötigt vollständiges Objekt für PUT # Mapper liefert nur STAMMDATEN (keine Kontaktdaten - die kommen später über separate Endpoints) advo_updates = mapper.map_cbeteiligte_to_advoware(espo_entity) # Merge mit aktuellen Advoware-Daten merged_data = {**advo_entity, **advo_updates} context.logger.info(f"📝 Merge: {len(advo_updates)} Stammdaten-Felder → {len(merged_data)} Gesamt-Felder") context.logger.debug(f" Gesynct: {', '.join(advo_updates.keys())}") await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}', method='PUT', data=merged_data ) await sync_utils.release_sync_lock(entity_id, 'clean') context.logger.info(f"✅ Advoware aktualisiert (initial sync)") return # KEIN SYNC NÖTIG if comparison == 'no_change': context.logger.info(f"✅ Keine Änderungen, Sync übersprungen") await sync_utils.release_sync_lock(entity_id, 'clean') return # ESPOCRM NEUER → Update Advoware if comparison == 'espocrm_newer': context.logger.info(f"📤 EspoCRM ist neuer → Update Advoware STAMMDATEN") # WICHTIG: Advoware benötigt vollständiges Objekt für PUT # Mapper liefert nur STAMMDATEN (keine Kontaktdaten - die kommen über separate Endpoints) advo_updates = mapper.map_cbeteiligte_to_advoware(espo_entity) # Merge mit aktuellen Advoware-Daten merged_data = {**advo_entity, **advo_updates} context.logger.info(f"📝 Merge: {len(advo_updates)} Stammdaten-Felder → {len(merged_data)} Gesamt-Felder") context.logger.debug(f" Gesynct: {', '.join(advo_updates.keys())}") await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}', method='PUT', data=merged_data ) await sync_utils.release_sync_lock(entity_id, 'clean') context.logger.info(f"✅ Advoware aktualisiert") # ADVOWARE NEUER → Update EspoCRM elif comparison == 'advoware_newer': context.logger.info(f"📥 Advoware ist neuer → Update EspoCRM STAMMDATEN") espo_data = mapper.map_advoware_to_cbeteiligte(advo_entity) await espocrm.update_entity('CBeteiligte', entity_id, espo_data) await sync_utils.release_sync_lock(entity_id, 'clean') context.logger.info(f"✅ EspoCRM aktualisiert") # KONFLIKT → EspoCRM WINS elif comparison == 'conflict': context.logger.warning(f"⚠️ KONFLIKT erkannt → EspoCRM WINS (STAMMDATEN)") # Überschreibe Advoware mit EspoCRM (merge mit aktuellen Daten) advo_updates = mapper.map_cbeteiligte_to_advoware(espo_entity) merged_data = {**advo_entity, **advo_updates} context.logger.info(f"📝 Merge: {len(advo_updates)} Stammdaten-Felder → {len(merged_data)} Gesamt-Felder") await advoware.api_call( f'api/v1/advonet/Beteiligte/{betnr}', method='PUT', data=merged_data ) conflict_msg = ( f"EspoCRM: {espo_entity.get('modifiedAt')}, " f"Advoware: {advo_entity.get('geaendertAm')}. " f"EspoCRM hat gewonnen." ) await sync_utils.resolve_conflict_espocrm_wins( entity_id, espo_entity, advo_entity, conflict_msg ) context.logger.info(f"✅ Konflikt gelöst: EspoCRM → Advoware") except Exception as e: context.logger.error(f"❌ UPDATE fehlgeschlagen: {e}") import traceback context.logger.error(traceback.format_exc()) await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)