Add sync strategy documentation and templates for bidirectional sync between EspoCRM and Advoware

- 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.
This commit is contained in:
2026-02-07 15:54:13 +00:00
parent 8550107b89
commit ae1d96f767
12 changed files with 1162 additions and 1069 deletions

View File

@@ -13,6 +13,8 @@ from typing import Dict, Any, Optional, Tuple, Literal
from datetime import datetime
import pytz
import logging
import redis
from config import Config
from services.espocrm import EspoCRMAPI
logger = logging.getLogger(__name__)
@@ -20,13 +22,34 @@ logger = logging.getLogger(__name__)
# Timestamp-Vergleich Ergebnis-Typen
TimestampResult = Literal["espocrm_newer", "advoware_newer", "conflict", "no_change"]
# Max retry before permanent failure
MAX_SYNC_RETRIES = 5
# Lock TTL in seconds (prevents deadlocks)
LOCK_TTL_SECONDS = 300 # 5 minutes
class BeteiligteSync:
"""Utility-Klasse für Beteiligte-Synchronisation"""
def __init__(self, espocrm_api: EspoCRMAPI, context=None):
def __init__(self, espocrm_api: EspoCRMAPI, redis_client: redis.Redis = None, context=None):
self.espocrm = espocrm_api
self.context = context
self.redis = redis_client or self._init_redis()
def _init_redis(self) -> redis.Redis:
"""Initialize Redis client for distributed locking"""
try:
client = redis.Redis(
host=Config.REDIS_HOST,
port=int(Config.REDIS_PORT),
db=int(Config.REDIS_DB_ADVOWARE_CACHE),
decode_responses=True
)
client.ping()
return client
except Exception as e:
self._log(f"Redis connection failed: {e}", level='error')
return None
def _log(self, message: str, level: str = 'info'):
"""Logging mit Context-Support"""
@@ -37,7 +60,7 @@ class BeteiligteSync:
async def acquire_sync_lock(self, entity_id: str) -> bool:
"""
Setzt syncStatus auf "syncing" (atomares Lock)
Atomic distributed lock via Redis + syncStatus update
Args:
entity_id: EspoCRM CBeteiligte ID
@@ -46,24 +69,32 @@ class BeteiligteSync:
True wenn Lock erfolgreich, False wenn bereits im Sync
"""
try:
entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
# STEP 1: Atomic Redis lock (prevents race conditions)
if self.redis:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
acquired = self.redis.set(lock_key, "locked", nx=True, ex=LOCK_TTL_SECONDS)
if not acquired:
self._log(f"Redis lock bereits aktiv für {entity_id}", level='warning')
return False
current_status = entity.get('syncStatus')
if current_status == 'syncing':
self._log(f"Entity {entity_id} bereits im Sync-Prozess", level='warning')
return False
# Setze Lock
# STEP 2: Update syncStatus (für UI visibility)
await self.espocrm.update_entity('CBeteiligte', entity_id, {
'syncStatus': 'syncing'
})
self._log(f"Sync-Lock für {entity_id} erworben (vorher: {current_status})")
self._log(f"Sync-Lock für {entity_id} erworben")
return True
except Exception as e:
self._log(f"Fehler beim Acquire Lock: {e}", level='error')
# Clean up Redis lock on error
if self.redis:
try:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
self.redis.delete(lock_key)
except:
pass
return False
async def release_sync_lock(
@@ -71,16 +102,18 @@ class BeteiligteSync:
entity_id: str,
new_status: str = 'clean',
error_message: Optional[str] = None,
increment_retry: bool = False
increment_retry: bool = False,
extra_fields: Optional[Dict[str, Any]] = None
) -> None:
"""
Gibt Sync-Lock frei und setzt finalen Status
Gibt Sync-Lock frei und setzt finalen Status (kombiniert mit extra fields)
Args:
entity_id: EspoCRM CBeteiligte ID
new_status: Neuer syncStatus (clean, failed, conflict, etc.)
error_message: Optional: Fehlermeldung für syncErrorMessage
increment_retry: Ob syncRetryCount erhöht werden soll
extra_fields: Optional: Zusätzliche Felder für EspoCRM update (z.B. betnr)
"""
try:
update_data = {
@@ -93,20 +126,48 @@ class BeteiligteSync:
else:
update_data['syncErrorMessage'] = None
# Handle retry count
if increment_retry:
# Hole aktuellen Retry-Count
entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
current_retry = entity.get('syncRetryCount') or 0
update_data['syncRetryCount'] = current_retry + 1
new_retry = current_retry + 1
update_data['syncRetryCount'] = new_retry
# Check max retries - mark as permanently failed
if new_retry >= MAX_SYNC_RETRIES:
update_data['syncStatus'] = 'permanently_failed'
await self.send_notification(
entity_id,
f"Sync fehlgeschlagen nach {MAX_SYNC_RETRIES} Versuchen. Manuelle Prüfung erforderlich.",
notification_type='error'
)
self._log(f"Max retries ({MAX_SYNC_RETRIES}) erreicht für {entity_id}", level='error')
else:
update_data['syncRetryCount'] = 0
# Merge extra fields (e.g., betnr from create operation)
if extra_fields:
update_data.update(extra_fields)
await self.espocrm.update_entity('CBeteiligte', entity_id, update_data)
self._log(f"Sync-Lock released: {entity_id}{new_status}")
# Release Redis lock
if self.redis:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
self.redis.delete(lock_key)
except Exception as e:
self._log(f"Fehler beim Release Lock: {e}", level='error')
# Ensure Redis lock is released even on error
if self.redis:
try:
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
self.redis.delete(lock_key)
except:
pass
@staticmethod
def parse_timestamp(ts: Any) -> Optional[datetime]:
@@ -211,10 +272,49 @@ class BeteiligteSync:
# Keine Änderungen
return "no_change"
def merge_for_advoware_put(
self,
advo_entity: Dict[str, Any],
espo_entity: Dict[str, Any],
mapper
) -> Dict[str, Any]:
"""
Merged EspoCRM updates mit Advoware entity für PUT operation
Advoware benötigt vollständige Objekte für PUT (Read-Modify-Write pattern).
Diese Funktion merged die gemappten EspoCRM-Updates in das bestehende
Advoware-Objekt.
Args:
advo_entity: Aktuelles Advoware entity (vollständiges Objekt)
espo_entity: EspoCRM entity mit Updates
mapper: BeteiligteMapper instance
Returns:
Merged dict für Advoware PUT
"""
# Map EspoCRM → Advoware (nur Stammdaten)
advo_updates = mapper.map_cbeteiligte_to_advoware(espo_entity)
# Merge: Advoware entity als Base, überschreibe mit EspoCRM updates
merged = {**advo_entity, **advo_updates}
# Logging
self._log(
f"📝 Merge: {len(advo_updates)} Stammdaten-Felder → {len(merged)} Gesamt-Felder",
level='info'
)
self._log(
f" Gesynct: {', '.join(advo_updates.keys())}",
level='debug'
)
return merged
async def send_notification(
self,
entity_id: str,
notification_type: Literal["conflict", "deleted"],
notification_type: Literal["conflict", "deleted", "error"],
extra_data: Optional[Dict[str, Any]] = None
) -> None:
"""