- 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.
737 lines
34 KiB
Python
737 lines
34 KiB
Python
"""
|
||
Kommunikation Sync Utilities
|
||
Bidirektionale Synchronisation: Advoware ↔ EspoCRM
|
||
|
||
Strategie:
|
||
- Emails: emailAddressData[] ↔ Advoware Kommunikationen (kommKz: 4,8,11,12)
|
||
- Phones: phoneNumberData[] ↔ Advoware Kommunikationen (kommKz: 1,2,3,6,7,9,10)
|
||
- Matching: Hash-basiert via bemerkung-Marker
|
||
- Type Detection: Marker > Top-Level > Value Pattern > Default
|
||
"""
|
||
|
||
import logging
|
||
from typing import Dict, List, Optional, Tuple, Any
|
||
from services.kommunikation_mapper import (
|
||
parse_marker, create_marker, create_slot_marker,
|
||
detect_kommkz, encode_value, decode_value,
|
||
is_email_type, is_phone_type,
|
||
advoware_to_espocrm_email, advoware_to_espocrm_phone,
|
||
find_matching_advoware, find_empty_slot,
|
||
should_sync_to_espocrm, get_user_bemerkung,
|
||
calculate_hash,
|
||
EMAIL_KOMMKZ, PHONE_KOMMKZ
|
||
)
|
||
from services.advoware_service import AdvowareService
|
||
from services.espocrm import EspoCRMAPI
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class KommunikationSyncManager:
|
||
"""Manager für Kommunikation-Synchronisation"""
|
||
|
||
def __init__(self, advoware: AdvowareService, espocrm: EspoCRMAPI, context=None):
|
||
self.advoware = advoware
|
||
self.espocrm = espocrm
|
||
self.context = context
|
||
self.logger = context.logger if context else logger
|
||
|
||
# ========== BIDIRECTIONAL SYNC ==========
|
||
|
||
async def sync_bidirectional(self, beteiligte_id: str, betnr: int,
|
||
direction: str = 'both') -> Dict[str, Any]:
|
||
"""
|
||
Bidirektionale Synchronisation mit intelligentem Diffing
|
||
|
||
Optimiert:
|
||
- Lädt Daten nur 1x von jeder Seite
|
||
- Echtes 3-Way Diffing (Advoware, EspoCRM, Marker)
|
||
- Handhabt alle 6 Szenarien korrekt
|
||
|
||
Args:
|
||
direction: 'both', 'to_espocrm', 'to_advoware'
|
||
|
||
Returns:
|
||
Combined results mit detaillierten Änderungen
|
||
"""
|
||
result = {
|
||
'advoware_to_espocrm': {'emails_synced': 0, 'phones_synced': 0, 'errors': []},
|
||
'espocrm_to_advoware': {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []},
|
||
'summary': {'total_changes': 0}
|
||
}
|
||
|
||
try:
|
||
# ========== LADE DATEN NUR 1X ==========
|
||
self.logger.info(f"[KOMM] Bidirectional Sync: betnr={betnr}, bet_id={beteiligte_id}")
|
||
|
||
# Advoware Daten
|
||
advo_result = await self.advoware.get_beteiligter(betnr)
|
||
if isinstance(advo_result, list):
|
||
advo_bet = advo_result[0] if advo_result else None
|
||
else:
|
||
advo_bet = advo_result
|
||
|
||
if not advo_bet:
|
||
result['advoware_to_espocrm']['errors'].append("Advoware Beteiligte nicht gefunden")
|
||
result['espocrm_to_advoware']['errors'].append("Advoware Beteiligte nicht gefunden")
|
||
return result
|
||
|
||
# EspoCRM Daten
|
||
espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id)
|
||
if not espo_bet:
|
||
result['advoware_to_espocrm']['errors'].append("EspoCRM Beteiligte nicht gefunden")
|
||
result['espocrm_to_advoware']['errors'].append("EspoCRM Beteiligte nicht gefunden")
|
||
return result
|
||
|
||
advo_kommunikationen = advo_bet.get('kommunikation', [])
|
||
espo_emails = espo_bet.get('emailAddressData', [])
|
||
espo_phones = espo_bet.get('phoneNumberData', [])
|
||
|
||
self.logger.info(f"[KOMM] Geladen: {len(advo_kommunikationen)} Advoware, {len(espo_emails)} EspoCRM emails, {len(espo_phones)} EspoCRM phones")
|
||
|
||
# Check ob initialer Sync
|
||
stored_komm_hash = espo_bet.get('kommunikationHash')
|
||
is_initial_sync = not stored_komm_hash
|
||
|
||
# ========== 3-WAY DIFFING MIT HASH-BASIERTER KONFLIKT-ERKENNUNG ==========
|
||
diff = self._compute_diff(advo_kommunikationen, espo_emails, espo_phones, advo_bet, espo_bet)
|
||
|
||
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} =====")
|
||
|
||
# ========== APPLY CHANGES ==========
|
||
|
||
# 1. Advoware → EspoCRM (Var4: Neu in Advoware, Var6: Geändert in Advoware)
|
||
# WICHTIG: Bei Konflikt (espo_wins=true) KEINE Advoware-Änderungen übernehmen!
|
||
if direction in ['both', 'to_espocrm'] and not espo_wins:
|
||
self.logger.info(f"[KOMM] ✅ Applying Advoware→EspoCRM changes...")
|
||
espo_result = await self._apply_advoware_to_espocrm(
|
||
beteiligte_id, diff, advo_bet
|
||
)
|
||
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})")
|
||
|
||
# 2. EspoCRM → Advoware (Var1: Neu in EspoCRM, Var2: Gelöscht in EspoCRM, Var5: Geändert in EspoCRM)
|
||
if direction in ['both', 'to_advoware']:
|
||
advo_result = await self._apply_espocrm_to_advoware(
|
||
betnr, diff, advo_bet
|
||
)
|
||
result['espocrm_to_advoware'] = advo_result
|
||
|
||
total_changes = (
|
||
result['advoware_to_espocrm']['emails_synced'] +
|
||
result['advoware_to_espocrm']['phones_synced'] +
|
||
result['espocrm_to_advoware']['created'] +
|
||
result['espocrm_to_advoware']['updated'] +
|
||
result['espocrm_to_advoware']['deleted']
|
||
)
|
||
result['summary']['total_changes'] = total_changes
|
||
|
||
# Speichere neuen Kommunikations-Hash in EspoCRM (für nächsten Sync)
|
||
# WICHTIG: Auch beim initialen Sync oder wenn keine Änderungen
|
||
if total_changes > 0 or is_initial_sync:
|
||
# Re-berechne Hash nach allen Änderungen
|
||
advo_result_final = await self.advoware.get_beteiligter(betnr)
|
||
if isinstance(advo_result_final, list):
|
||
advo_bet_final = advo_result_final[0]
|
||
else:
|
||
advo_bet_final = advo_result_final
|
||
|
||
import hashlib
|
||
final_kommunikationen = advo_bet_final.get('kommunikation', [])
|
||
|
||
# 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} (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")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler bei Bidirectional Sync: {e}", exc_info=True)
|
||
result['advoware_to_espocrm']['errors'].append(str(e))
|
||
result['espocrm_to_advoware']['errors'].append(str(e))
|
||
|
||
return result
|
||
|
||
# ========== 3-WAY DIFFING ==========
|
||
|
||
def _compute_diff(self, advo_kommunikationen: List[Dict], espo_emails: List[Dict],
|
||
espo_phones: List[Dict], advo_bet: Dict, espo_bet: Dict) -> Dict[str, List]:
|
||
"""
|
||
Berechnet Diff zwischen Advoware und EspoCRM mit Kommunikations-Hash-basierter Konflikt-Erkennung
|
||
|
||
Da die Beteiligte-rowId sich NICHT bei Kommunikations-Änderungen ändert,
|
||
nutzen wir einen Hash aus allen Kommunikations-rowIds + EspoCRM modifiedAt.
|
||
|
||
Returns:
|
||
{
|
||
'advo_changed': [(komm, old_value, new_value)], # Var6: In Advoware geändert
|
||
'advo_new': [komm], # Var4: Neu in Advoware (ohne Marker)
|
||
'advo_deleted': [(value, item)], # Var3: In Advoware gelöscht (via Hash)
|
||
'espo_changed': [(value, advo_komm)], # Var5: In EspoCRM geändert
|
||
'espo_new': [(value, item)], # Var1: Neu in EspoCRM (via Hash)
|
||
'espo_deleted': [advo_komm], # Var2: In EspoCRM gelöscht
|
||
'no_change': [(value, komm, item)] # Keine Änderung
|
||
}
|
||
"""
|
||
diff = {
|
||
'advo_changed': [],
|
||
'advo_new': [],
|
||
'advo_deleted': [], # NEU: Var3
|
||
'espo_changed': [],
|
||
'espo_new': [],
|
||
'espo_deleted': [],
|
||
'no_change': [],
|
||
'espo_wins': False # Default
|
||
}
|
||
|
||
# Hole Sync-Metadaten für Konflikt-Erkennung
|
||
espo_modified = espo_bet.get('modifiedAt')
|
||
last_sync = espo_bet.get('advowareLastSync')
|
||
|
||
# Berechne Hash aus Kommunikations-rowIds
|
||
# FIX #3: Nur sync-relevante Kommunikationen für Hash verwenden
|
||
import hashlib
|
||
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')
|
||
|
||
# Parse Timestamps
|
||
from services.beteiligte_sync_utils import BeteiligteSync
|
||
espo_modified_ts = BeteiligteSync.parse_timestamp(espo_modified)
|
||
last_sync_ts = BeteiligteSync.parse_timestamp(last_sync)
|
||
|
||
# Bestimme wer geändert hat
|
||
espo_changed_since_sync = espo_modified_ts and last_sync_ts and espo_modified_ts > last_sync_ts
|
||
advo_changed_since_sync = stored_komm_hash and current_advo_hash != stored_komm_hash
|
||
|
||
# Initial Sync: Wenn kein Hash gespeichert ist, behandle als "keine Änderung in Advoware"
|
||
is_initial_sync = not stored_komm_hash
|
||
|
||
self.logger.info(f"[KOMM] 🔍 Konflikt-Check:")
|
||
self.logger.info(f"[KOMM] - EspoCRM changed: {espo_changed_since_sync} (modified={espo_modified}, lastSync={last_sync})")
|
||
self.logger.info(f"[KOMM] - Advoware changed: {advo_changed_since_sync} (stored_hash={stored_komm_hash}, current_hash={current_advo_hash})")
|
||
self.logger.info(f"[KOMM] - Initial sync: {is_initial_sync}")
|
||
self.logger.info(f"[KOMM] - Kommunikation rowIds count: {len(komm_rowids)}")
|
||
|
||
if espo_changed_since_sync and advo_changed_since_sync:
|
||
self.logger.warn(f"[KOMM] ⚠️ KONFLIKT: Beide Seiten geändert seit letztem Sync - EspoCRM WINS")
|
||
espo_wins = True
|
||
else:
|
||
espo_wins = False
|
||
|
||
# Speichere espo_wins im diff für spätere Verwendung
|
||
diff['espo_wins'] = espo_wins
|
||
|
||
# Baue EspoCRM Value Map
|
||
espo_values = {}
|
||
for email in espo_emails:
|
||
val = email.get('emailAddress', '').strip()
|
||
if val:
|
||
espo_values[val] = {'value': val, 'is_email': True, 'primary': email.get('primary', False), 'type': 'email'}
|
||
|
||
for phone in espo_phones:
|
||
val = phone.get('phoneNumber', '').strip()
|
||
if val:
|
||
espo_values[val] = {'value': val, 'is_email': False, 'primary': phone.get('primary', False), 'type': phone.get('type', 'Office')}
|
||
|
||
# Baue Advoware Maps
|
||
advo_with_marker = {} # synced_value -> (komm, current_value)
|
||
advo_without_marker = [] # Einträge ohne Marker (von Advoware angelegt)
|
||
|
||
for komm in advo_kommunikationen:
|
||
if not should_sync_to_espocrm(komm):
|
||
continue
|
||
|
||
tlf = (komm.get('tlf') or '').strip()
|
||
if not tlf: # Leere Einträge ignorieren
|
||
continue
|
||
|
||
bemerkung = komm.get('bemerkung') or ''
|
||
marker = parse_marker(bemerkung)
|
||
|
||
if marker and not marker['is_slot']:
|
||
# Hat Marker → Von EspoCRM synchronisiert
|
||
synced_value = marker['synced_value']
|
||
advo_with_marker[synced_value] = (komm, tlf)
|
||
else:
|
||
# Kein Marker → Von Advoware angelegt (Var4)
|
||
advo_without_marker.append(komm)
|
||
|
||
# ========== ANALYSE ==========
|
||
|
||
# 1. Prüfe Advoware-Einträge MIT Marker
|
||
for synced_value, (komm, current_value) in advo_with_marker.items():
|
||
|
||
if synced_value != current_value:
|
||
# Var6: In Advoware geändert
|
||
self.logger.info(f"[KOMM] ✏️ Var6: Changed in Advoware - synced='{synced_value[:30]}...', current='{current_value[:30]}...'")
|
||
diff['advo_changed'].append((komm, synced_value, current_value))
|
||
|
||
elif synced_value in espo_values:
|
||
espo_item = espo_values[synced_value]
|
||
|
||
# Prüfe ob primary geändert wurde (Var5 könnte auch sein)
|
||
current_online = komm.get('online', False)
|
||
espo_primary = espo_item['primary']
|
||
|
||
if current_online != espo_primary:
|
||
# Var5: EspoCRM hat primary geändert
|
||
self.logger.info(f"[KOMM] 🔄 Var5: Primary changed in EspoCRM - value='{synced_value}', advo_online={current_online}, espo_primary={espo_primary}")
|
||
diff['espo_changed'].append((synced_value, komm, espo_item))
|
||
else:
|
||
# Keine Änderung
|
||
self.logger.info(f"[KOMM] ✓ No change: '{synced_value[:30]}...'")
|
||
diff['no_change'].append((synced_value, komm, espo_item))
|
||
|
||
else:
|
||
# Eintrag war mal in EspoCRM (hat Marker), ist jetzt aber nicht mehr da
|
||
# → Var2: In EspoCRM gelöscht
|
||
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - synced_value='{synced_value}', komm_id={komm.get('id')}")
|
||
diff['espo_deleted'].append(komm)
|
||
|
||
# 2. Prüfe Advoware-Einträge OHNE Marker
|
||
for komm in advo_without_marker:
|
||
# Var4: Neu in Advoware angelegt
|
||
tlf = (komm.get('tlf') or '').strip()
|
||
self.logger.info(f"[KOMM] ➕ Var4: New in Advoware - value='{tlf[:30]}...', komm_id={komm.get('id')}")
|
||
diff['advo_new'].append(komm)
|
||
|
||
# 3. Prüfe EspoCRM-Einträge die NICHT in Advoware sind (oder nur mit altem Marker)
|
||
for value, espo_item in espo_values.items():
|
||
if value not in advo_with_marker:
|
||
# HASH-BASIERTE KONFLIKT-LOGIK: Unterscheide Var1 von Var3
|
||
|
||
if espo_wins or (espo_changed_since_sync and not advo_changed_since_sync):
|
||
# Var1: Neu in EspoCRM (EspoCRM geändert, Advoware nicht)
|
||
self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value}' (espo changed, advo unchanged)")
|
||
diff['espo_new'].append((value, espo_item))
|
||
|
||
elif advo_changed_since_sync and not espo_changed_since_sync:
|
||
# Var3: In Advoware gelöscht (Advoware geändert, EspoCRM nicht)
|
||
self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}' (advo changed, espo unchanged)")
|
||
diff['advo_deleted'].append((value, espo_item))
|
||
|
||
else:
|
||
# Kein klarer Hinweis - Default: Behandle als Var1 (neu in EspoCRM)
|
||
self.logger.info(f"[KOMM] Var1 (default): '{value}' - no clear indication, treating as new in EspoCRM")
|
||
diff['espo_new'].append((value, espo_item))
|
||
|
||
return diff
|
||
|
||
# ========== APPLY CHANGES ==========
|
||
|
||
async def _apply_advoware_to_espocrm(self, beteiligte_id: str, diff: Dict,
|
||
advo_bet: Dict) -> Dict[str, Any]:
|
||
"""
|
||
Wendet Advoware-Änderungen auf EspoCRM an (Var4, Var6)
|
||
"""
|
||
result = {'emails_synced': 0, 'phones_synced': 0, 'markers_updated': 0, 'errors': []}
|
||
|
||
try:
|
||
# Lade aktuelle EspoCRM Daten
|
||
espo_bet = await self.espocrm.get_entity('CBeteiligte', beteiligte_id)
|
||
espo_emails = list(espo_bet.get('emailAddressData', []))
|
||
espo_phones = list(espo_bet.get('phoneNumberData', []))
|
||
|
||
# Var6: Advoware-Änderungen → Update Marker + Sync zu EspoCRM
|
||
for komm, old_value, new_value in diff['advo_changed']:
|
||
self.logger.info(f"[KOMM] Var6: Advoware changed '{old_value}' → '{new_value}'")
|
||
|
||
# Update Marker in Advoware
|
||
bemerkung = komm.get('bemerkung') or ''
|
||
marker = parse_marker(bemerkung)
|
||
user_text = marker.get('user_text', '') if marker else ''
|
||
kommkz = marker['kommKz'] if marker else detect_kommkz(new_value, advo_bet)
|
||
|
||
new_marker = create_marker(new_value, kommkz, user_text)
|
||
await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], {
|
||
'bemerkung': new_marker
|
||
})
|
||
result['markers_updated'] += 1
|
||
|
||
# Update in EspoCRM: Finde alten Wert und ersetze mit neuem
|
||
if is_email_type(kommkz):
|
||
for i, email in enumerate(espo_emails):
|
||
if email.get('emailAddress') == old_value:
|
||
espo_emails[i] = {
|
||
'emailAddress': new_value,
|
||
'lower': new_value.lower(),
|
||
'primary': komm.get('online', False),
|
||
'optOut': False,
|
||
'invalid': False
|
||
}
|
||
result['emails_synced'] += 1
|
||
break
|
||
else:
|
||
for i, phone in enumerate(espo_phones):
|
||
if phone.get('phoneNumber') == old_value:
|
||
type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
|
||
espo_phones[i] = {
|
||
'phoneNumber': new_value,
|
||
'type': type_map.get(kommkz, 'Other'),
|
||
'primary': komm.get('online', False),
|
||
'optOut': False,
|
||
'invalid': False
|
||
}
|
||
result['phones_synced'] += 1
|
||
break
|
||
|
||
# Var4: Neu in Advoware → Zu EspoCRM hinzufügen + Marker setzen
|
||
for komm in diff['advo_new']:
|
||
tlf = (komm.get('tlf') or '').strip()
|
||
kommkz = detect_kommkz(tlf, advo_bet, komm.get('bemerkung'))
|
||
|
||
self.logger.info(f"[KOMM] Var4: New in Advoware '{tlf}', syncing to EspoCRM")
|
||
|
||
# Setze Marker in Advoware
|
||
new_marker = create_marker(tlf, kommkz)
|
||
await self.advoware.update_kommunikation(advo_bet['betNr'], komm['id'], {
|
||
'bemerkung': new_marker
|
||
})
|
||
|
||
# Zu EspoCRM hinzufügen
|
||
if is_email_type(kommkz):
|
||
espo_emails.append({
|
||
'emailAddress': tlf,
|
||
'lower': tlf.lower(),
|
||
'primary': komm.get('online', False),
|
||
'optOut': False,
|
||
'invalid': False
|
||
})
|
||
result['emails_synced'] += 1
|
||
else:
|
||
type_map = {1: 'Office', 2: 'Fax', 3: 'Mobile', 6: 'Home', 7: 'Fax', 9: 'Mobile', 10: 'Other'}
|
||
espo_phones.append({
|
||
'phoneNumber': tlf,
|
||
'type': type_map.get(kommkz, 'Other'),
|
||
'primary': komm.get('online', False),
|
||
'optOut': False,
|
||
'invalid': False
|
||
})
|
||
result['phones_synced'] += 1
|
||
|
||
# Var3: In Advoware gelöscht → Aus EspoCRM entfernen
|
||
for value, espo_item in diff.get('advo_deleted', []):
|
||
self.logger.info(f"[KOMM] Var3: Deleted in Advoware '{value}', removing from EspoCRM")
|
||
|
||
if espo_item['is_email']:
|
||
espo_emails = [e for e in espo_emails if e.get('emailAddress') != value]
|
||
result['emails_synced'] += 1 # Zählt als "synced" (gelöscht)
|
||
else:
|
||
espo_phones = [p for p in espo_phones if p.get('phoneNumber') != value]
|
||
result['phones_synced'] += 1
|
||
|
||
# Update EspoCRM wenn Änderungen
|
||
if result['emails_synced'] > 0 or result['phones_synced'] > 0:
|
||
await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
|
||
'emailAddressData': espo_emails,
|
||
'phoneNumberData': espo_phones
|
||
})
|
||
self.logger.info(f"[KOMM] ✅ Updated EspoCRM: {result['emails_synced']} emails, {result['phones_synced']} phones")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler bei Advoware→EspoCRM Apply: {e}", exc_info=True)
|
||
result['errors'].append(str(e))
|
||
|
||
return result
|
||
|
||
async def _apply_espocrm_to_advoware(self, betnr: int, diff: Dict,
|
||
advo_bet: Dict) -> Dict[str, Any]:
|
||
"""
|
||
Wendet EspoCRM-Änderungen auf Advoware an (Var1, Var2, Var3, Var5)
|
||
"""
|
||
result = {'created': 0, 'updated': 0, 'deleted': 0, 'errors': []}
|
||
|
||
try:
|
||
advo_kommunikationen = advo_bet.get('kommunikation', [])
|
||
|
||
# Var2: In EspoCRM gelöscht → Empty Slot in Advoware
|
||
for komm in diff['espo_deleted']:
|
||
komm_id = komm.get('id')
|
||
tlf = (komm.get('tlf') or '').strip()
|
||
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - komm_id={komm_id}, value='{tlf[:30]}...'")
|
||
await self._create_empty_slot(betnr, komm)
|
||
self.logger.info(f"[KOMM] ✅ Empty slot created for komm_id={komm_id}")
|
||
result['deleted'] += 1
|
||
|
||
# Var5: In EspoCRM geändert (z.B. primary Flag)
|
||
for value, advo_komm, espo_item in diff['espo_changed']:
|
||
self.logger.info(f"[KOMM] ✏️ Var5: EspoCRM changed '{value[:30]}...', primary={espo_item.get('primary')}")
|
||
|
||
bemerkung = advo_komm.get('bemerkung') or ''
|
||
marker = parse_marker(bemerkung)
|
||
user_text = marker.get('user_text', '') if marker else ''
|
||
|
||
# Erkenne kommKz mit espo_type
|
||
if marker:
|
||
kommkz = marker['kommKz']
|
||
self.logger.info(f"[KOMM] kommKz from marker: {kommkz}")
|
||
else:
|
||
espo_type = espo_item.get('type', 'email' if '@' in value else None)
|
||
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
|
||
self.logger.info(f"[KOMM] kommKz detected: espo_type={espo_type}, kommKz={kommkz}")
|
||
|
||
# Update in Advoware
|
||
await self.advoware.update_kommunikation(betnr, advo_komm['id'], {
|
||
'tlf': value,
|
||
'online': espo_item['primary'],
|
||
'bemerkung': create_marker(value, kommkz, user_text)
|
||
})
|
||
self.logger.info(f"[KOMM] ✅ Updated komm_id={advo_komm['id']}, kommKz={kommkz}")
|
||
result['updated'] += 1
|
||
|
||
# Var1: Neu in EspoCRM → Create oder reuse Slot in Advoware
|
||
for value, espo_item in diff['espo_new']:
|
||
self.logger.info(f"[KOMM] ➕ Var1: New in EspoCRM '{value[:30]}...', type={espo_item.get('type')}")
|
||
|
||
# Erkenne kommKz mit espo_type
|
||
espo_type = espo_item.get('type', 'email' if '@' in value else None)
|
||
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
|
||
self.logger.info(f"[KOMM] 🔍 kommKz detected: espo_type={espo_type}, kommKz={kommkz}")
|
||
|
||
# Suche leeren Slot
|
||
empty_slot = find_empty_slot(kommkz, advo_kommunikationen)
|
||
|
||
if empty_slot:
|
||
# Reuse Slot
|
||
self.logger.info(f"[KOMM] ♻️ Reusing empty slot: slot_id={empty_slot['id']}, kommKz={kommkz}")
|
||
await self.advoware.update_kommunikation(betnr, empty_slot['id'], {
|
||
'tlf': value,
|
||
'online': espo_item['primary'],
|
||
'bemerkung': create_marker(value, kommkz)
|
||
})
|
||
self.logger.info(f"[KOMM] ✅ Slot reused successfully")
|
||
else:
|
||
# Create new
|
||
self.logger.info(f"[KOMM] ➕ Creating new kommunikation: kommKz={kommkz}")
|
||
await self.advoware.create_kommunikation(betnr, {
|
||
'tlf': value,
|
||
'kommKz': kommkz,
|
||
'online': espo_item['primary'],
|
||
'bemerkung': create_marker(value, kommkz)
|
||
})
|
||
self.logger.info(f"[KOMM] ✅ Created new kommunikation with kommKz={kommkz}")
|
||
|
||
result['created'] += 1
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler bei EspoCRM→Advoware Apply: {e}", exc_info=True)
|
||
result['errors'].append(str(e))
|
||
|
||
return result
|
||
|
||
# ========== HELPER METHODS ==========
|
||
|
||
async def _create_empty_slot(self, betnr: int, advo_komm: Dict) -> None:
|
||
"""
|
||
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)
|
||
|
||
# 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]}...'")
|
||
|
||
slot_marker = create_slot_marker(kommkz)
|
||
|
||
update_data = {
|
||
'tlf': '',
|
||
'bemerkung': slot_marker,
|
||
'online': False
|
||
}
|
||
|
||
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
|
||
self.logger.info(f"[KOMM] ✅ Created empty slot: komm_id={komm_id}, kommKz={kommkz}")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler beim Erstellen von Empty Slot: {e}", exc_info=True)
|
||
|
||
def _needs_update(self, advo_komm: Dict, espo_item: Dict) -> bool:
|
||
"""Prüft ob Update nötig ist"""
|
||
current_value = (advo_komm.get('tlf') or '').strip()
|
||
new_value = espo_item['value'].strip()
|
||
|
||
current_online = advo_komm.get('online', False)
|
||
new_online = espo_item.get('primary', False)
|
||
|
||
return current_value != new_value or current_online != new_online
|
||
|
||
async def _update_kommunikation(self, betnr: int, advo_komm: Dict, espo_item: Dict) -> None:
|
||
"""Updated Advoware Kommunikation"""
|
||
try:
|
||
komm_id = advo_komm['id']
|
||
value = espo_item['value']
|
||
|
||
# Erkenne kommKz (sollte aus Marker kommen)
|
||
bemerkung = advo_komm.get('bemerkung') or ''
|
||
marker = parse_marker(bemerkung)
|
||
kommkz = marker['kommKz'] if marker else detect_kommkz(value, espo_type=espo_item.get('type'))
|
||
|
||
# Behalte User-Bemerkung
|
||
user_text = get_user_bemerkung(advo_komm)
|
||
new_marker = create_marker(value, kommkz, user_text)
|
||
|
||
update_data = {
|
||
'tlf': value,
|
||
'bemerkung': new_marker,
|
||
'online': espo_item.get('primary', False)
|
||
}
|
||
|
||
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
|
||
self.logger.info(f"[KOMM] ✅ Updated: komm_id={komm_id}, value={value[:30]}...")
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler beim Update: {e}", exc_info=True)
|
||
|
||
async def _create_or_reuse_kommunikation(self, betnr: int, espo_item: Dict,
|
||
advo_kommunikationen: List[Dict]) -> bool:
|
||
"""
|
||
Erstellt neue Kommunikation oder nutzt leeren Slot
|
||
|
||
Returns:
|
||
True wenn erfolgreich erstellt/reused
|
||
"""
|
||
try:
|
||
value = espo_item['value']
|
||
|
||
# Erkenne kommKz mit EspoCRM type
|
||
espo_type = espo_item.get('type', 'email' if '@' in value else None)
|
||
kommkz = detect_kommkz(value, espo_type=espo_type)
|
||
self.logger.info(f"[KOMM] 🔍 kommKz detection: value='{value[:30]}...', espo_type={espo_type}, kommKz={kommkz}")
|
||
|
||
# Suche leeren Slot mit passendem kommKz
|
||
empty_slot = find_empty_slot(kommkz, advo_kommunikationen)
|
||
|
||
new_marker = create_marker(value, kommkz)
|
||
|
||
if empty_slot:
|
||
# ========== REUSE SLOT ==========
|
||
komm_id = empty_slot['id']
|
||
self.logger.info(f"[KOMM] ♻️ Reusing empty slot: komm_id={komm_id}, kommKz={kommkz}")
|
||
update_data = {
|
||
'tlf': value,
|
||
'bemerkung': new_marker,
|
||
'online': espo_item.get('primary', False)
|
||
}
|
||
|
||
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
|
||
self.logger.info(f"[KOMM] ✅ Slot reused successfully: value='{value[:30]}...'")
|
||
|
||
else:
|
||
# ========== CREATE NEW ==========
|
||
self.logger.info(f"[KOMM] ➕ Creating new kommunikation entry: kommKz={kommkz}")
|
||
create_data = {
|
||
'tlf': value,
|
||
'bemerkung': new_marker,
|
||
'kommKz': kommkz,
|
||
'online': espo_item.get('primary', False)
|
||
}
|
||
|
||
await self.advoware.create_kommunikation(betnr, create_data)
|
||
self.logger.info(f"[KOMM] ✅ Created new: value='{value[:30]}...', kommKz={kommkz}")
|
||
|
||
return True
|
||
|
||
except Exception as e:
|
||
self.logger.error(f"[KOMM] Fehler beim Erstellen/Reuse: {e}", exc_info=True)
|
||
return False
|
||
|
||
|
||
# ========== CHANGE DETECTION ==========
|
||
|
||
def detect_kommunikation_changes(old_bet: Dict, new_bet: Dict) -> bool:
|
||
"""
|
||
Erkennt Änderungen in Kommunikationen via rowId
|
||
|
||
Args:
|
||
old_bet: Alte Beteiligte-Daten (mit kommunikation[])
|
||
new_bet: Neue Beteiligte-Daten (mit kommunikation[])
|
||
|
||
Returns:
|
||
True wenn Änderungen erkannt
|
||
"""
|
||
old_komm = old_bet.get('kommunikation', [])
|
||
new_komm = new_bet.get('kommunikation', [])
|
||
|
||
# Check Count
|
||
if len(old_komm) != len(new_komm):
|
||
return True
|
||
|
||
# Check rowIds
|
||
old_row_ids = {k.get('rowId') for k in old_komm}
|
||
new_row_ids = {k.get('rowId') for k in new_komm}
|
||
|
||
return old_row_ids != new_row_ids
|
||
|
||
|
||
def detect_espocrm_kommunikation_changes(old_data: Dict, new_data: Dict) -> bool:
|
||
"""
|
||
Erkennt Änderungen in EspoCRM emailAddressData/phoneNumberData
|
||
|
||
Returns:
|
||
True wenn Änderungen erkannt
|
||
"""
|
||
old_emails = old_data.get('emailAddressData', [])
|
||
new_emails = new_data.get('emailAddressData', [])
|
||
|
||
old_phones = old_data.get('phoneNumberData', [])
|
||
new_phones = new_data.get('phoneNumberData', [])
|
||
|
||
# Einfacher Vergleich: Count und Values
|
||
if len(old_emails) != len(new_emails) or len(old_phones) != len(new_phones):
|
||
return True
|
||
|
||
old_email_values = {e.get('emailAddress') for e in old_emails}
|
||
new_email_values = {e.get('emailAddress') for e in new_emails}
|
||
|
||
old_phone_values = {p.get('phoneNumber') for p in old_phones}
|
||
new_phone_values = {p.get('phoneNumber') for p in new_phones}
|
||
|
||
return old_email_values != new_email_values or old_phone_values != new_phone_values
|