feat: Implement VMH Bankverbindungen sync handlers and API steps for create, update, and delete operations

This commit is contained in:
2026-02-08 12:49:14 +00:00
parent 709456301c
commit 68c8b398aa
7 changed files with 649 additions and 21 deletions

View File

@@ -1,25 +1,4 @@
[ [
{
"id": "vmh",
"config": {
"steps/vmh/beteiligte_sync_event_step.py": {
"x": 805,
"y": 188
},
"steps/vmh/webhook/beteiligte_update_api_step.py": {
"x": 13,
"y": 154
},
"steps/vmh/webhook/beteiligte_delete_api_step.py": {
"x": 14,
"y": -72
},
"steps/vmh/webhook/beteiligte_create_api_step.py": {
"x": 7,
"y": 373
}
}
},
{ {
"id": "advoware_cal_sync", "id": "advoware_cal_sync",
"config": { "config": {
@@ -107,5 +86,42 @@
"y": 0 "y": 0
} }
} }
},
{
"id": "vmh",
"config": {
"steps/vmh/beteiligte_sync_event_step.py": {
"x": 805,
"y": 188
},
"steps/vmh/bankverbindungen_sync_event_step.py": {
"x": 350,
"y": 1006
},
"steps/vmh/webhook/beteiligte_update_api_step.py": {
"x": 13,
"y": 154
},
"steps/vmh/webhook/beteiligte_delete_api_step.py": {
"x": 14,
"y": -72
},
"steps/vmh/webhook/beteiligte_create_api_step.py": {
"x": 7,
"y": 373
},
"steps/vmh/webhook/bankverbindungen_update_api_step.py": {
"x": 0,
"y": 729
},
"steps/vmh/webhook/bankverbindungen_delete_api_step.py": {
"x": 0,
"y": 972
},
"steps/vmh/webhook/bankverbindungen_create_api_step.py": {
"x": 0,
"y": 1215
}
}
} }
] ]

View File

@@ -0,0 +1,174 @@
"""
EspoCRM ↔ Advoware Bankverbindungen Mapper
Transformiert Bankverbindungen zwischen den beiden Systemen
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class BankverbindungenMapper:
"""Mapper für CBankverbindungen (EspoCRM) ↔ Bankverbindung (Advoware)"""
@staticmethod
def map_cbankverbindungen_to_advoware(espo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CBankverbindungen → Advoware Bankverbindung Format
Args:
espo_entity: CBankverbindungen Entity von EspoCRM
Returns:
Dict für Advoware API (POST/PUT /api/v1/advonet/Beteiligte/{id}/Bankverbindungen)
"""
logger.debug(f"Mapping EspoCRM → Advoware Bankverbindung: {espo_entity.get('id')}")
advo_data = {}
# Bankname
bank = espo_entity.get('bank')
if bank:
advo_data['bank'] = bank
# Kontonummer (deprecated, aber noch supported)
kto_nr = espo_entity.get('kontoNummer')
if kto_nr:
advo_data['ktoNr'] = kto_nr
# BLZ (deprecated, aber noch supported)
blz = espo_entity.get('blz')
if blz:
advo_data['blz'] = blz
# IBAN
iban = espo_entity.get('iban')
if iban:
advo_data['iban'] = iban
# BIC
bic = espo_entity.get('bic')
if bic:
advo_data['bic'] = bic
# Kontoinhaber
kontoinhaber = espo_entity.get('kontoinhaber')
if kontoinhaber:
advo_data['kontoinhaber'] = kontoinhaber
# SEPA Mandat
mandatsreferenz = espo_entity.get('mandatsreferenz')
if mandatsreferenz:
advo_data['mandatsreferenz'] = mandatsreferenz
mandat_vom = espo_entity.get('mandatVom')
if mandat_vom:
advo_data['mandatVom'] = mandat_vom
logger.debug(f"Mapped to Advoware: IBAN={advo_data.get('iban')}, Bank={advo_data.get('bank')}")
return advo_data
@staticmethod
def map_advoware_to_cbankverbindungen(advo_entity: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert Advoware Bankverbindung → EspoCRM CBankverbindungen Format
Args:
advo_entity: Bankverbindung von Advoware API
Returns:
Dict für EspoCRM API (POST/PUT /api/v1/CBankverbindungen)
"""
logger.debug(f"Mapping Advoware → EspoCRM: id={advo_entity.get('id')}")
espo_data = {
'advowareId': advo_entity.get('id'), # Link zu Advoware
'advowareRowId': advo_entity.get('rowId'), # Änderungserkennung
}
# Bankname
bank = advo_entity.get('bank')
if bank:
espo_data['bank'] = bank
# Kontonummer
kto_nr = advo_entity.get('ktoNr')
if kto_nr:
espo_data['kontoNummer'] = kto_nr
# BLZ
blz = advo_entity.get('blz')
if blz:
espo_data['blz'] = blz
# IBAN
iban = advo_entity.get('iban')
if iban:
espo_data['iban'] = iban
# BIC
bic = advo_entity.get('bic')
if bic:
espo_data['bic'] = bic
# Kontoinhaber
kontoinhaber = advo_entity.get('kontoinhaber')
if kontoinhaber:
espo_data['kontoinhaber'] = kontoinhaber
# SEPA Mandat
mandatsreferenz = advo_entity.get('mandatsreferenz')
if mandatsreferenz:
espo_data['mandatsreferenz'] = mandatsreferenz
mandat_vom = advo_entity.get('mandatVom')
if mandat_vom:
# Konvertiere DateTime zu Date (EspoCRM Format: YYYY-MM-DD)
espo_data['mandatVom'] = mandat_vom.split('T')[0] if 'T' in mandat_vom else mandat_vom
logger.debug(f"Mapped to EspoCRM: IBAN={espo_data.get('iban')}")
# Entferne None-Werte (EspoCRM Validierung)
espo_data = {k: v for k, v in espo_data.items() if v is not None}
return espo_data
@staticmethod
def get_changed_fields(espo_entity: Dict[str, Any], advo_entity: Dict[str, Any]) -> List[str]:
"""
Vergleicht zwei Entities und gibt Liste der geänderten Felder zurück
Args:
espo_entity: EspoCRM CBankverbindungen
advo_entity: Advoware Bankverbindung
Returns:
Liste von Feldnamen die unterschiedlich sind
"""
mapped_advo = BankverbindungenMapper.map_advoware_to_cbankverbindungen(advo_entity)
changed = []
compare_fields = [
'bank', 'iban', 'bic', 'kontoNummer', 'blz',
'kontoinhaber', 'mandatsreferenz', 'mandatVom',
'advowareId', 'advowareRowId'
]
for field in compare_fields:
espo_val = espo_entity.get(field)
advo_val = mapped_advo.get(field)
# Normalisiere None und leere Strings
espo_val = espo_val if espo_val else None
advo_val = advo_val if advo_val else None
if espo_val != advo_val:
changed.append(field)
logger.debug(f"Field '{field}' changed: EspoCRM='{espo_val}' vs Advoware='{advo_val}'")
return changed

View File

@@ -0,0 +1,251 @@
from services.advoware import AdvowareAPI
from services.espocrm import EspoCRMAPI
from services.bankverbindungen_mapper import BankverbindungenMapper
from services.beteiligte_sync_utils import BeteiligteSync
import json
import redis
from config import Config
config = {
'type': 'event',
'name': 'VMH Bankverbindungen Sync Handler',
'description': 'Zentraler Sync-Handler für Bankverbindungen (Webhooks + Cron Events)',
'subscribes': [
'vmh.bankverbindungen.create',
'vmh.bankverbindungen.update',
'vmh.bankverbindungen.delete',
'vmh.bankverbindungen.sync_check'
],
'flows': ['vmh'],
'emits': []
}
async def handler(event_data, context):
"""
Zentraler Sync-Handler für Bankverbindungen
Verarbeitet:
- vmh.bankverbindungen.create: Neu in EspoCRM → Create in Advoware
- vmh.bankverbindungen.update: Geändert in EspoCRM → Update in Advoware
- vmh.bankverbindungen.delete: Gelöscht in EspoCRM → Delete in Advoware
- vmh.bankverbindungen.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"🔄 Bankverbindungen Sync gestartet: {action.upper()} | Entity: {entity_id} | Source: {source}")
# Shared Redis client
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) # Reuse utils
mapper = BankverbindungenMapper()
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:
context.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:
context.logger.error(f"❌ Fehler beim Laden von EspoCRM Entity: {e}")
redis_client.delete(lock_key)
return
context.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:
context.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:
context.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, context, redis_client, lock_key)
# FALL B: Existiert (hat advowareId) → UPDATE oder CHECK
elif advoware_id:
await handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, advoware, mapper, context, redis_client, lock_key)
# FALL C: DELETE
elif action == 'delete':
await handle_delete(entity_id, betnr, advoware_id, espocrm, advoware, context, redis_client, lock_key)
else:
context.logger.warn(f"⚠️ Unbekannte Kombination: action={action}, advowareId={advoware_id}")
redis_client.delete(lock_key)
except Exception as e:
context.logger.error(f"❌ Unerwarteter Fehler im Sync-Handler: {e}")
import traceback
context.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, context, redis_client, lock_key):
"""Erstellt neue Bankverbindung in Advoware"""
try:
context.logger.info(f"🔨 CREATE Bankverbindung in Advoware für Beteiligter {betnr}...")
advo_data = mapper.map_cbankverbindungen_to_advoware(espo_entity)
context.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',
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}")
context.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)
context.logger.info(f"✅ CREATE erfolgreich: {entity_id} → Advoware ID {new_id}")
except Exception as e:
context.logger.error(f"❌ CREATE fehlgeschlagen: {e}")
redis_client.delete(lock_key)
async def handle_update(entity_id, betnr, advoware_id, espo_entity, espocrm, advoware, mapper, context, redis_client, lock_key):
"""Update nicht möglich - Sendet Notification an User"""
try:
context.logger.warn(f"⚠️ UPDATE: Advoware API unterstützt kein PUT für Bankverbindungen")
# Erstelle Notification für User in EspoCRM
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
notification_message = (
f"Bankverbindung wurde in EspoCRM geändert, aber die Advoware API unterstützt keine Updates.\n\n"
f"**Bitte manuell in Advoware aktualisieren:**\n"
f"- Bank: {bank}\n"
f"- IBAN: {iban}\n"
f"- Beteiligter betNr: {betnr}\n"
f"- Advoware ID: {advoware_id}\n\n"
f"**Workaround:** Löschen und neu erstellen in EspoCRM, dann wird neue Bankverbindung in Advoware angelegt."
)
# Sende Notification via EspoCRM API
await espocrm.api_call('/Notification', method='POST', json_data={
'type': 'message',
'message': notification_message,
'userId': espo_entity.get('createdById') or espo_entity.get('modifiedById'),
'relatedType': 'CBankverbindungen',
'relatedId': entity_id
})
context.logger.info(f"📧 Notification an User gesendet: Manuelle Aktualisierung erforderlich")
redis_client.delete(lock_key)
except Exception as e:
context.logger.error(f"❌ UPDATE Notification fehlgeschlagen: {e}")
import traceback
context.logger.error(traceback.format_exc())
redis_client.delete(lock_key)
async def handle_delete(entity_id, betnr, advoware_id, espocrm, advoware, context, redis_client, lock_key):
"""Delete nicht möglich - Sendet Notification an User"""
try:
context.logger.warn(f"⚠️ DELETE: Advoware API unterstützt kein DELETE für Bankverbindungen")
if not advoware_id:
context.logger.info(f" Keine advowareId vorhanden, nur EspoCRM-seitiges Delete")
redis_client.delete(lock_key)
return
# Hole Entity-Details für Notification (vor dem Delete)
try:
espo_entity = await espocrm.get_entity('CBankverbindungen', entity_id)
iban = espo_entity.get('iban', 'N/A')
bank = espo_entity.get('bank', 'N/A')
user_id = espo_entity.get('createdById') or espo_entity.get('modifiedById')
except:
iban = 'N/A'
bank = 'N/A'
user_id = None
# Erstelle Notification für User in EspoCRM
notification_message = (
f"Bankverbindung wurde in EspoCRM gelöscht, aber die Advoware API unterstützt keine Löschungen.\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 bleibt in Advoware bestehen bis zur manuellen Löschung."
)
# Sende Notification
if user_id:
await espocrm.api_call('/Notification', method='POST', json_data={
'type': 'message',
'message': notification_message,
'userId': user_id
})
context.logger.info(f"📧 Notification an User gesendet: Manuelle Löschung erforderlich")
redis_client.delete(lock_key)
except Exception as e:
context.logger.error(f"❌ DELETE Notification fehlgeschlagen: {e}")
redis_client.delete(lock_key)

View File

@@ -0,0 +1,61 @@
import json
import datetime
config = {
'type': 'api',
'name': 'VMH Webhook Bankverbindungen Create',
'description': 'Empfängt Create-Webhooks von EspoCRM für Bankverbindungen',
'path': '/vmh/webhook/bankverbindungen/create',
'method': 'POST',
'flows': ['vmh'],
'emits': ['vmh.bankverbindungen.create']
}
async def handler(req, context):
try:
payload = req.get('body', [])
context.logger.info("VMH Webhook Bankverbindungen Create empfangen")
context.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Sammle alle IDs aus dem Batch
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
context.logger.info(f"{len(entity_ids)} IDs zum Create-Sync gefunden")
# Emittiere Events
for entity_id in entity_ids:
await context.emit({
'topic': 'vmh.bankverbindungen.create',
'data': {
'entity_id': entity_id,
'action': 'create',
'source': 'webhook',
'timestamp': req.get('timestamp') or datetime.datetime.now().isoformat()
}
})
context.logger.info(f"VMH Create Webhook verarbeitet: {len(entity_ids)} Events emittiert")
return {
'status': 200,
'body': {
'status': 'received',
'action': 'create',
'ids_count': len(entity_ids)
}
}
except Exception as e:
context.logger.error(f"Fehler beim Verarbeiten des VMH Create Webhooks: {e}")
return {
'status': 500,
'body': {'error': str(e)}
}

View File

@@ -0,0 +1,61 @@
import json
import datetime
config = {
'type': 'api',
'name': 'VMH Webhook Bankverbindungen Delete',
'description': 'Empfängt Delete-Webhooks von EspoCRM für Bankverbindungen',
'path': '/vmh/webhook/bankverbindungen/delete',
'method': 'POST',
'flows': ['vmh'],
'emits': ['vmh.bankverbindungen.delete']
}
async def handler(req, context):
try:
payload = req.get('body', [])
context.logger.info("VMH Webhook Bankverbindungen Delete empfangen")
context.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Sammle alle IDs
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
context.logger.info(f"{len(entity_ids)} IDs zum Delete-Sync gefunden")
# Emittiere Events
for entity_id in entity_ids:
await context.emit({
'topic': 'vmh.bankverbindungen.delete',
'data': {
'entity_id': entity_id,
'action': 'delete',
'source': 'webhook',
'timestamp': req.get('timestamp') or datetime.datetime.now().isoformat()
}
})
context.logger.info(f"VMH Delete Webhook verarbeitet: {len(entity_ids)} Events emittiert")
return {
'status': 200,
'body': {
'status': 'received',
'action': 'delete',
'ids_count': len(entity_ids)
}
}
except Exception as e:
context.logger.error(f"Fehler beim Verarbeiten des VMH Delete Webhooks: {e}")
return {
'status': 500,
'body': {'error': str(e)}
}

View File

@@ -0,0 +1,61 @@
import json
import datetime
config = {
'type': 'api',
'name': 'VMH Webhook Bankverbindungen Update',
'description': 'Empfängt Update-Webhooks von EspoCRM für Bankverbindungen',
'path': '/vmh/webhook/bankverbindungen/update',
'method': 'POST',
'flows': ['vmh'],
'emits': ['vmh.bankverbindungen.update']
}
async def handler(req, context):
try:
payload = req.get('body', [])
context.logger.info("VMH Webhook Bankverbindungen Update empfangen")
context.logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}")
# Sammle alle IDs
entity_ids = set()
if isinstance(payload, list):
for entity in payload:
if isinstance(entity, dict) and 'id' in entity:
entity_ids.add(entity['id'])
elif isinstance(payload, dict) and 'id' in payload:
entity_ids.add(payload['id'])
context.logger.info(f"{len(entity_ids)} IDs zum Update-Sync gefunden")
# Emittiere Events
for entity_id in entity_ids:
await context.emit({
'topic': 'vmh.bankverbindungen.update',
'data': {
'entity_id': entity_id,
'action': 'update',
'source': 'webhook',
'timestamp': req.get('timestamp') or datetime.datetime.now().isoformat()
}
})
context.logger.info(f"VMH Update Webhook verarbeitet: {len(entity_ids)} Events emittiert")
return {
'status': 200,
'body': {
'status': 'received',
'action': 'update',
'ids_count': len(entity_ids)
}
}
except Exception as e:
context.logger.error(f"Fehler beim Verarbeiten des VMH Update Webhooks: {e}")
return {
'status': 500,
'body': {'error': str(e)}
}

4
bitbylaw/types.d.ts vendored
View File

@@ -13,9 +13,13 @@ declare module 'motia' {
interface Handlers { interface Handlers {
'VMH Beteiligte Sync Handler': EventHandler<never, never> 'VMH Beteiligte Sync Handler': EventHandler<never, never>
'VMH Bankverbindungen Sync Handler': EventHandler<never, never>
'VMH Webhook Beteiligte Update': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.update'; data: never }> 'VMH Webhook Beteiligte Update': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.update'; data: never }>
'VMH Webhook Beteiligte Delete': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.delete'; data: never }> 'VMH Webhook Beteiligte Delete': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.delete'; data: never }>
'VMH Webhook Beteiligte Create': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.create'; data: never }> 'VMH Webhook Beteiligte Create': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.beteiligte.create'; data: never }>
'VMH Webhook Bankverbindungen Update': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.bankverbindungen.update'; data: never }>
'VMH Webhook Bankverbindungen Delete': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.bankverbindungen.delete'; data: never }>
'VMH Webhook Bankverbindungen Create': ApiRouteHandler<Record<string, unknown>, unknown, { topic: 'vmh.bankverbindungen.create'; data: never }>
'Advoware Proxy PUT': ApiRouteHandler<Record<string, unknown>, unknown, never> 'Advoware Proxy PUT': ApiRouteHandler<Record<string, unknown>, unknown, never>
'Advoware Proxy POST': ApiRouteHandler<Record<string, unknown>, unknown, never> 'Advoware Proxy POST': ApiRouteHandler<Record<string, unknown>, unknown, never>
'Advoware Proxy GET': ApiRouteHandler<Record<string, unknown>, unknown, never> 'Advoware Proxy GET': ApiRouteHandler<Record<string, unknown>, unknown, never>