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:
bsiggel
2026-03-01 22:19:36 +00:00
parent 0216c4c3ae
commit 014947e9e0
9 changed files with 2283 additions and 0 deletions

View 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
}
)