- Introduced SYNC_STRATEGY_ARCHIVE.md detailing the sync process, status values, and flow for updating entities from EspoCRM to Advoware and vice versa. - Created SYNC_TEMPLATE.md as a guide for implementing new syncs, including field definitions, mapper examples, sync utilities, event handlers, and cron jobs. - Added README_SYNC.md for the Beteiligte sync event handler, outlining its functionality, event subscriptions, optimizations, error handling, and performance metrics.
281 lines
11 KiB
Python
281 lines
11 KiB
Python
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}")
|
|
|
|
# Shared Redis client for distributed locking
|
|
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, redis_client, 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}")
|
|
|
|
# OPTIMIERT: Kombiniere release_lock + betnr update in 1 API call
|
|
await sync_utils.release_sync_lock(
|
|
entity_id,
|
|
'clean',
|
|
error_message=None,
|
|
extra_fields={'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")
|
|
|
|
# OPTIMIERT: Use merge utility (reduces code duplication)
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
|
|
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")
|
|
|
|
# OPTIMIERT: Use merge utility
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
|
|
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)")
|
|
|
|
# OPTIMIERT: Use merge utility
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
|
|
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) |