feat(sync): Add force_espo_wins option for conflict resolution in bidirectional sync

This commit is contained in:
2026-02-09 10:30:01 +00:00
parent af00495cee
commit 8dc699ec9e
2 changed files with 37 additions and 8 deletions

View File

@@ -39,7 +39,7 @@ class KommunikationSyncManager:
# ========== BIDIRECTIONAL SYNC ========== # ========== BIDIRECTIONAL SYNC ==========
async def sync_bidirectional(self, beteiligte_id: str, betnr: int, async def sync_bidirectional(self, beteiligte_id: str, betnr: int,
direction: str = 'both') -> Dict[str, Any]: direction: str = 'both', force_espo_wins: bool = False) -> Dict[str, Any]:
""" """
Bidirektionale Synchronisation mit intelligentem Diffing Bidirektionale Synchronisation mit intelligentem Diffing
@@ -53,6 +53,7 @@ class KommunikationSyncManager:
Args: Args:
direction: 'both', 'to_espocrm', 'to_advoware' direction: 'both', 'to_espocrm', 'to_advoware'
force_espo_wins: Erzwingt EspoCRM-wins Konfliktlösung (für Stammdaten-Konflikte)
Returns: Returns:
Combined results mit detaillierten Änderungen Combined results mit detaillierten Änderungen
@@ -102,13 +103,28 @@ class KommunikationSyncManager:
# ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ========== # ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ==========
diff = self._compute_diff(advo_kommunikationen, espo_emails, espo_phones, advo_bet, espo_bet) diff = self._compute_diff(advo_kommunikationen, espo_emails, espo_phones, advo_bet, espo_bet)
# WICHTIG: force_espo_wins überschreibt den Hash-basierten Konflikt-Check
if force_espo_wins:
diff['espo_wins'] = True
self.logger.info(f"[KOMM] ⚠️ force_espo_wins=True → EspoCRM WINS (override)")
# Konvertiere Var3 (advo_deleted) → Var1 (espo_new)
# Bei Konflikt müssen gelöschte Advoware-Einträge wiederhergestellt werden
if diff['advo_deleted']:
self.logger.info(f"[KOMM] 🔄 Converting {len(diff['advo_deleted'])} Var3→Var1 (force EspoCRM wins)")
for value, espo_item in diff['advo_deleted']:
diff['espo_new'].append((value, espo_item))
diff['advo_deleted'] = [] # Leeren, da jetzt als Var1 behandelt
espo_wins = diff.get('espo_wins', False) espo_wins = diff.get('espo_wins', False)
self.logger.info(f"[KOMM] ===== DIFF RESULTS =====") self.logger.info(f"[KOMM] ===== DIFF RESULTS =====")
self.logger.info(f"[KOMM] Diff: {len(diff['advo_changed'])} Advoware changed, {len(diff['espo_changed'])} EspoCRM changed, " self.logger.info(f"[KOMM] Diff: {len(diff['advo_changed'])} Advoware changed, {len(diff['espo_changed'])} EspoCRM changed, "
f"{len(diff['advo_new'])} Advoware new, {len(diff['espo_new'])} EspoCRM new, " f"{len(diff['advo_new'])} Advoware new, {len(diff['espo_new'])} EspoCRM new, "
f"{len(diff['advo_deleted'])} Advoware deleted, {len(diff['espo_deleted'])} EspoCRM deleted") f"{len(diff['advo_deleted'])} Advoware deleted, {len(diff['espo_deleted'])} EspoCRM deleted")
self.logger.info(f"[KOMM] ===== CONFLICT STATUS: espo_wins={espo_wins} =====")
force_status = " (force=True)" if force_espo_wins else ""
self.logger.info(f"[KOMM] ===== CONFLICT STATUS: espo_wins={espo_wins}{force_status} =====")
# ========== APPLY CHANGES ========== # ========== APPLY CHANGES ==========
@@ -145,6 +161,12 @@ class KommunikationSyncManager:
for komm in diff['advo_new']: for komm in diff['advo_new']:
await self._create_empty_slot(betnr, komm) await self._create_empty_slot(betnr, komm)
result['espocrm_to_advoware']['deleted'] += 1 result['espocrm_to_advoware']['deleted'] += 1
# Var3: Wiederherstellung gelöschter Einträge (kein separater Code nötig)
# → Wird über Var1 in _apply_espocrm_to_advoware behandelt
# Die gelöschten Einträge sind noch in EspoCRM vorhanden und werden als "espo_new" erkannt
if len(diff['advo_deleted']) > 0:
self.logger.info(f"[KOMM] {len(diff['advo_deleted'])} Var3 entries (deleted in Advoware) will be restored via espo_new")
# 2. EspoCRM → Advoware (Var1: Neu in EspoCRM, Var2: Gelöscht in EspoCRM, Var5: Geändert in EspoCRM) # 2. EspoCRM → Advoware (Var1: Neu in EspoCRM, Var2: Gelöscht in EspoCRM, Var5: Geändert in EspoCRM)
if sync_to_advoware: if sync_to_advoware:
@@ -732,10 +754,15 @@ class KommunikationSyncManager:
# ========== HELPER METHODS ========== # ========== HELPER METHODS ==========
async def _create_empty_slot(self, betnr: int, advo_komm: Dict) -> None: async def _create_empty_slot(self, betnr: int, advo_komm: Dict, synced_value: str = None) -> None:
""" """
Erstellt leeren Slot für gelöschten Eintrag Erstellt leeren Slot für gelöschten Eintrag
Args:
betnr: Beteiligten-Nummer
advo_komm: Kommunikations-Eintrag aus Advoware
synced_value: Optional - Original-Wert aus EspoCRM (nur für Logging)
Verwendet für: Verwendet für:
- Var2: In EspoCRM gelöscht (hat Marker) - Var2: In EspoCRM gelöscht (hat Marker)
- Var4 bei Konflikt: Neu in Advoware aber EspoCRM wins (hat KEINEN Marker) - Var4 bei Konflikt: Neu in Advoware aber EspoCRM wins (hat KEINEN Marker)
@@ -759,13 +786,14 @@ class KommunikationSyncManager:
slot_marker = create_slot_marker(kommkz) slot_marker = create_slot_marker(kommkz)
update_data = { update_data = {
'tlf': '', 'tlf': '', # Empty Slot = leerer Wert
'bemerkung': slot_marker, 'bemerkung': slot_marker,
'online': False 'online': False
} }
log_value = synced_value if synced_value else tlf
await self.advoware.update_kommunikation(betnr, komm_id, update_data) await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Created empty slot: komm_id={komm_id}, kommKz={kommkz}") self.logger.info(f"[KOMM] ✅ Created empty slot: komm_id={komm_id}, kommKz={kommkz}, original_value='{log_value[:30]}...'")
except Exception as e: except Exception as e:
import traceback import traceback

View File

@@ -158,19 +158,20 @@ async def handler(event_data, context):
context.logger.error(traceback.format_exc()) context.logger.error(traceback.format_exc())
async def run_kommunikation_sync(entity_id: str, betnr: int, komm_sync, context, direction: str = 'both') -> Dict[str, Any]: async def run_kommunikation_sync(entity_id: str, betnr: int, komm_sync, context, direction: str = 'both', force_espo_wins: bool = False) -> Dict[str, Any]:
""" """
Helper: Führt Kommunikation-Sync aus mit Error-Handling Helper: Führt Kommunikation-Sync aus mit Error-Handling
Args: Args:
direction: 'both' (bidirektional), 'to_advoware' (nur EspoCRM→Advoware), 'to_espocrm' (nur Advoware→EspoCRM) direction: 'both' (bidirektional), 'to_advoware' (nur EspoCRM→Advoware), 'to_espocrm' (nur Advoware→EspoCRM)
force_espo_wins: Erzwingt EspoCRM-wins Konfliktlösung (für Stammdaten-Konflikte)
Returns: Returns:
Sync-Ergebnis oder None bei Fehler Sync-Ergebnis oder None bei Fehler
""" """
context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...") context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...")
try: try:
komm_result = await komm_sync.sync_bidirectional(entity_id, betnr, direction=direction) komm_result = await komm_sync.sync_bidirectional(entity_id, betnr, direction=direction, force_espo_wins=force_espo_wins)
context.logger.info(f"✅ Kommunikation synced: {komm_result}") context.logger.info(f"✅ Kommunikation synced: {komm_result}")
return komm_result return komm_result
except Exception as e: except Exception as e:
@@ -419,7 +420,7 @@ async def handle_update(entity_id, betnr, espo_entity, espocrm, advoware, sync_u
) )
# KOMMUNIKATION SYNC: NUR EspoCRM→Advoware (EspoCRM wins!) # KOMMUNIKATION SYNC: NUR EspoCRM→Advoware (EspoCRM wins!)
await run_kommunikation_sync(entity_id, betnr, komm_sync, context, direction='to_advoware') await run_kommunikation_sync(entity_id, betnr, komm_sync, context, direction='to_advoware', force_espo_wins=True)
# Release Lock NACH Kommunikation-Sync # Release Lock NACH Kommunikation-Sync
await sync_utils.release_sync_lock(entity_id, 'clean') await sync_utils.release_sync_lock(entity_id, 'clean')