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:
266
services/adressen_mapper.py
Normal file
266
services/adressen_mapper.py
Normal 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
696
services/adressen_sync.py
Normal 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
|
||||
333
services/kommunikation_mapper.py
Normal file
333
services/kommunikation_mapper.py
Normal 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
|
||||
998
services/kommunikation_sync_utils.py
Normal file
998
services/kommunikation_sync_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user