Migrate VMH Integration - Phase 3: Core sync handlers & utilities

- Added 5 core service modules:
  * services/notification_utils.py: NotificationManager for manual actions (412 lines)
  * services/advoware_service.py: Extended Advoware operations wrapper
  * services/espocrm_mapper.py: BeteiligteMapper for data transformation (198 lines)
  * services/bankverbindungen_mapper.py: BankverbindungenMapper (174 lines)
  * services/beteiligte_sync_utils.py: BeteiligteSync with complex logic (663 lines)
    - Distributed locking via Redis + syncStatus
    - rowId-based change detection (primary) + timestamp fallback
    - Retry with exponential backoff (1min, 5min, 15min, 1h, 4h)
    - Conflict resolution (EspoCRM wins)
    - Soft-delete handling & round-trip validation

- Added 2 event handler steps:
  * steps/vmh/beteiligte_sync_event_step.py: Handles vmh.beteiligte.* queue events
    - CREATE: New Beteiligte → POST to Advoware
    - UPDATE: Bi-directional sync with conflict resolution
    - DELETE: Not yet implemented
    - SYNC_CHECK: Periodic sync check via cron
  * steps/vmh/bankverbindungen_sync_event_step.py: Handles vmh.bankverbindungen.* events
    - CREATE: New Bankverbindung → POST to Advoware
    - UPDATE/DELETE: Send notification (API limitation - no PUT/DELETE support)

- Added pytz dependency to pyproject.toml (required for timezone handling)

System Status:
- 27 steps registered (14 VMH-specific)
- 8 queue subscriptions active (4 Beteiligte + 4 Bankverbindungen)
- All webhook endpoints operational and emitting queue events
- Sync handlers ready for processing

Note: Kommunikation sync (kommunikation_sync_utils.py) not yet migrated.
Complex bi-directional sync for Telefon/Email/Fax will be added in Phase 4.

Updated MIGRATION_STATUS.md
This commit is contained in:
bsiggel
2026-03-01 22:19:36 +00:00
parent 0216c4c3ae
commit 014947e9e0
9 changed files with 2283 additions and 0 deletions

View File

@@ -0,0 +1,264 @@
"""
VMH Bankverbindungen Sync Handler
Zentraler Sync-Handler für Bankverbindungen (Webhooks + Cron Events)
Verarbeitet:
- vmh.bankverbindungen.create: Neu in EspoCRM → Create in Advoware
- vmh.bankverbindungen.update: Geändert in EspoCRM → Notification (nicht unterstützt)
- vmh.bankverbindungen.delete: Gelöscht in EspoCRM → Notification (nicht unterstützt)
- vmh.bankverbindungen.sync_check: Cron-Check → Sync wenn nötig
"""
from typing import Dict, Any, Optional
from motia import FlowContext
from services.advoware import AdvowareAPI
from services.espocrm import EspoCRMAPI
from services.bankverbindungen_mapper import BankverbindungenMapper
from services.notification_utils import NotificationManager
import json
import redis
import os
config = {
"name": "VMH Bankverbindungen Sync Handler",
"description": "Zentraler Sync-Handler für Bankverbindungen (Webhooks + Cron Events)",
"flows": ["vmh"],
"triggers": [
{"type": "queue", "topic": "vmh.bankverbindungen.create"},
{"type": "queue", "topic": "vmh.bankverbindungen.update"},
{"type": "queue", "topic": "vmh.bankverbindungen.delete"},
{"type": "queue", "topic": "vmh.bankverbindungen.sync_check"}
],
"enqueues": []
}
async def handler(event_data: Dict[str, Any], ctx: FlowContext[Any]):
"""Zentraler Sync-Handler für Bankverbindungen"""
entity_id = event_data.get('entity_id')
action = event_data.get('action', 'sync_check')
source = event_data.get('source', 'unknown')
if not entity_id:
ctx.logger.error("Keine entity_id im Event gefunden")
return
ctx.logger.info(f"🔄 Bankverbindungen Sync gestartet: {action.upper()} | Entity: {entity_id} | Source: {source}")
# Shared Redis client
redis_host = os.getenv('REDIS_HOST', 'localhost')
redis_port = int(os.getenv('REDIS_PORT', '6379'))
redis_db = int(os.getenv('REDIS_DB_ADVOWARE_CACHE', '1'))
redis_client = redis.Redis(
host=redis_host,
port=redis_port,
db=redis_db,
decode_responses=True
)
# APIs initialisieren
espocrm = EspoCRMAPI()
advoware = AdvowareAPI(ctx)
mapper = BankverbindungenMapper()
notification_mgr = NotificationManager(espocrm_api=espocrm, context=ctx)
try:
# 1. ACQUIRE LOCK
lock_key = f"sync_lock:cbankverbindungen:{entity_id}"
acquired = redis_client.set(lock_key, "locked", nx=True, ex=900) # 15min TTL
if not acquired:
ctx.logger.warn(f"⏸️ Sync bereits aktiv für {entity_id}, überspringe")
return
# 2. FETCH ENTITY VON ESPOCRM
try:
espo_entity = await espocrm.get_entity('CBankverbindungen', entity_id)
except Exception as e:
ctx.logger.error(f"❌ Fehler beim Laden von EspoCRM Entity: {e}")
redis_client.delete(lock_key)
return
ctx.logger.info(f"📋 Entity geladen: {espo_entity.get('name', 'Unbenannt')} (IBAN: {espo_entity.get('iban', 'N/A')})")
advoware_id = espo_entity.get('advowareId')
beteiligte_id = espo_entity.get('cBeteiligteId') # Parent Beteiligter
if not beteiligte_id:
ctx.logger.error(f"❌ Keine cBeteiligteId gefunden - Bankverbindung muss einem Beteiligten zugeordnet sein")
redis_client.delete(lock_key)
return
# Hole betNr vom Parent
parent = await espocrm.get_entity('CBeteiligte', beteiligte_id)
betnr = parent.get('betnr')
if not betnr:
ctx.logger.error(f"❌ Parent Beteiligter {beteiligte_id} hat keine betNr")
redis_client.delete(lock_key)
return
# 3. BESTIMME SYNC-AKTION
# FALL A: Neu (kein advowareId) → CREATE in Advoware
if not advoware_id and action in ['create', 'sync_check']:
await handle_create(entity_id, betnr, espo_entity, espocrm, advoware, mapper, ctx, redis_client, lock_key)
# FALL B: Existiert (hat advowareId) → UPDATE oder CHECK (nicht unterstützt!)
elif advoware_id and action in ['update', 'sync_check']:
await handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key)
# FALL C: DELETE (nicht unterstützt!)
elif action == 'delete':
await handle_delete(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key)
else:
ctx.logger.warn(f"⚠️ Unbekannte Kombination: action={action}, advowareId={advoware_id}")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
try:
redis_client.delete(lock_key)
except:
pass
async def handle_create(entity_id, betnr, espo_entity, espocrm, advoware, mapper, ctx, redis_client, lock_key):
"""Erstellt neue Bankverbindung in Advoware"""
try:
ctx.logger.info(f"🔨 CREATE Bankverbindung in Advoware für Beteiligter {betnr}...")
advo_data = mapper.map_cbankverbindungen_to_advoware(espo_entity)
ctx.logger.info(f"📤 Sende an Advoware: {json.dumps(advo_data, ensure_ascii=False)[:200]}...")
# POST zu Advoware (Beteiligten-spezifischer Endpoint!)
result = await advoware.api_call(
f'api/v1/advonet/Beteiligte/{betnr}/Bankverbindungen',
method='POST',
json_data=advo_data
)
# Extrahiere ID und rowId
if isinstance(result, list) and len(result) > 0:
new_entity = result[0]
elif isinstance(result, dict):
new_entity = result
else:
raise Exception(f"Unexpected response format: {result}")
new_id = new_entity.get('id')
new_rowid = new_entity.get('rowId')
if not new_id:
raise Exception(f"Keine ID in Advoware Response: {result}")
ctx.logger.info(f"✅ In Advoware erstellt: ID={new_id}, rowId={new_rowid[:20] if new_rowid else 'N/A'}...")
# Schreibe advowareId + rowId zurück
await espocrm.update_entity('CBankverbindungen', entity_id, {
'advowareId': new_id,
'advowareRowId': new_rowid
})
redis_client.delete(lock_key)
ctx.logger.info(f"✅ CREATE erfolgreich: {entity_id} → Advoware ID {new_id}")
except Exception as e:
ctx.logger.error(f"❌ CREATE fehlgeschlagen: {e}")
redis_client.delete(lock_key)
async def handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key):
"""Update nicht möglich - Sendet Notification an User"""
try:
ctx.logger.warn(f"⚠️ UPDATE: Advoware API unterstützt kein PUT für Bankverbindungen")
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
name = espo_entity.get('name', 'Unbenannt')
# Sende Notification
await notification_mgr.notify_manual_action_required(
entity_type='CBankverbindungen',
entity_id=entity_id,
action_type='general_manual_action',
details={
'message': f'UPDATE nicht möglich für Bankverbindung: {name}',
'description': (
f"Die Advoware API unterstützt keine Updates für Bankverbindungen.\n\n"
f"**Details:**\n"
f"- Bank: {bank}\n"
f"- IBAN: {iban}\n"
f"- Beteiligter betNr: {betnr}\n"
f"- Advoware ID: {advoware_id}\n\n"
f"**Workaround:**\n"
f"Löschen Sie die Bankverbindung in EspoCRM und erstellen Sie sie neu. "
f"Die neue Bankverbindung wird dann automatisch in Advoware angelegt."
),
'entity_name': name,
'priority': 'Normal'
},
create_task=True
)
ctx.logger.info(f"📧 Notification gesendet: Update-Limitation")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ UPDATE Notification fehlgeschlagen: {e}")
import traceback
ctx.logger.error(traceback.format_exc())
redis_client.delete(lock_key)
async def handle_delete(entity_id, betnr, advoware_id, espo_entity, espocrm, notification_mgr, ctx, redis_client, lock_key):
"""Delete nicht möglich - Sendet Notification an User"""
try:
ctx.logger.warn(f"⚠️ DELETE: Advoware API unterstützt kein DELETE für Bankverbindungen")
if not advoware_id:
ctx.logger.info(f" Keine advowareId vorhanden, nur EspoCRM-seitiges Delete")
redis_client.delete(lock_key)
return
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
name = espo_entity.get('name', 'Unbenannt')
# Sende Notification
await notification_mgr.notify_manual_action_required(
entity_type='CBankverbindungen',
entity_id=entity_id,
action_type='general_manual_action',
details={
'message': f'DELETE erforderlich für Bankverbindung: {name}',
'description': (
f"Die Advoware API unterstützt keine Löschungen für Bankverbindungen.\n\n"
f"**Bitte manuell in Advoware löschen:**\n"
f"- Bank: {bank}\n"
f"- IBAN: {iban}\n"
f"- Beteiligter betNr: {betnr}\n"
f"- Advoware ID: {advoware_id}\n\n"
f"Die Bankverbindung wurde in EspoCRM gelöscht, bleibt aber in Advoware "
f"bestehen bis zur manuellen Löschung."
),
'entity_name': name,
'priority': 'Normal'
},
create_task=True
)
ctx.logger.info(f"📧 Notification gesendet: Delete erforderlich")
redis_client.delete(lock_key)
except Exception as e:
ctx.logger.error(f"❌ DELETE Notification fehlgeschlagen: {e}")
redis_client.delete(lock_key)