Files
motia-iii/services/adressen_sync.py

695 lines
28 KiB
Python

"""
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
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