- 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
439 lines
17 KiB
Python
439 lines
17 KiB
Python
"""
|
|
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
|
|
}
|
|
)
|