feat(sync): Implement auto-reset for permanently_failed entities and add retry backoff logic
- Added logic to reset permanently_failed entities that have reached their auto-reset threshold in `beteiligte_sync_cron_step.py`. - Enhanced event handling in `beteiligte_sync_event_step.py` to skip retries if the next retry time has not been reached. - Introduced validation checks after sync operations to ensure data consistency and integrity. - Created detailed documentation outlining the fixes and their impacts on the sync process. - Added scripts for analyzing sync issues and comparing entities to facilitate debugging and validation.
This commit is contained in:
@@ -27,6 +27,10 @@ TimestampResult = Literal["espocrm_newer", "advoware_newer", "conflict", "no_cha
|
||||
MAX_SYNC_RETRIES = 5
|
||||
# Lock TTL in seconds (prevents deadlocks)
|
||||
LOCK_TTL_SECONDS = 900 # 15 minutes
|
||||
# Retry backoff: Wartezeit zwischen Retries (in Minuten)
|
||||
RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h
|
||||
# Auto-Reset nach 24h (für permanently_failed entities)
|
||||
AUTO_RESET_HOURS = 24
|
||||
|
||||
|
||||
class BeteiligteSync:
|
||||
@@ -141,17 +145,35 @@ class BeteiligteSync:
|
||||
new_retry = current_retry + 1
|
||||
update_data['syncRetryCount'] = new_retry
|
||||
|
||||
# FIX #12: Exponential backoff - berechne nächsten Retry-Zeitpunkt
|
||||
if new_retry <= len(RETRY_BACKOFF_MINUTES):
|
||||
backoff_minutes = RETRY_BACKOFF_MINUTES[new_retry - 1]
|
||||
else:
|
||||
backoff_minutes = RETRY_BACKOFF_MINUTES[-1] # Letzte Backoff-Zeit
|
||||
|
||||
from datetime import timedelta
|
||||
next_retry = now_utc + timedelta(minutes=backoff_minutes)
|
||||
update_data['syncNextRetry'] = next_retry.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
self._log(f"Retry {new_retry}/{MAX_SYNC_RETRIES}, nächster Versuch in {backoff_minutes} Minuten")
|
||||
|
||||
# Check max retries - mark as permanently failed
|
||||
if new_retry >= MAX_SYNC_RETRIES:
|
||||
update_data['syncStatus'] = 'permanently_failed'
|
||||
|
||||
# FIX #12: Auto-Reset Timestamp für Wiederherstellung nach 24h
|
||||
auto_reset_time = now_utc + timedelta(hours=AUTO_RESET_HOURS)
|
||||
update_data['syncAutoResetAt'] = auto_reset_time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
await self.send_notification(
|
||||
entity_id,
|
||||
f"Sync fehlgeschlagen nach {MAX_SYNC_RETRIES} Versuchen. Manuelle Prüfung erforderlich.",
|
||||
f"Sync fehlgeschlagen nach {MAX_SYNC_RETRIES} Versuchen. Auto-Reset in {AUTO_RESET_HOURS}h.",
|
||||
notification_type='error'
|
||||
)
|
||||
self._log(f"Max retries ({MAX_SYNC_RETRIES}) erreicht für {entity_id}", level='error')
|
||||
self._log(f"Max retries ({MAX_SYNC_RETRIES}) erreicht für {entity_id}, Auto-Reset um {auto_reset_time}", level='error')
|
||||
else:
|
||||
update_data['syncRetryCount'] = 0
|
||||
update_data['syncNextRetry'] = None
|
||||
|
||||
# Merge extra fields (e.g., betnr from create operation)
|
||||
if extra_fields:
|
||||
@@ -247,9 +269,31 @@ class BeteiligteSync:
|
||||
last_sync = espo_entity.get('advowareLastSync')
|
||||
espo_modified = espo_entity.get('modifiedAt')
|
||||
|
||||
# SPECIAL CASE: Kein lastSync → Initial Sync (EspoCRM→Advoware)
|
||||
# SPECIAL CASE: Kein lastSync → Initial Sync
|
||||
# FIX #11: Vergleiche Timestamps statt blind EspoCRM zu bevorzugen
|
||||
if not last_sync:
|
||||
self._log(f"Initial Sync (kein lastSync) → EspoCRM neuer")
|
||||
self._log(f"Initial Sync (kein lastSync) → Vergleiche Timestamps")
|
||||
|
||||
# Wenn beide Timestamps vorhanden, vergleiche sie
|
||||
if espo_ts and advo_ts:
|
||||
if espo_ts > advo_ts:
|
||||
self._log(f"Initial Sync: EspoCRM neuer ({espo_ts} > {advo_ts})")
|
||||
return 'espocrm_newer'
|
||||
elif advo_ts > espo_ts:
|
||||
self._log(f"Initial Sync: Advoware neuer ({advo_ts} > {espo_ts})")
|
||||
return 'advoware_newer'
|
||||
else:
|
||||
self._log(f"Initial Sync: Beide gleich alt")
|
||||
return 'no_change'
|
||||
|
||||
# Fallback: Wenn nur einer Timestamp hat, bevorzuge den
|
||||
if espo_ts and not advo_ts:
|
||||
return 'espocrm_newer'
|
||||
if advo_ts and not espo_ts:
|
||||
return 'advoware_newer'
|
||||
|
||||
# Wenn keine Timestamps verfügbar: EspoCRM bevorzugen (default)
|
||||
self._log(f"Initial Sync: Keine Timestamps verfügbar → EspoCRM bevorzugt")
|
||||
return 'espocrm_newer'
|
||||
|
||||
if espo_rowid and advo_rowid:
|
||||
@@ -496,6 +540,81 @@ class BeteiligteSync:
|
||||
except Exception as e:
|
||||
self._log(f"Fehler beim Handle Deleted: {e}", level='error')
|
||||
|
||||
async def validate_sync_result(
|
||||
self,
|
||||
entity_id: str,
|
||||
betnr: int,
|
||||
mapper,
|
||||
direction: str = 'to_advoware'
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
FIX #13: Validiert Sync-Ergebnis durch Round-Trip Verification
|
||||
|
||||
Args:
|
||||
entity_id: EspoCRM CBeteiligte ID
|
||||
betnr: Advoware betNr
|
||||
mapper: BeteiligteMapper instance
|
||||
direction: 'to_advoware' oder 'to_espocrm'
|
||||
|
||||
Returns:
|
||||
(success: bool, error_message: Optional[str])
|
||||
"""
|
||||
try:
|
||||
self._log(f"🔍 Validiere Sync-Ergebnis (direction={direction})...", level='debug')
|
||||
|
||||
# Lade beide Entities erneut
|
||||
espo_entity = await self.espocrm.get_entity('CBeteiligte', entity_id)
|
||||
|
||||
from services.advoware import AdvowareAPI
|
||||
advoware_api = AdvowareAPI(self.context)
|
||||
advo_result = await advoware_api.api_call(f'api/v1/advonet/Beteiligte/{betnr}', method='GET')
|
||||
|
||||
if isinstance(advo_result, list):
|
||||
advo_entity = advo_result[0] if advo_result else None
|
||||
else:
|
||||
advo_entity = advo_result
|
||||
|
||||
if not advo_entity:
|
||||
return False, f"Advoware Entity {betnr} nicht gefunden nach Sync"
|
||||
|
||||
# Validiere Stammdaten
|
||||
critical_fields = ['name', 'rechtsform']
|
||||
differences = []
|
||||
|
||||
if direction == 'to_advoware':
|
||||
# EspoCRM → Advoware: Prüfe ob Advoware die EspoCRM-Werte hat
|
||||
advo_mapped = mapper.map_cbeteiligte_to_advoware(espo_entity)
|
||||
|
||||
for field in critical_fields:
|
||||
espo_val = advo_mapped.get(field)
|
||||
advo_val = advo_entity.get(field)
|
||||
|
||||
if espo_val != advo_val:
|
||||
differences.append(f"{field}: expected '{espo_val}', got '{advo_val}'")
|
||||
|
||||
elif direction == 'to_espocrm':
|
||||
# Advoware → EspoCRM: Prüfe ob EspoCRM die Advoware-Werte hat
|
||||
espo_mapped = mapper.map_advoware_to_cbeteiligte(advo_entity)
|
||||
|
||||
for field in critical_fields:
|
||||
advo_val = espo_mapped.get(field)
|
||||
espo_val = espo_entity.get(field)
|
||||
|
||||
if advo_val != espo_val:
|
||||
differences.append(f"{field}: expected '{advo_val}', got '{espo_val}'")
|
||||
|
||||
if differences:
|
||||
error_msg = f"Validation failed: {', '.join(differences)}"
|
||||
self._log(f"❌ {error_msg}", level='error')
|
||||
return False, error_msg
|
||||
|
||||
self._log(f"✅ Validation erfolgreich", level='debug')
|
||||
return True, None
|
||||
|
||||
except Exception as e:
|
||||
self._log(f"⚠️ Validation error: {e}", level='error')
|
||||
return False, f"Validation exception: {str(e)}"
|
||||
|
||||
async def resolve_conflict_espocrm_wins(
|
||||
self,
|
||||
entity_id: str,
|
||||
|
||||
@@ -282,15 +282,18 @@ def find_empty_slot(kommkz: int, advo_kommunikationen: List[Dict]) -> Optional[D
|
||||
"""
|
||||
Findet leeren Slot mit passendem kommKz
|
||||
|
||||
Leere Slots haben: tlf='' und bemerkung='[ESPOCRM-SLOT:kommKz]'
|
||||
Leere Slots haben: tlf='' (WIRKLICH leer!) UND bemerkung='[ESPOCRM-SLOT:kommKz]'
|
||||
|
||||
WICHTIG: User könnte Wert in einen Slot eingetragen haben → dann ist es KEIN Empty Slot mehr!
|
||||
"""
|
||||
for k in advo_kommunikationen:
|
||||
tlf = (k.get('tlf') or '').strip()
|
||||
bemerkung = k.get('bemerkung') or ''
|
||||
|
||||
if not tlf: # Leer
|
||||
# Muss BEIDES erfüllen: tlf leer UND Slot-Marker
|
||||
if not tlf:
|
||||
marker = parse_marker(bemerkung)
|
||||
if marker and marker['is_slot'] and marker['kommKz'] == kommkz:
|
||||
if marker and marker.get('is_slot') and marker.get('kommKz') == kommkz:
|
||||
return k
|
||||
|
||||
return None
|
||||
@@ -301,21 +304,15 @@ def should_sync_to_espocrm(advo_komm: Dict) -> bool:
|
||||
Prüft ob Advoware-Kommunikation zu EspoCRM synchronisiert werden soll
|
||||
|
||||
Nur wenn:
|
||||
- Wert vorhanden
|
||||
- Kein leerer Slot
|
||||
- Wert vorhanden (tlf ist nicht leer)
|
||||
|
||||
WICHTIG: Ein Slot-Marker allein bedeutet NICHT "nicht sync-relevant"!
|
||||
User könnte einen Wert in einen Slot eingetragen haben.
|
||||
"""
|
||||
tlf = (advo_komm.get('tlf') or '').strip()
|
||||
if not tlf:
|
||||
return False
|
||||
|
||||
bemerkung = advo_komm.get('bemerkung') or ''
|
||||
marker = parse_marker(bemerkung)
|
||||
|
||||
# Keine leeren Slots
|
||||
if marker and marker['is_slot']:
|
||||
return False
|
||||
|
||||
return True
|
||||
# Nur relevante Kriterium: Hat tlf einen Wert?
|
||||
return bool(tlf)
|
||||
|
||||
|
||||
def get_user_bemerkung(advo_komm: Dict) -> str:
|
||||
|
||||
@@ -116,6 +116,14 @@ class KommunikationSyncManager:
|
||||
result['advoware_to_espocrm'] = espo_result
|
||||
elif direction in ['both', 'to_espocrm'] and espo_wins:
|
||||
self.logger.info(f"[KOMM] ⚠️ CONFLICT: EspoCRM wins - skipping Advoware→EspoCRM sync")
|
||||
|
||||
# FIX: Bei Konflikt müssen Var4-Einträge (neu in Advoware) zu Empty Slots gemacht werden!
|
||||
# Sonst bleiben sie in Advoware aber nicht in EspoCRM → Nicht synchron!
|
||||
self.logger.info(f"[KOMM] 🔄 Converting {len(diff['advo_new'])} Var4 entries to Empty Slots (EspoCRM wins)...")
|
||||
for komm in diff['advo_new']:
|
||||
await self._create_empty_slot(betnr, komm)
|
||||
result['espocrm_to_advoware']['deleted'] += 1
|
||||
|
||||
else:
|
||||
self.logger.info(f"[KOMM] ℹ️ Skipping Advoware→EspoCRM (direction={direction})")
|
||||
|
||||
@@ -147,13 +155,21 @@ class KommunikationSyncManager:
|
||||
|
||||
import hashlib
|
||||
final_kommunikationen = advo_bet_final.get('kommunikation', [])
|
||||
komm_rowids = sorted([k.get('rowId', '') for k in final_kommunikationen if k.get('rowId')])
|
||||
|
||||
# FIX #3: Nur sync-relevante Kommunikationen für Hash verwenden
|
||||
# (nicht leere Slots oder nicht-sync-relevante Einträge)
|
||||
sync_relevant_komm = [
|
||||
k for k in final_kommunikationen
|
||||
if should_sync_to_espocrm(k)
|
||||
]
|
||||
|
||||
komm_rowids = sorted([k.get('rowId', '') for k in sync_relevant_komm if k.get('rowId')])
|
||||
new_komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16]
|
||||
|
||||
await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
|
||||
'kommunikationHash': new_komm_hash
|
||||
})
|
||||
self.logger.info(f"[KOMM] ✅ Updated kommunikationHash: {new_komm_hash}")
|
||||
self.logger.info(f"[KOMM] ✅ Updated kommunikationHash: {new_komm_hash} (based on {len(sync_relevant_komm)} sync-relevant of {len(final_kommunikationen)} total)")
|
||||
|
||||
self.logger.info(f"[KOMM] ✅ Bidirectional Sync complete: {total_changes} total changes")
|
||||
|
||||
@@ -201,8 +217,13 @@ class KommunikationSyncManager:
|
||||
last_sync = espo_bet.get('advowareLastSync')
|
||||
|
||||
# Berechne Hash aus Kommunikations-rowIds
|
||||
# FIX #3: Nur sync-relevante Kommunikationen für Hash verwenden
|
||||
import hashlib
|
||||
komm_rowids = sorted([k.get('rowId', '') for k in advo_kommunikationen if k.get('rowId')])
|
||||
sync_relevant_komm = [
|
||||
k for k in advo_kommunikationen
|
||||
if should_sync_to_espocrm(k)
|
||||
]
|
||||
komm_rowids = sorted([k.get('rowId', '') for k in sync_relevant_komm if k.get('rowId')])
|
||||
current_advo_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16]
|
||||
stored_komm_hash = espo_bet.get('kommunikationHash')
|
||||
|
||||
@@ -534,17 +555,29 @@ class KommunikationSyncManager:
|
||||
# ========== HELPER METHODS ==========
|
||||
|
||||
async def _create_empty_slot(self, betnr: int, advo_komm: Dict) -> None:
|
||||
"""Erstellt leeren Slot für gelöschten Eintrag"""
|
||||
"""
|
||||
Erstellt leeren Slot für gelöschten Eintrag
|
||||
|
||||
Verwendet für:
|
||||
- Var2: In EspoCRM gelöscht (hat Marker)
|
||||
- Var4 bei Konflikt: Neu in Advoware aber EspoCRM wins (hat KEINEN Marker)
|
||||
"""
|
||||
try:
|
||||
komm_id = advo_komm['id']
|
||||
tlf = (advo_komm.get('tlf') or '').strip()
|
||||
bemerkung = advo_komm.get('bemerkung') or ''
|
||||
marker = parse_marker(bemerkung)
|
||||
|
||||
if not marker:
|
||||
self.logger.warning(f"[KOMM] Kein Marker gefunden für gelöschten Eintrag: {komm_id}")
|
||||
return
|
||||
# Bestimme kommKz
|
||||
if marker:
|
||||
# Hat Marker (Var2)
|
||||
kommkz = marker['kommKz']
|
||||
else:
|
||||
# Kein Marker (Var4 bei Konflikt) - erkenne kommKz aus Wert
|
||||
from services.kommunikation_mapper import detect_kommkz
|
||||
kommkz = detect_kommkz(tlf) if tlf else 1 # Default: TelGesch
|
||||
self.logger.info(f"[KOMM] Var4 ohne Marker: erkenne kommKz={kommkz} aus Wert '{tlf[:20]}...'")
|
||||
|
||||
kommkz = marker['kommKz']
|
||||
slot_marker = create_slot_marker(kommkz)
|
||||
|
||||
update_data = {
|
||||
|
||||
Reference in New Issue
Block a user