Add requests dependency to project

- Included 'requests' in the dependencies list of uv.lock.
- Specified minimum version of 'requests' as 2.32.0 in requires-dist.
This commit is contained in:
bsiggel
2026-03-01 22:39:33 +00:00
parent 014947e9e0
commit 17f908d036
8 changed files with 2666 additions and 9 deletions

266
services/adressen_mapper.py Normal file
View File

@@ -0,0 +1,266 @@
"""
Adressen Mapper: EspoCRM CAdressen ↔ Advoware Adressen
Transformiert Adressen zwischen den beiden Systemen.
Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12.
"""
from typing import Dict, Any, Optional
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class AdressenMapper:
"""Mapper für CAdressen (EspoCRM) ↔ Adressen (Advoware)"""
@staticmethod
def map_cadressen_to_advoware_create(espo_addr: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CAdressen → Advoware Adressen Format (CREATE/POST)
Für CREATE werden ALLE 11 Felder gemappt (inkl. READ-ONLY bei PUT).
Args:
espo_addr: CAdressen Entity von EspoCRM
Returns:
Dict für Advoware POST /api/v1/advonet/Beteiligte/{betnr}/Adressen
"""
logger.debug(f"Mapping EspoCRM → Advoware (CREATE): {espo_addr.get('id')}")
# Formatiere Anschrift (mehrzeilig)
anschrift = AdressenMapper._format_anschrift(espo_addr)
advo_data = {
# R/W Felder (via PUT änderbar)
'strasse': espo_addr.get('adresseStreet') or '',
'plz': espo_addr.get('adressePostalCode') or '',
'ort': espo_addr.get('adresseCity') or '',
'anschrift': anschrift,
# READ-ONLY Felder (nur bei CREATE!)
'land': espo_addr.get('adresseCountry') or 'DE',
'postfach': espo_addr.get('postfach'),
'postfachPLZ': espo_addr.get('postfachPLZ'),
'standardAnschrift': bool(espo_addr.get('isPrimary', False)),
'bemerkung': f"EspoCRM-ID: {espo_addr['id']}", # WICHTIG für Matching!
'gueltigVon': AdressenMapper._format_datetime(espo_addr.get('validFrom')),
'gueltigBis': AdressenMapper._format_datetime(espo_addr.get('validUntil'))
}
return advo_data
@staticmethod
def map_cadressen_to_advoware_update(espo_addr: Dict[str, Any]) -> Dict[str, Any]:
"""
Transformiert EspoCRM CAdressen → Advoware Adressen Format (UPDATE/PUT)
Für UPDATE werden NUR die 4 R/W Felder gemappt!
Alle anderen Änderungen müssen über Notifications gehandelt werden.
Args:
espo_addr: CAdressen Entity von EspoCRM
Returns:
Dict für Advoware PUT /api/v1/advonet/Beteiligte/{betnr}/Adressen/{index}
"""
logger.debug(f"Mapping EspoCRM → Advoware (UPDATE): {espo_addr.get('id')}")
# NUR R/W Felder!
advo_data = {
'strasse': espo_addr.get('adresseStreet') or '',
'plz': espo_addr.get('adressePostalCode') or '',
'ort': espo_addr.get('adresseCity') or '',
'anschrift': AdressenMapper._format_anschrift(espo_addr)
}
return advo_data
@staticmethod
def map_advoware_to_cadressen(advo_addr: Dict[str, Any],
beteiligte_id: str,
existing_espo_addr: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""
Transformiert Advoware Adressen → EspoCRM CAdressen Format
Args:
advo_addr: Adresse von Advoware GET
beteiligte_id: EspoCRM CBeteiligte ID (für Relation)
existing_espo_addr: Existierende EspoCRM Entity (für Update)
Returns:
Dict für EspoCRM API
"""
logger.debug(f"Mapping Advoware → EspoCRM: Index {advo_addr.get('reihenfolgeIndex')}")
espo_data = {
# Core Adressfelder
'adresseStreet': advo_addr.get('strasse'),
'adressePostalCode': advo_addr.get('plz'),
'adresseCity': advo_addr.get('ort'),
'adresseCountry': advo_addr.get('land') or 'DE',
# Zusatzfelder
'postfach': advo_addr.get('postfach'),
'postfachPLZ': advo_addr.get('postfachPLZ'),
'description': advo_addr.get('bemerkung'),
# Status-Felder
'isPrimary': bool(advo_addr.get('standardAnschrift', False)),
'validFrom': advo_addr.get('gueltigVon'),
'validUntil': advo_addr.get('gueltigBis'),
# Sync-Felder
'advowareRowId': advo_addr.get('rowId'),
'advowareLastSync': datetime.now().isoformat(),
'syncStatus': 'synced',
# Relation
'beteiligteId': beteiligte_id
}
# Preserve existing fields when updating
if existing_espo_addr:
espo_data['id'] = existing_espo_addr['id']
# Keep existing isActive if not changed
if 'isActive' in existing_espo_addr:
espo_data['isActive'] = existing_espo_addr['isActive']
else:
# New address
espo_data['isActive'] = True
return espo_data
@staticmethod
def detect_readonly_changes(espo_addr: Dict[str, Any],
advo_addr: Dict[str, Any]) -> list[Dict[str, Any]]:
"""
Erkenne Änderungen an READ-ONLY Feldern (nicht via PUT änderbar)
Args:
espo_addr: EspoCRM CAdressen Entity
advo_addr: Advoware Adresse
Returns:
Liste von Änderungen mit Feldnamen und Werten
"""
changes = []
# Mapping: EspoCRM-Feld → (Advoware-Feld, Label)
readonly_mappings = {
'adresseCountry': ('land', 'Land'),
'postfach': ('postfach', 'Postfach'),
'postfachPLZ': ('postfachPLZ', 'Postfach PLZ'),
'isPrimary': ('standardAnschrift', 'Hauptadresse'),
'validFrom': ('gueltigVon', 'Gültig von'),
'validUntil': ('gueltigBis', 'Gültig bis')
}
for espo_field, (advo_field, label) in readonly_mappings.items():
espo_value = espo_addr.get(espo_field)
advo_value = advo_addr.get(advo_field)
# Normalisiere Werte für Vergleich
if espo_field == 'isPrimary':
espo_value = bool(espo_value)
advo_value = bool(advo_value)
elif espo_field in ['validFrom', 'validUntil']:
# Datetime-Vergleich (nur Datum)
espo_value = AdressenMapper._normalize_date(espo_value)
advo_value = AdressenMapper._normalize_date(advo_value)
# Vergleiche
if espo_value != advo_value:
changes.append({
'field': label,
'espoField': espo_field,
'advoField': advo_field,
'espoCRM_value': espo_value,
'advoware_value': advo_value
})
return changes
@staticmethod
def _format_anschrift(espo_addr: Dict[str, Any]) -> str:
"""
Formatiert mehrzeilige Anschrift für Advoware
Format:
{Firmenname oder Name}
{Strasse}
{PLZ} {Ort}
"""
parts = []
# Zeile 1: Name
if espo_addr.get('firmenname'):
parts.append(espo_addr['firmenname'])
elif espo_addr.get('firstName') or espo_addr.get('lastName'):
name = f"{espo_addr.get('firstName', '')} {espo_addr.get('lastName', '')}".strip()
if name:
parts.append(name)
# Zeile 2: Straße
if espo_addr.get('adresseStreet'):
parts.append(espo_addr['adresseStreet'])
# Zeile 3: PLZ + Ort
plz = espo_addr.get('adressePostalCode', '').strip()
ort = espo_addr.get('adresseCity', '').strip()
if plz or ort:
parts.append(f"{plz} {ort}".strip())
return '\n'.join(parts)
@staticmethod
def _format_datetime(dt: Any) -> Optional[str]:
"""
Formatiert Datetime für Advoware API (ISO 8601)
Args:
dt: datetime object, ISO string, oder None
Returns:
ISO 8601 string oder None
"""
if not dt:
return None
if isinstance(dt, str):
# Bereits String - prüfe ob gültig
try:
datetime.fromisoformat(dt.replace('Z', '+00:00'))
return dt
except:
return None
if isinstance(dt, datetime):
return dt.isoformat()
return None
@staticmethod
def _normalize_date(dt: Any) -> Optional[str]:
"""
Normalisiert Datum für Vergleich (nur Datum, keine Zeit)
Returns:
YYYY-MM-DD string oder None
"""
if not dt:
return None
if isinstance(dt, str):
try:
dt_obj = datetime.fromisoformat(dt.replace('Z', '+00:00'))
return dt_obj.strftime('%Y-%m-%d')
except:
return None
if isinstance(dt, datetime):
return dt.strftime('%Y-%m-%d')
return None

696
services/adressen_sync.py Normal file
View File

@@ -0,0 +1,696 @@
"""
Adressen Synchronization: EspoCRM ↔ Advoware
Synchronisiert CAdressen zwischen EspoCRM und Advoware.
Basierend auf ADRESSEN_SYNC_ANALYSE.md Abschnitt 12.
SYNC-STRATEGIE:
- CREATE: Vollautomatisch (alle 11 Felder)
- UPDATE: Nur R/W Felder (strasse, plz, ort, anschrift)
- DELETE: Nur via Notification (kein API-DELETE verfügbar)
- READ-ONLY Änderungen: Nur via Notification
KONFLIKT-BEHANDLUNG (wie bei Beteiligten):
- rowId-basierte Änderungserkennung (Advoware rowId ändert sich bei jedem PUT)
- Timestamp-Vergleich für EspoCRM (modifiedAt vs advowareLastSync)
- Bei Konflikt (beide geändert): EspoCRM GEWINNT IMMER!
- Notification bei Konflikt mit Details
"""
from typing import Dict, Any, Optional, List
from datetime import datetime
import logging
from services.advoware import AdvowareAPI
from services.espocrm import EspoCRMAPI
from services.adressen_mapper import AdressenMapper
from services.notification_utils import NotificationManager
logger = logging.getLogger(__name__)
class AdressenSync:
"""Sync-Klasse für Adressen zwischen EspoCRM und Advoware"""
def __init__(self, context=None):
"""
Initialize AdressenSync
Args:
context: Application context mit logger
"""
self.context = context
self.advo = AdvowareAPI(context=context)
self.espo = EspoCRMAPI(context=context)
self.mapper = AdressenMapper()
self.notification_manager = NotificationManager(espocrm_api=self.espo, context=context)
# ========================================================================
# KONFLIKT-ERKENNUNG
# ========================================================================
def compare_addresses(self, espo_addr: Dict[str, Any], advo_addr: Dict[str, Any]) -> str:
"""
Vergleicht Änderungen zwischen EspoCRM und Advoware Adresse
Nutzt die gleiche Strategie wie bei Beteiligten:
- PRIMÄR: rowId-Vergleich (Advoware rowId ändert sich bei jedem PUT)
- FALLBACK: Keine Änderung wenn rowId gleich
Args:
espo_addr: EspoCRM CAdressen Entity
advo_addr: Advoware Adresse
Returns:
"espocrm_newer": EspoCRM wurde seit letztem Sync geändert
"advoware_newer": Advoware wurde seit letztem Sync geändert
"conflict": Beide wurden seit letztem Sync geändert (EspoCRM gewinnt!)
"no_change": Keine Änderungen
"""
espo_rowid = espo_addr.get('advowareRowId')
advo_rowid = advo_addr.get('rowId')
last_sync = espo_addr.get('advowareLastSync')
espo_modified = espo_addr.get('modifiedAt')
# SPECIAL CASE: Kein lastSync → Initial Sync (EspoCRM bevorzugen)
if not last_sync:
logger.debug("Initial Sync (kein lastSync) → EspoCRM bevorzugt")
return 'espocrm_newer'
if espo_rowid and advo_rowid:
# Prüfe ob Advoware geändert wurde (rowId)
advo_changed = (espo_rowid != advo_rowid)
# Prüfe ob EspoCRM auch geändert wurde (seit letztem Sync)
espo_changed = False
if espo_modified and last_sync:
try:
# Parse Timestamps (ISO 8601 Format)
espo_ts = datetime.fromisoformat(espo_modified.replace('Z', '+00:00')) if isinstance(espo_modified, str) else espo_modified
sync_ts = datetime.fromisoformat(last_sync.replace('Z', '+00:00')) if isinstance(last_sync, str) else last_sync
espo_changed = (espo_ts > sync_ts)
except Exception as e:
logger.debug(f"Timestamp-Parse-Fehler: {e}")
# Konfliktlogik: Beide geändert seit letztem Sync?
if advo_changed and espo_changed:
logger.warning("🚨 KONFLIKT: Beide Seiten haben Adresse geändert seit letztem Sync")
return 'conflict'
elif advo_changed:
logger.info(f"Advoware rowId geändert: {espo_rowid[:20]}... → {advo_rowid[:20]}...")
return 'advoware_newer'
elif espo_changed:
logger.info("EspoCRM neuer (modifiedAt > lastSync)")
return 'espocrm_newer'
else:
# Keine Änderungen
logger.debug("Keine Änderungen (rowId identisch)")
return 'no_change'
# FALLBACK: Kein rowId vorhanden → konservativ EspoCRM bevorzugen
logger.debug("rowId nicht verfügbar → EspoCRM bevorzugt")
return 'espocrm_newer'
# ========================================================================
# CREATE: EspoCRM → Advoware
# ========================================================================
async def create_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]:
"""
Erstelle neue Adresse in Advoware
Alle 11 Felder werden synchronisiert (inkl. READ-ONLY).
Args:
espo_addr: CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
Erstellte Adresse oder None bei Fehler
"""
try:
espo_id = espo_addr['id']
logger.info(f"Creating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}")
# Map zu Advoware Format (alle Felder)
advo_data = self.mapper.map_cadressen_to_advoware_create(espo_addr)
# POST zu Advoware
result = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='POST',
json_data=advo_data
)
# POST gibt Array zurück, nimm erste Adresse
if isinstance(result, list) and result:
created_addr = result[0]
else:
created_addr = result
logger.info(
f"✓ Created address in Advoware: "
f"Index {created_addr.get('reihenfolgeIndex')}, "
f"EspoCRM ID {espo_id}"
)
# Update EspoCRM mit Sync-Info
await self._update_espo_sync_info(espo_id, created_addr, 'synced')
return created_addr
except Exception as e:
logger.error(f"Failed to create address: {e}", exc_info=True)
# Update syncStatus
await self._update_espo_sync_status(espo_addr['id'], 'error')
return None
# ========================================================================
# UPDATE: EspoCRM → Advoware (nur R/W Felder)
# ========================================================================
async def update_address(self, espo_addr: Dict[str, Any], betnr: int) -> Optional[Dict[str, Any]]:
"""
Update Adresse in Advoware (nur R/W Felder)
Nur strasse, plz, ort, anschrift werden geändert.
Alle anderen Änderungen → Notification.
Mit Konflikt-Erkennung: Wenn beide Seiten geändert → EspoCRM gewinnt!
Args:
espo_addr: CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
Aktualisierte Adresse oder None bei Fehler
"""
try:
espo_id = espo_addr['id']
logger.info(f"Updating address in Advoware for EspoCRM ID {espo_id}, BetNr {betnr}")
# 1. Finde Adresse in Advoware via bemerkung (EINZIGE stabile Methode)
target = await self._find_address_by_espo_id(betnr, espo_id)
if not target:
logger.warning(f"Address not found in Advoware: {espo_id} - creating new")
return await self.create_address(espo_addr, betnr)
# 2. KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden
comparison = self.compare_addresses(espo_addr, target)
if comparison == 'no_change':
logger.info(f"⏭ No changes detected, skipping update: {espo_id}")
return target
if comparison == 'advoware_newer':
logger.info(f"⬇ Advoware neuer, sync Advoware → EspoCRM statt Update")
# Advoware hat sich geändert, aber EspoCRM nicht
# → Sync in andere Richtung (aktualisiere EspoCRM)
await self._update_espo_address(
espo_id,
target,
espo_addr.get('beteiligteId'),
espo_addr
)
return target
# comparison ist 'espocrm_newer' oder 'conflict'
# In beiden Fällen: EspoCRM gewinnt!
if comparison == 'conflict':
logger.warning(
f"⚠️ KONFLIKT erkannt für Adresse {espo_id} - EspoCRM gewinnt! "
f"Überschreibe Advoware."
)
# 3. Map nur R/W Felder
rw_data = self.mapper.map_cadressen_to_advoware_update(espo_addr)
# 4. PUT mit aktuellem reihenfolgeIndex (dynamisch!)
current_index = target['reihenfolgeIndex']
result = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen/{current_index}',
method='PUT',
json_data=rw_data
)
# Extrahiere neue rowId aus Response
new_rowid = None
if isinstance(result, list) and result:
new_rowid = result[0].get('rowId')
elif isinstance(result, dict):
new_rowid = result.get('rowId')
logger.info(
f"✓ Updated address in Advoware (R/W fields): "
f"Index {current_index}, EspoCRM ID {espo_id}, neue rowId: {new_rowid[:20] if new_rowid else 'N/A'}..."
)
# 5. Bei Konflikt: Notification erstellen
if comparison == 'conflict':
await self._notify_conflict(
espo_addr,
betnr,
target,
f"Advoware rowId: {target.get('rowId', 'N/A')[:20]}..., "
f"EspoCRM modifiedAt: {espo_addr.get('modifiedAt', 'N/A')}"
)
# 6. Prüfe READ-ONLY Feld-Änderungen
readonly_changes = self.mapper.detect_readonly_changes(espo_addr, target)
if readonly_changes:
logger.warning(
f"⚠ READ-ONLY fields changed for {espo_id}: "
f"{len(readonly_changes)} fields"
)
await self._notify_readonly_changes(espo_addr, betnr, readonly_changes)
# 7. Update EspoCRM mit neuer Sync-Info (inkl. neuer rowId!)
result_with_rowid = result[0] if isinstance(result, list) and result else result
if new_rowid and isinstance(result_with_rowid, dict):
result_with_rowid['rowId'] = new_rowid
await self._update_espo_sync_info(espo_id, result_with_rowid, 'synced')
return result_with_rowid
except Exception as e:
logger.error(f"Failed to update address: {e}", exc_info=True)
# Update syncStatus
await self._update_espo_sync_status(espo_addr['id'], 'error')
return None
# ========================================================================
# DELETE: EspoCRM → Advoware (nur Notification)
# ========================================================================
async def handle_address_deletion(self, espo_addr: Dict[str, Any], betnr: int) -> bool:
"""
Handle Adress-Löschung (nur Notification)
Kein API-DELETE verfügbar → Manuelle Löschung erforderlich.
Args:
espo_addr: Gelöschte CAdressen Entity von EspoCRM
betnr: Advoware Beteiligte-Nummer
Returns:
True wenn Notification erfolgreich
"""
try:
espo_id = espo_addr['id']
logger.info(f"Handling address deletion for EspoCRM ID {espo_id}, BetNr {betnr}")
# 1. Finde Adresse in Advoware
target = await self._find_address_by_espo_id(betnr, espo_id)
if not target:
logger.info(f"Address already deleted or not found: {espo_id}")
return True
# 2. Erstelle Notification für manuelle Löschung
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_id,
action_type='address_delete_required',
details={
'message': 'Adresse in Advoware löschen',
'description': (
f'Adresse wurde in EspoCRM gelöscht:\n'
f'{target.get("strasse")}\n'
f'{target.get("plz")} {target.get("ort")}\n\n'
f'Bitte manuell in Advoware löschen:\n'
f'1. Öffne Beteiligten {betnr} in Advoware\n'
f'2. Gehe zu Adressen-Tab\n'
f'3. Lösche Adresse (Index {target.get("reihenfolgeIndex")})\n'
f'4. Speichern'
),
'advowareIndex': target.get('reihenfolgeIndex'),
'betnr': betnr,
'address': f"{target.get('strasse')}, {target.get('ort')}",
'priority': 'Medium'
}
)
logger.info(f"✓ Created delete notification for address {espo_id}")
return True
except Exception as e:
logger.error(f"Failed to handle address deletion: {e}", exc_info=True)
return False
# ========================================================================
# SYNC: Advoware → EspoCRM (vollständig)
# ========================================================================
async def sync_from_advoware(self, betnr: int, espo_beteiligte_id: str) -> Dict[str, int]:
"""
Synct alle Adressen von Advoware zu EspoCRM
Alle Felder werden übernommen (Advoware = Master).
Args:
betnr: Advoware Beteiligte-Nummer
espo_beteiligte_id: EspoCRM CBeteiligte ID
Returns:
Dict mit Statistiken: created, updated, unchanged
"""
stats = {'created': 0, 'updated': 0, 'unchanged': 0, 'errors': 0}
try:
logger.info(f"Syncing addresses from Advoware BetNr {betnr} → EspoCRM {espo_beteiligte_id}")
# 1. Hole alle Adressen von Advoware
advo_addresses = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='GET'
)
logger.info(f"Found {len(advo_addresses)} addresses in Advoware")
# 2. Hole existierende EspoCRM Adressen
import json
espo_addresses = await self.espo.list_entities(
'CAdressen',
where=json.dumps([{
'type': 'equals',
'attribute': 'beteiligteId',
'value': espo_beteiligte_id
}])
)
espo_addrs_by_id = {addr['id']: addr for addr in espo_addresses.get('list', [])}
# 3. Sync jede Adresse
for advo_addr in advo_addresses:
try:
# Match via bemerkung
bemerkung = advo_addr.get('bemerkung', '')
if 'EspoCRM-ID:' in bemerkung:
# Existierende Adresse
espo_id = bemerkung.split('EspoCRM-ID:')[1].strip().split()[0]
if espo_id in espo_addrs_by_id:
espo_addr = espo_addrs_by_id[espo_id]
# KONFLIKT-CHECK: Vergleiche ob beide Seiten geändert wurden
comparison = self.compare_addresses(espo_addr, advo_addr)
if comparison == 'no_change':
logger.debug(f"⏭ No changes detected, skipping: {espo_id}")
stats['unchanged'] += 1
elif comparison == 'espocrm_newer':
# EspoCRM ist neuer → Skip (wird von EspoCRM Webhook behandelt)
logger.info(f"⬆ EspoCRM neuer, skip sync: {espo_id}")
stats['unchanged'] += 1
elif comparison == 'conflict':
# Konflikt: EspoCRM gewinnt → Skip Update (EspoCRM bleibt Master)
logger.warning(
f"⚠️ KONFLIKT: EspoCRM ist Master, skip Advoware→EspoCRM update: {espo_id}"
)
stats['unchanged'] += 1
else:
# comparison == 'advoware_newer': Update EspoCRM
result = await self._update_espo_address(
espo_id,
advo_addr,
espo_beteiligte_id,
espo_addr
)
if result:
stats['updated'] += 1
else:
stats['errors'] += 1
else:
logger.warning(f"EspoCRM address not found: {espo_id}")
stats['errors'] += 1
else:
# Neue Adresse aus Advoware (kein EspoCRM-ID)
result = await self._create_espo_address(advo_addr, espo_beteiligte_id)
if result:
stats['created'] += 1
else:
stats['errors'] += 1
except Exception as e:
logger.error(f"Failed to sync address: {e}", exc_info=True)
stats['errors'] += 1
logger.info(
f"✓ Sync complete: "
f"created={stats['created']}, "
f"updated={stats['updated']}, "
f"errors={stats['errors']}"
)
return stats
except Exception as e:
logger.error(f"Failed to sync from Advoware: {e}", exc_info=True)
return stats
# ========================================================================
# HELPER METHODS
# ========================================================================
async def _find_address_by_espo_id(self, betnr: int, espo_id: str) -> Optional[Dict[str, Any]]:
"""
Finde Adresse in Advoware via bemerkung-Matching
Args:
betnr: Advoware Beteiligte-Nummer
espo_id: EspoCRM CAdressen ID
Returns:
Advoware Adresse oder None
"""
try:
all_addresses = await self.advo.api_call(
f'/api/v1/advonet/Beteiligte/{betnr}/Adressen',
method='GET'
)
bemerkung_match = f"EspoCRM-ID: {espo_id}"
target = next(
(a for a in all_addresses
if bemerkung_match in (a.get('bemerkung') or '')),
None
)
return target
except Exception as e:
logger.error(f"Failed to find address: {e}", exc_info=True)
return None
async def _update_espo_sync_info(self, espo_id: str, advo_addr: Dict[str, Any],
status: str = 'synced') -> bool:
"""
Update Sync-Info in EspoCRM CAdressen
Args:
espo_id: EspoCRM CAdressen ID
advo_addr: Advoware Adresse (für rowId)
status: syncStatus (nicht verwendet, da EspoCRM-Feld möglicherweise nicht existiert)
Returns:
True wenn erfolgreich
"""
try:
update_data = {
'advowareRowId': advo_addr.get('rowId'),
'advowareLastSync': datetime.now().isoformat()
# syncStatus removed - Feld existiert möglicherweise nicht
}
result = await self.espo.update_entity('CAdressen', espo_id, update_data)
return bool(result)
except Exception as e:
logger.error(f"Failed to update sync info: {e}", exc_info=True)
return False
async def _update_espo_sync_status(self, espo_id: str, status: str) -> bool:
"""
Update nur syncStatus in EspoCRM (optional - Feld möglicherweise nicht vorhanden)
Args:
espo_id: EspoCRM CAdressen ID
status: syncStatus ('error', 'pending', etc.)
Returns:
True wenn erfolgreich
"""
try:
# Feld möglicherweise nicht vorhanden - ignoriere Fehler
result = await self.espo.update_entity(
'CAdressen',
espo_id,
{'description': f'Sync-Status: {status}'} # Als Workaround in description
)
return bool(result)
except Exception as e:
logger.error(f"Failed to update sync status: {e}", exc_info=True)
return False
async def _notify_conflict(self, espo_addr: Dict[str, Any], betnr: int,
advo_addr: Dict[str, Any], conflict_details: str) -> bool:
"""
Erstelle Notification für Adress-Konflikt
Args:
espo_addr: EspoCRM CAdressen Entity
betnr: Advoware Beteiligte-Nummer
advo_addr: Advoware Adresse (für Details)
conflict_details: Details zum Konflikt
Returns:
True wenn Notification erfolgreich
"""
try:
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_addr['id'],
action_type='address_sync_conflict',
details={
'message': 'Sync-Konflikt bei Adresse (EspoCRM hat gewonnen)',
'description': (
f'Sowohl EspoCRM als auch Advoware haben diese Adresse seit '
f'dem letzten Sync geändert.\n\n'
f'EspoCRM hat Vorrang - Änderungen wurden nach Advoware übertragen.\n\n'
f'Details:\n{conflict_details}\n\n'
f'Bitte prüfen Sie die Daten in Advoware und stellen Sie sicher, '
f'dass keine wichtigen Änderungen verloren gegangen sind.'
),
'address': f"{espo_addr.get('adresseStreet')}, {espo_addr.get('adresseCity')}",
'betnr': betnr,
'priority': 'High'
}
)
return True
except Exception as e:
logger.error(f"Failed to create conflict notification: {e}", exc_info=True)
return False
async def _notify_readonly_changes(self, espo_addr: Dict[str, Any], betnr: int,
changes: List[Dict[str, Any]]) -> bool:
"""
Erstelle Notification für READ-ONLY Feld-Änderungen
Args:
espo_addr: EspoCRM CAdressen Entity
betnr: Advoware Beteiligte-Nummer
changes: Liste von Änderungen
Returns:
True wenn Notification erfolgreich
"""
try:
change_details = '\n'.join([
f"- {c['field']}: EspoCRM='{c['espoCRM_value']}'"
f"Advoware='{c['advoware_value']}'"
for c in changes
])
await self.notification_manager.notify_manual_action_required(
entity_type='CAdressen',
entity_id=espo_addr['id'],
action_type='readonly_field_conflict',
details={
'message': f'{len(changes)} READ-ONLY Feld(er) geändert',
'description': (
f'Folgende Felder wurden in EspoCRM geändert, sind aber '
f'READ-ONLY in Advoware und können nicht automatisch '
f'synchronisiert werden:\n\n{change_details}\n\n'
f'Bitte manuell in Advoware anpassen:\n'
f'1. Öffne Beteiligten {betnr} in Advoware\n'
f'2. Gehe zu Adressen-Tab\n'
f'3. Passe die Felder manuell an\n'
f'4. Speichern'
),
'changes': changes,
'address': f"{espo_addr.get('adresseStreet')}, "
f"{espo_addr.get('adresseCity')}",
'betnr': betnr,
'priority': 'High'
}
)
return True
except Exception as e:
logger.error(f"Failed to create notification: {e}", exc_info=True)
return False
async def _create_espo_address(self, advo_addr: Dict[str, Any],
beteiligte_id: str) -> Optional[str]:
"""
Erstelle neue Adresse in EspoCRM
Args:
advo_addr: Advoware Adresse
beteiligte_id: EspoCRM CBeteiligte ID
Returns:
EspoCRM ID oder None
"""
try:
espo_data = self.mapper.map_advoware_to_cadressen(advo_addr, beteiligte_id)
result = await self.espo.create_entity('CAdressen', espo_data)
if result and 'id' in result:
logger.info(f"✓ Created address in EspoCRM: {result['id']}")
return result['id']
return None
except Exception as e:
logger.error(f"Failed to create EspoCRM address: {e}", exc_info=True)
return None
async def _update_espo_address(self, espo_id: str, advo_addr: Dict[str, Any],
beteiligte_id: str,
existing: Dict[str, Any]) -> bool:
"""
Update existierende Adresse in EspoCRM
Args:
espo_id: EspoCRM CAdressen ID
advo_addr: Advoware Adresse
beteiligte_id: EspoCRM CBeteiligte ID
existing: Existierende EspoCRM Entity
Returns:
True wenn erfolgreich
"""
try:
espo_data = self.mapper.map_advoware_to_cadressen(
advo_addr,
beteiligte_id,
existing
)
result = await self.espo.update_entity('CAdressen', espo_id, espo_data)
if result:
logger.info(f"✓ Updated address in EspoCRM: {espo_id}")
return True
return False
except Exception as e:
logger.error(f"Failed to update EspoCRM address: {e}", exc_info=True)
return False

View File

@@ -0,0 +1,333 @@
"""
Kommunikation Mapper: Advoware ↔ EspoCRM
Mapping-Strategie:
- Marker in Advoware bemerkung: [ESPOCRM:hash:kommKz]
- Typ-Erkennung: Marker > Top-Level > Wert > Default
- Bidirektional mit Slot-Wiederverwendung
"""
import hashlib
import base64
import re
from typing import Optional, Dict, Any, List, Tuple
# kommKz Enum
KOMMKZ_TEL_GESCH = 1
KOMMKZ_FAX_GESCH = 2
KOMMKZ_MOBIL = 3
KOMMKZ_MAIL_GESCH = 4
KOMMKZ_INTERNET = 5
KOMMKZ_TEL_PRIVAT = 6
KOMMKZ_FAX_PRIVAT = 7
KOMMKZ_MAIL_PRIVAT = 8
KOMMKZ_AUTO_TELEFON = 9
KOMMKZ_SONSTIGE = 10
KOMMKZ_EPOST = 11
KOMMKZ_BEA = 12
# EspoCRM phone type mapping
KOMMKZ_TO_PHONE_TYPE = {
KOMMKZ_TEL_GESCH: 'Office',
KOMMKZ_FAX_GESCH: 'Fax',
KOMMKZ_MOBIL: 'Mobile',
KOMMKZ_TEL_PRIVAT: 'Home',
KOMMKZ_FAX_PRIVAT: 'Fax',
KOMMKZ_AUTO_TELEFON: 'Mobile',
KOMMKZ_SONSTIGE: 'Other',
}
# Reverse mapping: EspoCRM phone type to kommKz
PHONE_TYPE_TO_KOMMKZ = {
'Office': KOMMKZ_TEL_GESCH,
'Fax': KOMMKZ_FAX_GESCH,
'Mobile': KOMMKZ_MOBIL,
'Home': KOMMKZ_TEL_PRIVAT,
'Other': KOMMKZ_SONSTIGE,
}
# Email kommKz values
EMAIL_KOMMKZ = [KOMMKZ_MAIL_GESCH, KOMMKZ_MAIL_PRIVAT, KOMMKZ_EPOST, KOMMKZ_BEA]
# Phone kommKz values
PHONE_KOMMKZ = [KOMMKZ_TEL_GESCH, KOMMKZ_FAX_GESCH, KOMMKZ_MOBIL,
KOMMKZ_TEL_PRIVAT, KOMMKZ_FAX_PRIVAT, KOMMKZ_AUTO_TELEFON, KOMMKZ_SONSTIGE]
def encode_value(value: str) -> str:
"""Encodiert Wert mit Base64 (URL-safe) für Marker"""
return base64.urlsafe_b64encode(value.encode('utf-8')).decode('ascii').rstrip('=')
def decode_value(encoded: str) -> str:
"""Decodiert Base64-kodierten Wert aus Marker"""
# Add padding if needed
padding = 4 - (len(encoded) % 4)
if padding != 4:
encoded += '=' * padding
return base64.urlsafe_b64decode(encoded.encode('ascii')).decode('utf-8')
def calculate_hash(value: str) -> str:
"""Legacy: Hash-Berechnung (für Rückwärtskompatibilität mit alten Markern)"""
return hashlib.sha256(value.encode()).hexdigest()[:8]
def parse_marker(bemerkung: str) -> Optional[Dict[str, Any]]:
"""
Parse ESPOCRM-Marker aus bemerkung
Returns:
{'synced_value': '...', 'kommKz': 4, 'is_slot': False, 'user_text': '...'}
oder None (synced_value ist decoded, nicht base64)
"""
if not bemerkung:
return None
# Match SLOT: [ESPOCRM-SLOT:kommKz]
slot_pattern = r'\[ESPOCRM-SLOT:(\d+)\](.*)'
slot_match = re.match(slot_pattern, bemerkung)
if slot_match:
return {
'synced_value': '',
'kommKz': int(slot_match.group(1)),
'is_slot': True,
'user_text': slot_match.group(2).strip()
}
# Match: [ESPOCRM:base64_value:kommKz]
pattern = r'\[ESPOCRM:([^:]+):(\d+)\](.*)'
match = re.match(pattern, bemerkung)
if not match:
return None
encoded_value = match.group(1)
# Decode Base64 value
try:
synced_value = decode_value(encoded_value)
except Exception as e:
# Fallback: Könnte alter Hash-Marker sein
synced_value = encoded_value
return {
'synced_value': synced_value,
'kommKz': int(match.group(2)),
'is_slot': False,
'user_text': match.group(3).strip()
}
def create_marker(value: str, kommkz: int, user_text: str = '') -> str:
"""Erstellt ESPOCRM-Marker mit Base64-encodiertem Wert"""
encoded = encode_value(value)
suffix = f" {user_text}" if user_text else ""
return f"[ESPOCRM:{encoded}:{kommkz}]{suffix}"
def create_slot_marker(kommkz: int) -> str:
"""Erstellt Slot-Marker für gelöschte Einträge"""
return f"[ESPOCRM-SLOT:{kommkz}]"
def detect_kommkz(value: str, beteiligte: Optional[Dict] = None,
bemerkung: Optional[str] = None,
espo_type: Optional[str] = None) -> int:
"""
Erkenne kommKz mit mehrstufiger Strategie
Priorität:
1. Aus bemerkung-Marker (wenn vorhanden)
2. Aus EspoCRM type (wenn von EspoCRM kommend)
3. Aus Top-Level Feldern in beteiligte
4. Aus Wert (Email vs. Phone)
5. Default
Args:
espo_type: EspoCRM phone type ('Office', 'Mobile', 'Fax', etc.) oder 'email'
"""
# 1. Aus Marker
if bemerkung:
marker = parse_marker(bemerkung)
if marker:
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from marker: kommKz={marker['kommKz']}")
return marker['kommKz']
# 2. Aus EspoCRM type (für EspoCRM->Advoware Sync)
if espo_type:
if espo_type == 'email':
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from espo_type 'email': kommKz={KOMMKZ_MAIL_GESCH}")
return KOMMKZ_MAIL_GESCH
elif espo_type in PHONE_TYPE_TO_KOMMKZ:
kommkz = PHONE_TYPE_TO_KOMMKZ[espo_type]
import logging
logger = logging.getLogger(__name__)
logger.info(f"[KOMMKZ] Detected from espo_type '{espo_type}': kommKz={kommkz}")
return kommkz
# 3. Aus Top-Level Feldern (für genau EINEN Eintrag pro Typ)
if beteiligte:
top_level_map = {
'telGesch': KOMMKZ_TEL_GESCH,
'faxGesch': KOMMKZ_FAX_GESCH,
'mobil': KOMMKZ_MOBIL,
'emailGesch': KOMMKZ_MAIL_GESCH,
'email': KOMMKZ_MAIL_GESCH,
'internet': KOMMKZ_INTERNET,
'telPrivat': KOMMKZ_TEL_PRIVAT,
'faxPrivat': KOMMKZ_FAX_PRIVAT,
'autotelefon': KOMMKZ_AUTO_TELEFON,
'ePost': KOMMKZ_EPOST,
'bea': KOMMKZ_BEA,
}
for field, kommkz in top_level_map.items():
if beteiligte.get(field) == value:
return kommkz
# 3. Aus Wert (Email vs. Phone)
if '@' in value:
return KOMMKZ_MAIL_GESCH # Default Email
elif value.strip():
return KOMMKZ_TEL_GESCH # Default Phone
return 0
def is_email_type(kommkz: int) -> bool:
"""Prüft ob kommKz ein Email-Typ ist"""
return kommkz in EMAIL_KOMMKZ
def is_phone_type(kommkz: int) -> bool:
"""Prüft ob kommKz ein Telefon-Typ ist"""
return kommkz in PHONE_KOMMKZ
def advoware_to_espocrm_email(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]:
"""
Konvertiert Advoware Kommunikation zu EspoCRM emailAddressData
Args:
advo_komm: Advoware Kommunikation
beteiligte: Vollständiger Beteiligte (für Top-Level Felder)
Returns:
EspoCRM emailAddressData Element
"""
value = (advo_komm.get('tlf') or '').strip()
return {
'emailAddress': value,
'lower': value.lower(),
'primary': advo_komm.get('online', False),
'optOut': False,
'invalid': False
}
def advoware_to_espocrm_phone(advo_komm: Dict, beteiligte: Dict) -> Dict[str, Any]:
"""
Konvertiert Advoware Kommunikation zu EspoCRM phoneNumberData
Args:
advo_komm: Advoware Kommunikation
beteiligte: Vollständiger Beteiligte (für Top-Level Felder)
Returns:
EspoCRM phoneNumberData Element
"""
value = (advo_komm.get('tlf') or '').strip()
bemerkung = advo_komm.get('bemerkung')
# Erkenne kommKz
kommkz = detect_kommkz(value, beteiligte, bemerkung)
# Mappe zu EspoCRM type
phone_type = KOMMKZ_TO_PHONE_TYPE.get(kommkz, 'Other')
return {
'phoneNumber': value,
'type': phone_type,
'primary': advo_komm.get('online', False),
'optOut': False,
'invalid': False
}
def find_matching_advoware(espo_value: str, advo_kommunikationen: List[Dict]) -> Optional[Dict]:
"""
Findet passende Advoware-Kommunikation für EspoCRM Wert
Matching via synced_value in bemerkung-Marker
"""
for k in advo_kommunikationen:
bemerkung = k.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker and not marker['is_slot'] and marker['synced_value'] == espo_value:
return k
return None
def find_empty_slot(kommkz: int, advo_kommunikationen: List[Dict]) -> Optional[Dict]:
"""
Findet leeren Slot mit passendem 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 ''
# Muss BEIDES erfüllen: tlf leer UND Slot-Marker
if not tlf:
marker = parse_marker(bemerkung)
if marker and marker.get('is_slot') and marker.get('kommKz') == kommkz:
return k
return None
def should_sync_to_espocrm(advo_komm: Dict) -> bool:
"""
Prüft ob Advoware-Kommunikation zu EspoCRM synchronisiert werden soll
Nur wenn:
- 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()
# Nur relevante Kriterium: Hat tlf einen Wert?
return bool(tlf)
def get_user_bemerkung(advo_komm: Dict) -> str:
"""Extrahiert User-Bemerkung (ohne Marker)"""
bemerkung = advo_komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker:
return marker['user_text']
return bemerkung
def set_user_bemerkung(marker: str, user_text: str) -> str:
"""Fügt User-Bemerkung zu Marker hinzu"""
if user_text:
return f"{marker} {user_text}"
return marker

View File

@@ -0,0 +1,998 @@
"""
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', force_espo_wins: bool = False) -> Dict[str, Any]:
"""
Bidirektionale Synchronisation mit intelligentem Diffing
Optimiert:
- Lädt Daten nur 1x von jeder Seite (kein doppelter API-Call)
- Echtes 3-Way Diffing (Advoware, EspoCRM, Marker)
- Handhabt alle 6 Szenarien korrekt (Var1-6)
- Initial Sync: Value-Matching verhindert Duplikate (BUG-3 Fix)
- Hash nur bei Änderung schreiben (Performance)
- Lock-Release garantiert via try/finally
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
"""
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}
}
# NOTE: Lock-Management erfolgt außerhalb dieser Methode (in Event/Cron Handler)
# Diese Methode ist für die reine Sync-Logik zuständig
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)
# 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")
force_status = " (force=True)" if force_espo_wins else ""
self.logger.info(f"[KOMM] ===== CONFLICT STATUS: espo_wins={espo_wins}{force_status} =====")
# ========== APPLY CHANGES ==========
# Bestimme Sync-Richtungen und Konflikt-Handling
sync_to_espocrm = direction in ['both', 'to_espocrm']
sync_to_advoware = direction in ['both', 'to_advoware']
should_revert_advoware_changes = (sync_to_espocrm and espo_wins) or (direction == 'to_advoware')
# 1. Advoware → EspoCRM (Var4: Neu in Advoware, Var6: Geändert in Advoware)
if sync_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
# Bei Konflikt oder direction='to_advoware': Revert Advoware-Änderungen
if should_revert_advoware_changes:
if espo_wins:
self.logger.info(f"[KOMM] ⚠️ CONFLICT: EspoCRM wins - reverting Advoware changes")
else:
self.logger.info(f"[KOMM] Direction={direction}: reverting Advoware changes")
# Var6: Revert Änderungen
if len(diff['advo_changed']) > 0:
self.logger.info(f"[KOMM] 🔄 Reverting {len(diff['advo_changed'])} Var6 entries to EspoCRM values...")
for komm, old_value, new_value in diff['advo_changed']:
await self._revert_advoware_change(betnr, komm, old_value, new_value, advo_bet)
result['espocrm_to_advoware']['updated'] += 1
# Var4: Convert to Empty Slots
if len(diff['advo_new']) > 0:
self.logger.info(f"[KOMM] 🔄 Converting {len(diff['advo_new'])} Var4 entries to Empty Slots...")
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:
advo_result = await self._apply_espocrm_to_advoware(
betnr, diff, advo_bet
)
# Merge results (Var6/Var4 Counts aus Konflikt-Handling behalten)
result['espocrm_to_advoware']['created'] += advo_result['created']
result['espocrm_to_advoware']['updated'] += advo_result['updated']
result['espocrm_to_advoware']['deleted'] += advo_result['deleted']
result['espocrm_to_advoware']['errors'].extend(advo_result['errors'])
# 3. Initial Sync Matches: Nur Marker setzen (keine CREATE/UPDATE)
if is_initial_sync and 'initial_sync_matches' in diff:
self.logger.info(f"[KOMM] ✓ Processing {len(diff['initial_sync_matches'])} initial sync matches...")
for value, matched_komm, espo_item in diff['initial_sync_matches']:
# Erkenne kommKz
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
# Setze Marker in Advoware
await self.advoware.update_kommunikation(betnr, matched_komm['id'], {
'bemerkung': create_marker(value, kommkz),
'online': espo_item.get('primary', False)
})
result['espocrm_to_advoware']['updated'] += 1
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
# Hash-Update: Immer berechnen, aber nur schreiben wenn geändert
import hashlib
# FIX: Nur neu laden wenn Änderungen gemacht wurden
if total_changes > 0:
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
final_kommunikationen = advo_bet_final.get('kommunikation', [])
else:
# Keine Änderungen: Verwende cached data (keine doppelte API-Call)
final_kommunikationen = advo_bet.get('kommunikation', [])
# Berechne neuen Hash
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]
# Nur schreiben wenn Hash sich geändert hat oder Initial Sync
if new_komm_hash != stored_komm_hash:
await self.espocrm.update_entity('CBeteiligte', beteiligte_id, {
'kommunikationHash': new_komm_hash
})
self.logger.info(f"[KOMM] ✅ Updated kommunikationHash: {stored_komm_hash}{new_komm_hash} (based on {len(sync_relevant_komm)} sync-relevant of {len(final_kommunikationen)} total)")
else:
self.logger.info(f"[KOMM] Hash unchanged: {new_komm_hash} - no EspoCRM update needed")
self.logger.info(f"[KOMM] ✅ Bidirectional Sync complete: {total_changes} total changes")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler bei Bidirectional Sync: {e}")
self.logger.error(traceback.format_exc())
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 Hash-basierter Konflikt-Erkennung
Returns:
Dict mit Var1-6 Änderungen und Konflikt-Status
"""
diff = {
'advo_changed': [], # Var6
'advo_new': [], # Var4
'advo_deleted': [], # Var3
'espo_changed': [], # Var5
'espo_new': [], # Var1
'espo_deleted': [], # Var2
'no_change': [],
'espo_wins': False
}
# 1. Konflikt-Erkennung
is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync = \
self._detect_conflict(advo_kommunikationen, espo_bet)
diff['espo_wins'] = espo_wins
# 2. Baue Value-Maps
espo_values = self._build_espocrm_value_map(espo_emails, espo_phones)
advo_with_marker, advo_without_marker = self._build_advoware_maps(advo_kommunikationen)
# 3. Analysiere Advoware-Einträge MIT Marker
self._analyze_advoware_with_marker(advo_with_marker, espo_values, diff)
# 4. Analysiere Advoware-Einträge OHNE Marker (Var4) + Initial Sync Matching
self._analyze_advoware_without_marker(
advo_without_marker, espo_values, is_initial_sync, advo_bet, diff
)
# 5. Analysiere EspoCRM-Einträge die nicht in Advoware sind (Var1/Var3)
self._analyze_espocrm_only(
espo_values, advo_with_marker, espo_wins,
espo_changed_since_sync, advo_changed_since_sync, diff
)
return diff
def _detect_conflict(self, advo_kommunikationen: List[Dict], espo_bet: Dict) -> Tuple[bool, bool, bool, bool]:
"""
Erkennt Konflikte via Hash-Vergleich
Returns:
(is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync)
"""
espo_modified = espo_bet.get('modifiedAt')
last_sync = espo_bet.get('advowareLastSync')
stored_komm_hash = espo_bet.get('kommunikationHash')
# Berechne aktuellen Hash
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]
# 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 Änderungen
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
is_initial_sync = not stored_komm_hash
# Konflikt-Logik: Beide geändert → EspoCRM wins
espo_wins = espo_changed_since_sync and advo_changed_since_sync
self.logger.info(f"[KOMM] 🔍 Konflikt-Check:")
self.logger.info(f"[KOMM] - EspoCRM changed: {espo_changed_since_sync}, Advoware changed: {advo_changed_since_sync}")
self.logger.info(f"[KOMM] - Initial sync: {is_initial_sync}, Conflict: {espo_wins}")
self.logger.info(f"[KOMM] - Hash: stored={stored_komm_hash}, current={current_advo_hash}")
return is_initial_sync, espo_wins, espo_changed_since_sync, advo_changed_since_sync
def _build_espocrm_value_map(self, espo_emails: List[Dict], espo_phones: List[Dict]) -> Dict[str, Dict]:
"""Baut Map: value → {value, is_email, primary, type}"""
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')
}
return espo_values
def _build_advoware_maps(self, advo_kommunikationen: List[Dict]) -> Tuple[Dict, List]:
"""
Trennt Advoware-Einträge in MIT Marker und OHNE Marker
Returns:
(advo_with_marker: {synced_value: (komm, current_value)}, advo_without_marker: [komm])
"""
advo_with_marker = {}
advo_without_marker = []
for komm in advo_kommunikationen:
if not should_sync_to_espocrm(komm):
continue
tlf = (komm.get('tlf') or '').strip()
if not tlf:
continue
marker = parse_marker(komm.get('bemerkung', ''))
if marker and not marker['is_slot']:
# Hat Marker → Von EspoCRM synchronisiert
advo_with_marker[marker['synced_value']] = (komm, tlf)
else:
# Kein Marker → Von Advoware angelegt (Var4)
advo_without_marker.append(komm)
return advo_with_marker, advo_without_marker
def _analyze_advoware_with_marker(self, advo_with_marker: Dict, espo_values: Dict, diff: Dict) -> None:
"""Analysiert Advoware-Einträge MIT Marker für Var6, Var5, Var2"""
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")
diff['advo_changed'].append((komm, synced_value, current_value))
elif synced_value in espo_values:
espo_item = espo_values[synced_value]
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")
diff['espo_changed'].append((synced_value, komm, espo_item))
else:
# Keine Änderung
diff['no_change'].append((synced_value, komm, espo_item))
else:
# Var2: In EspoCRM gelöscht
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM")
diff['espo_deleted'].append(komm)
def _analyze_advoware_without_marker(
self, advo_without_marker: List[Dict], espo_values: Dict,
is_initial_sync: bool, advo_bet: Dict, diff: Dict
) -> None:
"""Analysiert Advoware-Einträge OHNE Marker für Var4 + Initial Sync Matching"""
# FIX BUG-3: Bei Initial Sync Value-Map erstellen
advo_values_without_marker = {}
if is_initial_sync:
advo_values_without_marker = {
(k.get('tlf') or '').strip(): k
for k in advo_without_marker
if (k.get('tlf') or '').strip()
}
# Sammle matched values für Initial Sync
matched_komm_ids = set()
# Prüfe ob EspoCRM-Werte bereits in Advoware existieren (Initial Sync)
if is_initial_sync:
for value in espo_values.keys():
if value in advo_values_without_marker:
matched_komm = advo_values_without_marker[value]
espo_item = espo_values[value]
# Match gefunden - setze nur Marker, kein Var1/Var4
if 'initial_sync_matches' not in diff:
diff['initial_sync_matches'] = []
diff['initial_sync_matches'].append((value, matched_komm, espo_item))
matched_komm_ids.add(matched_komm['id'])
self.logger.info(f"[KOMM] ✓ Initial Sync Match: '{value[:30]}...'")
# Var4: Neu in Advoware (nicht matched im Initial Sync)
for komm in advo_without_marker:
if komm['id'] not in matched_komm_ids:
tlf = (komm.get('tlf') or '').strip()
self.logger.info(f"[KOMM] Var4: New in Advoware - '{tlf[:30]}...'")
diff['advo_new'].append(komm)
def _analyze_espocrm_only(
self, espo_values: Dict, advo_with_marker: Dict,
espo_wins: bool, espo_changed_since_sync: bool,
advo_changed_since_sync: bool, diff: Dict
) -> None:
"""Analysiert EspoCRM-Einträge die nicht in Advoware sind für Var1/Var3"""
# Sammle bereits gematchte values aus Initial Sync
matched_values = set()
if 'initial_sync_matches' in diff:
matched_values = {v for v, k, e in diff['initial_sync_matches']}
for value, espo_item in espo_values.items():
# Skip wenn bereits im Initial Sync gematched
if value in matched_values:
continue
# Skip wenn in Advoware mit Marker
if value in advo_with_marker:
continue
# Hash-basierte Logik: Var1 vs Var3
if espo_wins or (espo_changed_since_sync and not advo_changed_since_sync):
# Var1: Neu in EspoCRM
self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value[:30]}...'")
diff['espo_new'].append((value, espo_item))
elif advo_changed_since_sync and not espo_changed_since_sync:
# Var3: In Advoware gelöscht
self.logger.info(f"[KOMM] 🗑️ Var3: Deleted in Advoware '{value[:30]}...'")
diff['advo_deleted'].append((value, espo_item))
else:
# Default: Var1 (neu in EspoCRM)
self.logger.info(f"[KOMM] Var1 (default): '{value[:30]}...'")
diff['espo_new'].append((value, espo_item))
# ========== 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:
import traceback
self.logger.error(f"[KOMM] Fehler bei Advoware→EspoCRM Apply: {e}")
self.logger.error(traceback.format_exc())
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', [])
# OPTIMIERUNG: Matche Var2 (Delete) + Var1 (New) mit gleichem kommKz
# → Direkt UPDATE statt DELETE+RELOAD+CREATE
var2_by_kommkz = {} # kommKz → [komm, ...]
var1_by_kommkz = {} # kommKz → [(value, espo_item), ...]
# Gruppiere Var2 nach kommKz
for komm in diff['espo_deleted']:
bemerkung = komm.get('bemerkung') or ''
marker = parse_marker(bemerkung)
if marker:
kommkz = marker['kommKz']
if kommkz not in var2_by_kommkz:
var2_by_kommkz[kommkz] = []
var2_by_kommkz[kommkz].append(komm)
# Gruppiere Var1 nach kommKz
for value, espo_item in diff['espo_new']:
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
if kommkz not in var1_by_kommkz:
var1_by_kommkz[kommkz] = []
var1_by_kommkz[kommkz].append((value, espo_item))
# Matche und führe direkte Updates aus
matched_var2_ids = set()
matched_var1_indices = {} # kommkz → set of matched indices
for kommkz in var2_by_kommkz.keys():
if kommkz in var1_by_kommkz:
var2_list = var2_by_kommkz[kommkz]
var1_list = var1_by_kommkz[kommkz]
# Matche paarweise
for i, (value, espo_item) in enumerate(var1_list):
if i < len(var2_list):
komm = var2_list[i]
komm_id = komm['id']
self.logger.info(f"[KOMM] 🔄 Var2+Var1 Match: kommKz={kommkz}, updating slot {komm_id} with '{value[:30]}...'")
# Direktes UPDATE statt DELETE+CREATE
await self.advoware.update_kommunikation(betnr, komm_id, {
'tlf': value,
'online': espo_item['primary'],
'bemerkung': create_marker(value, kommkz)
})
matched_var2_ids.add(komm_id)
if kommkz not in matched_var1_indices:
matched_var1_indices[kommkz] = set()
matched_var1_indices[kommkz].add(i)
result['created'] += 1
self.logger.info(f"[KOMM] ✅ Slot updated (optimized merge)")
# Unmatched Var2: Erstelle Empty Slots
for komm in diff['espo_deleted']:
komm_id = komm.get('id')
if komm_id not in matched_var2_ids:
synced_value = komm.get('_synced_value', '')
self.logger.info(f"[KOMM] 🗑️ Var2: Deleted in EspoCRM - komm_id={komm_id}, synced_value='{synced_value[:30]}...'")
await self._create_empty_slot(betnr, komm, synced_value=synced_value)
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
# Überspringe bereits gematchte Einträge (Var2+Var1 merged)
for idx, (value, espo_item) in enumerate(diff['espo_new']):
espo_type = espo_item.get('type', 'email' if '@' in value else None)
kommkz = detect_kommkz(value, advo_bet, espo_type=espo_type)
# Skip wenn bereits als Var2+Var1 Match verarbeitet
if kommkz in matched_var1_indices and idx in matched_var1_indices[kommkz]:
continue
self.logger.info(f"[KOMM] Var1: New in EspoCRM '{value[:30]}...', type={espo_item.get('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:
import traceback
self.logger.error(f"[KOMM] Fehler bei EspoCRM→Advoware Apply: {e}")
self.logger.error(traceback.format_exc())
result['errors'].append(str(e))
return result
# ========== HELPER METHODS ==========
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)
"""
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': '', # 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}, original_value='{log_value[:30]}...'")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Erstellen von Empty Slot: {e}")
self.logger.error(traceback.format_exc())
async def _revert_advoware_change(
self,
betnr: int,
advo_komm: Dict,
espo_synced_value: str,
advo_current_value: str,
advo_bet: Dict
) -> None:
"""
Revertiert Var6-Änderung in Advoware zurück auf EspoCRM-Wert
Verwendet bei direction='to_advoware' (EspoCRM wins):
- User hat in Advoware geändert
- Aber EspoCRM soll gewinnen
- → Setze Advoware zurück auf EspoCRM-Wert
Args:
advo_komm: Advoware Kommunikation mit Änderung
espo_synced_value: Der Wert der mit EspoCRM synchronisiert war (aus Marker)
advo_current_value: Der neue Wert in Advoware (User-Änderung)
"""
try:
komm_id = advo_komm['id']
bemerkung = advo_komm.get('bemerkung', '')
marker = parse_marker(bemerkung)
if not marker:
self.logger.error(f"[KOMM] Var6 ohne Marker - sollte nicht passieren! komm_id={komm_id}")
return
kommkz = marker['kommKz']
user_text = marker.get('user_text', '')
# Revert: Setze tlf zurück auf EspoCRM-Wert
new_marker = create_marker(espo_synced_value, kommkz, user_text)
update_data = {
'tlf': espo_synced_value,
'bemerkung': new_marker,
'online': advo_komm.get('online', False)
}
await self.advoware.update_kommunikation(betnr, komm_id, update_data)
self.logger.info(f"[KOMM] ✅ Reverted Var6: '{advo_current_value[:30]}...''{espo_synced_value[:30]}...' (komm_id={komm_id})")
except Exception as e:
import traceback
self.logger.error(f"[KOMM] Fehler beim Revert von Var6: {e}")
self.logger.error(traceback.format_exc())
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:
import traceback
self.logger.error(f"[KOMM] Fehler beim Update: {e}")
self.logger.error(traceback.format_exc())
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:
import traceback
self.logger.error(f"[KOMM] Fehler beim Erstellen/Reuse: {e}")
self.logger.error(traceback.format_exc())
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