From 8dc699ec9e7b38dab896d2b1393a354eef66e6e1 Mon Sep 17 00:00:00 2001 From: bitbylaw Date: Mon, 9 Feb 2026 10:30:01 +0000 Subject: [PATCH] feat(sync): Add force_espo_wins option for conflict resolution in bidirectional sync --- bitbylaw/services/kommunikation_sync_utils.py | 38 ++++++++++++++++--- .../steps/vmh/beteiligte_sync_event_step.py | 7 ++-- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/bitbylaw/services/kommunikation_sync_utils.py b/bitbylaw/services/kommunikation_sync_utils.py index 7e3b4a04..2ea910f8 100644 --- a/bitbylaw/services/kommunikation_sync_utils.py +++ b/bitbylaw/services/kommunikation_sync_utils.py @@ -39,7 +39,7 @@ class KommunikationSyncManager: # ========== BIDIRECTIONAL SYNC ========== 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 @@ -53,6 +53,7 @@ class KommunikationSyncManager: Args: direction: 'both', 'to_espocrm', 'to_advoware' + force_espo_wins: Erzwingt EspoCRM-wins Konfliktlösung (für Stammdaten-Konflikte) Returns: Combined results mit detaillierten Änderungen @@ -102,13 +103,28 @@ class KommunikationSyncManager: # ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ========== 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) 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, " 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") - 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 ========== @@ -145,6 +161,12 @@ class KommunikationSyncManager: for komm in diff['advo_new']: await self._create_empty_slot(betnr, komm) 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) if sync_to_advoware: @@ -732,10 +754,15 @@ class KommunikationSyncManager: # ========== 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 + Args: + betnr: Beteiligten-Nummer + advo_komm: Kommunikations-Eintrag aus Advoware + synced_value: Optional - Original-Wert aus EspoCRM (nur für Logging) + Verwendet für: - Var2: In EspoCRM gelöscht (hat 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) update_data = { - 'tlf': '', + 'tlf': '', # Empty Slot = leerer Wert 'bemerkung': slot_marker, 'online': False } + log_value = synced_value if synced_value else tlf 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: import traceback diff --git a/bitbylaw/steps/vmh/beteiligte_sync_event_step.py b/bitbylaw/steps/vmh/beteiligte_sync_event_step.py index d737c3e4..7d29a143 100644 --- a/bitbylaw/steps/vmh/beteiligte_sync_event_step.py +++ b/bitbylaw/steps/vmh/beteiligte_sync_event_step.py @@ -158,19 +158,20 @@ async def handler(event_data, context): 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 Args: 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: Sync-Ergebnis oder None bei Fehler """ context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...") 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}") return komm_result 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!) - 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 await sync_utils.release_sync_lock(entity_id, 'clean')