Migrate VMH Integration - Phase 3: Core sync handlers & utilities
- Added 5 core service modules:
* services/notification_utils.py: NotificationManager for manual actions (412 lines)
* services/advoware_service.py: Extended Advoware operations wrapper
* services/espocrm_mapper.py: BeteiligteMapper for data transformation (198 lines)
* services/bankverbindungen_mapper.py: BankverbindungenMapper (174 lines)
* services/beteiligte_sync_utils.py: BeteiligteSync with complex logic (663 lines)
- Distributed locking via Redis + syncStatus
- rowId-based change detection (primary) + timestamp fallback
- Retry with exponential backoff (1min, 5min, 15min, 1h, 4h)
- Conflict resolution (EspoCRM wins)
- Soft-delete handling & round-trip validation
- Added 2 event handler steps:
* steps/vmh/beteiligte_sync_event_step.py: Handles vmh.beteiligte.* queue events
- CREATE: New Beteiligte → POST to Advoware
- UPDATE: Bi-directional sync with conflict resolution
- DELETE: Not yet implemented
- SYNC_CHECK: Periodic sync check via cron
* steps/vmh/bankverbindungen_sync_event_step.py: Handles vmh.bankverbindungen.* events
- CREATE: New Bankverbindung → POST to Advoware
- UPDATE/DELETE: Send notification (API limitation - no PUT/DELETE support)
- Added pytz dependency to pyproject.toml (required for timezone handling)
System Status:
- 27 steps registered (14 VMH-specific)
- 8 queue subscriptions active (4 Beteiligte + 4 Bankverbindungen)
- All webhook endpoints operational and emitting queue events
- Sync handlers ready for processing
Note: Kommunikation sync (kommunikation_sync_utils.py) not yet migrated.
Complex bi-directional sync for Telefon/Email/Fax will be added in Phase 4.
Updated MIGRATION_STATUS.md
This commit is contained in:
438
services/notification_utils.py
Normal file
438
services/notification_utils.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
Zentrale Notification-Utilities für manuelle Eingriffe
|
||||
=======================================================
|
||||
|
||||
Wenn Advoware-API-Limitierungen existieren (z.B. READ-ONLY Felder),
|
||||
werden Notifications in EspoCRM erstellt, damit User manuelle Eingriffe
|
||||
vornehmen können.
|
||||
|
||||
Features:
|
||||
- Notifications an assigned Users
|
||||
- Task-Erstellung für manuelle Eingriffe
|
||||
- Zentrale Verwaltung aller Notification-Types
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, Literal, List
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
|
||||
class NotificationManager:
|
||||
"""
|
||||
Zentrale Klasse für Notifications bei Sync-Problemen
|
||||
"""
|
||||
|
||||
def __init__(self, espocrm_api, context=None):
|
||||
"""
|
||||
Args:
|
||||
espocrm_api: EspoCRMAPI instance
|
||||
context: Optional context für Logging
|
||||
"""
|
||||
self.espocrm = espocrm_api
|
||||
self.context = context
|
||||
self.logger = context.logger if context else logging.getLogger(__name__)
|
||||
|
||||
async def notify_manual_action_required(
|
||||
self,
|
||||
entity_type: str,
|
||||
entity_id: str,
|
||||
action_type: Literal[
|
||||
"address_delete_required",
|
||||
"address_reactivate_required",
|
||||
"address_field_update_required",
|
||||
"readonly_field_conflict",
|
||||
"missing_in_advoware",
|
||||
"sync_conflict",
|
||||
"entity_deleted_in_source",
|
||||
"general_manual_action"
|
||||
],
|
||||
details: Dict[str, Any],
|
||||
assigned_user_id: Optional[str] = None,
|
||||
create_task: bool = True
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Erstellt Notification und optional Task für manuelle Eingriffe
|
||||
|
||||
Args:
|
||||
entity_type: EspoCRM Entity Type (z.B. 'CAdressen', 'CBeteiligte')
|
||||
entity_id: Entity ID in EspoCRM
|
||||
action_type: Art der manuellen Aktion
|
||||
details: Detaillierte Informationen
|
||||
assigned_user_id: User der benachrichtigt werden soll (optional)
|
||||
create_task: Ob zusätzlich ein Task erstellt werden soll
|
||||
|
||||
Returns:
|
||||
Dict mit notification_id und optional task_id
|
||||
"""
|
||||
try:
|
||||
# Hole Entity-Daten
|
||||
entity = await self.espocrm.get_entity(entity_type, entity_id)
|
||||
entity_name = entity.get('name', f"{entity_type} {entity_id}")
|
||||
|
||||
# Falls kein assigned_user, versuche aus Entity zu holen
|
||||
if not assigned_user_id:
|
||||
assigned_user_id = entity.get('assignedUserId')
|
||||
|
||||
# Erstelle Notification
|
||||
notification_data = self._build_notification_message(
|
||||
action_type, entity_type, entity_name, details
|
||||
)
|
||||
|
||||
notification_id = await self._create_notification(
|
||||
user_id=assigned_user_id,
|
||||
message=notification_data['message'],
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id
|
||||
)
|
||||
|
||||
result = {'notification_id': notification_id}
|
||||
|
||||
# Optional: Task erstellen
|
||||
if create_task:
|
||||
task_id = await self._create_task(
|
||||
name=notification_data['task_name'],
|
||||
description=notification_data['task_description'],
|
||||
parent_type=entity_type,
|
||||
parent_id=entity_id,
|
||||
assigned_user_id=assigned_user_id,
|
||||
priority=notification_data['priority']
|
||||
)
|
||||
result['task_id'] = task_id
|
||||
|
||||
self.logger.info(
|
||||
f"Manual action notification created: {action_type} for "
|
||||
f"{entity_type}/{entity_id}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create notification: {e}")
|
||||
raise
|
||||
|
||||
def _build_notification_message(
|
||||
self,
|
||||
action_type: str,
|
||||
entity_type: str,
|
||||
entity_name: str,
|
||||
details: Dict[str, Any]
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Erstellt Notification-Message basierend auf Action-Type
|
||||
|
||||
Returns:
|
||||
Dict mit 'message', 'task_name', 'task_description', 'priority'
|
||||
"""
|
||||
|
||||
if action_type == "address_delete_required":
|
||||
return {
|
||||
'message': (
|
||||
f"🗑️ Adresse in Advoware löschen erforderlich\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"Grund: Advoware API unterstützt kein DELETE und gueltigBis ist READ-ONLY\n"
|
||||
f"Bitte manuell in Advoware löschen oder deaktivieren."
|
||||
),
|
||||
'task_name': f"Adresse in Advoware löschen: {entity_name}",
|
||||
'task_description': (
|
||||
f"MANUELLE AKTION ERFORDERLICH\n\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"BetNr: {details.get('betnr', 'N/A')}\n"
|
||||
f"Adresse: {details.get('strasse', '')}, {details.get('plz', '')} {details.get('ort', '')}\n\n"
|
||||
f"GRUND:\n"
|
||||
f"- DELETE API nicht verfügbar (403 Forbidden)\n"
|
||||
f"- gueltigBis ist READ-ONLY (kann nicht nachträglich gesetzt werden)\n\n"
|
||||
f"AKTION:\n"
|
||||
f"1. In Advoware Web-Interface einloggen\n"
|
||||
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
|
||||
f"3. Adresse suchen: {details.get('strasse', '')}\n"
|
||||
f"4. Adresse löschen oder deaktivieren\n\n"
|
||||
f"Nach Erledigung: Task als 'Completed' markieren."
|
||||
),
|
||||
'priority': 'Normal'
|
||||
}
|
||||
|
||||
elif action_type == "address_reactivate_required":
|
||||
return {
|
||||
'message': (
|
||||
f"♻️ Adresse-Reaktivierung in Advoware erforderlich\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"Grund: gueltigBis kann nicht nachträglich geändert werden\n"
|
||||
f"Bitte neue Adresse in Advoware erstellen."
|
||||
),
|
||||
'task_name': f"Neue Adresse in Advoware erstellen: {entity_name}",
|
||||
'task_description': (
|
||||
f"MANUELLE AKTION ERFORDERLICH\n\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"BetNr: {details.get('betnr', 'N/A')}\n\n"
|
||||
f"GRUND:\n"
|
||||
f"Diese Adresse wurde reaktiviert, aber die alte Adresse in Advoware "
|
||||
f"ist abgelaufen (gueltigBis in Vergangenheit). Da gueltigBis READ-ONLY ist, "
|
||||
f"muss eine neue Adresse erstellt werden.\n\n"
|
||||
f"AKTION:\n"
|
||||
f"1. In Advoware Web-Interface einloggen\n"
|
||||
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
|
||||
f"3. Neue Adresse erstellen:\n"
|
||||
f" - Straße: {details.get('strasse', '')}\n"
|
||||
f" - PLZ: {details.get('plz', '')}\n"
|
||||
f" - Ort: {details.get('ort', '')}\n"
|
||||
f" - Land: {details.get('land', '')}\n"
|
||||
f" - Bemerkung: EspoCRM-ID: {details.get('espocrm_id', '')}\n"
|
||||
f"4. Sync erneut durchführen, damit Mapping aktualisiert wird\n\n"
|
||||
f"Nach Erledigung: Task als 'Completed' markieren."
|
||||
),
|
||||
'priority': 'Normal'
|
||||
}
|
||||
|
||||
elif action_type == "address_field_update_required":
|
||||
readonly_fields = details.get('readonly_fields', [])
|
||||
return {
|
||||
'message': (
|
||||
f"⚠️ Adressfelder in Advoware können nicht aktualisiert werden\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"READ-ONLY Felder: {', '.join(readonly_fields)}\n"
|
||||
f"Bitte manuell in Advoware ändern."
|
||||
),
|
||||
'task_name': f"Adressfelder in Advoware aktualisieren: {entity_name}",
|
||||
'task_description': (
|
||||
f"MANUELLE AKTION ERFORDERLICH\n\n"
|
||||
f"Adresse: {entity_name}\n"
|
||||
f"BetNr: {details.get('betnr', 'N/A')}\n\n"
|
||||
f"GRUND:\n"
|
||||
f"Folgende Felder sind in Advoware API READ-ONLY und können nicht "
|
||||
f"via PUT geändert werden:\n"
|
||||
f"- {', '.join(readonly_fields)}\n\n"
|
||||
f"GEWÜNSCHTE ÄNDERUNGEN:\n" +
|
||||
'\n'.join([f" - {k}: {v}" for k, v in details.get('changes', {}).items()]) +
|
||||
f"\n\nAKTION:\n"
|
||||
f"1. In Advoware Web-Interface einloggen\n"
|
||||
f"2. Beteiligten mit BetNr {details.get('betnr', 'N/A')} öffnen\n"
|
||||
f"3. Adresse suchen und obige Felder manuell ändern\n"
|
||||
f"4. Sync erneut durchführen zur Bestätigung\n\n"
|
||||
f"Nach Erledigung: Task als 'Completed' markieren."
|
||||
),
|
||||
'priority': 'Low'
|
||||
}
|
||||
|
||||
elif action_type == "readonly_field_conflict":
|
||||
return {
|
||||
'message': (
|
||||
f"⚠️ Sync-Konflikt bei READ-ONLY Feldern\n"
|
||||
f"{entity_type}: {entity_name}\n"
|
||||
f"Änderungen konnten nicht synchronisiert werden."
|
||||
),
|
||||
'task_name': f"Sync-Konflikt prüfen: {entity_name}",
|
||||
'task_description': (
|
||||
f"SYNC-KONFLIKT\n\n"
|
||||
f"{entity_type}: {entity_name}\n\n"
|
||||
f"PROBLEM:\n"
|
||||
f"Felder wurden in EspoCRM geändert, sind aber in Advoware READ-ONLY.\n\n"
|
||||
f"BETROFFENE FELDER:\n" +
|
||||
'\n'.join([f" - {k}: {v}" for k, v in details.get('conflicts', {}).items()]) +
|
||||
f"\n\nOPTIONEN:\n"
|
||||
f"1. Änderungen in EspoCRM rückgängig machen (Advoware = Master)\n"
|
||||
f"2. Änderungen manuell in Advoware vornehmen\n"
|
||||
f"3. Feld als 'nicht synchronisiert' akzeptieren\n\n"
|
||||
f"Nach Entscheidung: Task als 'Completed' markieren."
|
||||
),
|
||||
'priority': 'Normal'
|
||||
}
|
||||
|
||||
elif action_type == "sync_conflict":
|
||||
return {
|
||||
'message': (
|
||||
f"⚠️ Sync-Konflikt\n"
|
||||
f"{entity_type}: {entity_name}\n"
|
||||
f"{details.get('message', 'Beide Systeme haben Änderungen')}"
|
||||
),
|
||||
'task_name': f"Sync-Konflikt: {entity_name}",
|
||||
'task_description': details.get('description', 'Keine Details verfügbar'),
|
||||
'priority': details.get('priority', 'Normal')
|
||||
}
|
||||
|
||||
elif action_type == "entity_deleted_in_source":
|
||||
return {
|
||||
'message': (
|
||||
f"🗑️ Element in Quellsystem gelöscht\n"
|
||||
f"{entity_type}: {entity_name}\n"
|
||||
f"{details.get('message', 'Wurde im Zielsystem gelöscht')}"
|
||||
),
|
||||
'task_name': f"Gelöscht: {entity_name}",
|
||||
'task_description': details.get('description', 'Element wurde gelöscht'),
|
||||
'priority': details.get('priority', 'High')
|
||||
}
|
||||
|
||||
elif action_type == "missing_in_advoware":
|
||||
return {
|
||||
'message': (
|
||||
f"❓ Element fehlt in Advoware\n"
|
||||
f"{entity_type}: {entity_name}\n"
|
||||
f"Bitte manuell in Advoware erstellen."
|
||||
),
|
||||
'task_name': f"In Advoware erstellen: {entity_name}",
|
||||
'task_description': (
|
||||
f"MANUELLE AKTION ERFORDERLICH\n\n"
|
||||
f"{entity_type}: {entity_name}\n\n"
|
||||
f"GRUND:\n"
|
||||
f"Dieses Element existiert in EspoCRM, aber nicht in Advoware.\n"
|
||||
f"Möglicherweise wurde es direkt in EspoCRM erstellt.\n\n"
|
||||
f"DATEN:\n" +
|
||||
'\n'.join([f" - {k}: {v}" for k, v in details.items() if k != 'espocrm_id']) +
|
||||
f"\n\nAKTION:\n"
|
||||
f"1. In Advoware Web-Interface einloggen\n"
|
||||
f"2. Element mit obigen Daten manuell erstellen\n"
|
||||
f"3. Sync erneut durchführen für Mapping\n\n"
|
||||
f"Nach Erledigung: Task als 'Completed' markieren."
|
||||
),
|
||||
'priority': 'Normal'
|
||||
}
|
||||
|
||||
else: # general_manual_action
|
||||
return {
|
||||
'message': (
|
||||
f"🔧 Manuelle Aktion erforderlich\n"
|
||||
f"{entity_type}: {entity_name}\n"
|
||||
f"{details.get('message', 'Bitte prüfen.')}"
|
||||
),
|
||||
'task_name': f"Manuelle Aktion: {entity_name}",
|
||||
'task_description': (
|
||||
f"MANUELLE AKTION ERFORDERLICH\n\n"
|
||||
f"{entity_type}: {entity_name}\n\n"
|
||||
f"{details.get('description', 'Keine Details verfügbar.')}"
|
||||
),
|
||||
'priority': details.get('priority', 'Normal')
|
||||
}
|
||||
|
||||
async def _create_notification(
|
||||
self,
|
||||
user_id: Optional[str],
|
||||
message: str,
|
||||
entity_type: str,
|
||||
entity_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt EspoCRM Notification (In-App)
|
||||
|
||||
Returns:
|
||||
notification_id
|
||||
"""
|
||||
if not user_id:
|
||||
self.logger.warning("No user assigned - notification not created")
|
||||
return None
|
||||
|
||||
notification_data = {
|
||||
'type': 'Message',
|
||||
'message': message,
|
||||
'userId': user_id,
|
||||
'relatedType': entity_type,
|
||||
'relatedId': entity_id,
|
||||
'read': False
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self.espocrm.create_entity('Notification', notification_data)
|
||||
return result.get('id')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create notification: {e}")
|
||||
return None
|
||||
|
||||
async def _create_task(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
parent_type: str,
|
||||
parent_id: str,
|
||||
assigned_user_id: Optional[str],
|
||||
priority: str = 'Normal'
|
||||
) -> str:
|
||||
"""
|
||||
Erstellt EspoCRM Task
|
||||
|
||||
Returns:
|
||||
task_id
|
||||
"""
|
||||
# Due Date: 7 Tage in Zukunft
|
||||
due_date = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d')
|
||||
|
||||
task_data = {
|
||||
'name': name,
|
||||
'description': description,
|
||||
'status': 'Not Started',
|
||||
'priority': priority,
|
||||
'dateEnd': due_date,
|
||||
'parentType': parent_type,
|
||||
'parentId': parent_id,
|
||||
'assignedUserId': assigned_user_id
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self.espocrm.create_entity('Task', task_data)
|
||||
return result.get('id')
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create task: {e}")
|
||||
return None
|
||||
|
||||
async def resolve_task(self, task_id: str) -> bool:
|
||||
"""
|
||||
Markiert Task als erledigt
|
||||
|
||||
Args:
|
||||
task_id: Task ID
|
||||
|
||||
Returns:
|
||||
True wenn erfolgreich
|
||||
"""
|
||||
try:
|
||||
await self.espocrm.update_entity('Task', task_id, {
|
||||
'status': 'Completed'
|
||||
})
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to complete task {task_id}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# Helper-Funktionen für häufige Use-Cases
|
||||
|
||||
async def notify_address_delete_required(
|
||||
notification_manager: NotificationManager,
|
||||
address_entity_id: str,
|
||||
betnr: str,
|
||||
address_data: Dict[str, Any]
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Shortcut: Notification für Adresse löschen
|
||||
"""
|
||||
return await notification_manager.notify_manual_action_required(
|
||||
entity_type='CAdressen',
|
||||
entity_id=address_entity_id,
|
||||
action_type='address_delete_required',
|
||||
details={
|
||||
'betnr': betnr,
|
||||
'strasse': address_data.get('adresseStreet'),
|
||||
'plz': address_data.get('adressePostalCode'),
|
||||
'ort': address_data.get('adresseCity'),
|
||||
'espocrm_id': address_entity_id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def notify_address_readonly_fields(
|
||||
notification_manager: NotificationManager,
|
||||
address_entity_id: str,
|
||||
betnr: str,
|
||||
readonly_fields: List[str],
|
||||
changes: Dict[str, Any]
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Shortcut: Notification für READ-ONLY Felder
|
||||
"""
|
||||
return await notification_manager.notify_manual_action_required(
|
||||
entity_type='CAdressen',
|
||||
entity_id=address_entity_id,
|
||||
action_type='address_field_update_required',
|
||||
details={
|
||||
'betnr': betnr,
|
||||
'readonly_fields': readonly_fields,
|
||||
'changes': changes
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user