""" Advoware Document Sync Business Logic Provides 3-way merge logic for document synchronization between: - Windows filesystem (USN-tracked) - EspoCRM (CRM database) - Advoware History (document timeline) """ from typing import Dict, Any, List, Optional, Literal, Tuple from dataclasses import dataclass from datetime import datetime from services.logging_utils import get_service_logger @dataclass class SyncAction: """ Represents a sync decision from 3-way merge. Attributes: action: Sync action to take reason: Human-readable explanation source: Which system is the source of truth needs_upload: True if file needs upload to Windows needs_download: True if file needs download from Windows """ action: Literal['CREATE', 'UPDATE_ESPO', 'UPLOAD_WINDOWS', 'DELETE', 'SKIP'] reason: str source: Literal['Windows', 'EspoCRM', 'Both', 'None'] needs_upload: bool needs_download: bool class AdvowareDocumentSyncUtils: """ Business logic for Advoware document sync. Provides methods for: - File list cleanup (filter by History) - 3-way merge decision logic - Conflict resolution - Metadata comparison """ def __init__(self, ctx): """ Initialize utils with context. Args: ctx: Motia context for logging """ self.ctx = ctx self.logger = get_service_logger(__name__, ctx) self.logger.info("AdvowareDocumentSyncUtils initialized") def _log(self, message: str, level: str = 'info') -> None: """Helper for consistent logging""" getattr(self.logger, level)(f"[AdvowareDocumentSyncUtils] {message}") def cleanup_file_list( self, windows_files: List[Dict[str, Any]], advoware_history: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """ Remove files from Windows list that are not in Advoware History. Strategy: Only sync files that have a History entry in Advoware. Files without History are ignored (may be temporary/system files). Args: windows_files: List of files from Windows Watcher advoware_history: List of History entries from Advoware Returns: Filtered list of Windows files that have History entries """ self._log(f"Cleaning file list: {len(windows_files)} Windows files, {len(advoware_history)} History entries") # Build set of full paths from History (normalized to lowercase) history_paths = set() history_file_details = [] # Track for logging for entry in advoware_history: datei = entry.get('datei', '') if datei: # Use full path for matching (case-insensitive) history_paths.add(datei.lower()) history_file_details.append({'path': datei}) self._log(f"📊 History has {len(history_paths)} unique file paths") # Log first 10 History paths for i, detail in enumerate(history_file_details[:10], 1): self._log(f" {i}. {detail['path']}") # Filter Windows files by matching full path cleaned = [] matches = [] for win_file in windows_files: win_path = win_file.get('path', '').lower() if win_path in history_paths: cleaned.append(win_file) matches.append(win_path) self._log(f"After cleanup: {len(cleaned)} files with History entries") # Log matches if matches: self._log(f"✅ Matched files (by full path):") for match in matches[:10]: # Zeige erste 10 self._log(f" - {match}") return cleaned def merge_three_way( self, espo_doc: Optional[Dict[str, Any]], windows_file: Optional[Dict[str, Any]], advo_history: Optional[Dict[str, Any]] ) -> SyncAction: """ Perform 3-way merge to determine sync action. Decision logic: 1. If Windows USN > EspoCRM sync_usn → Windows changed → Download 2. If blake3Hash != syncHash (EspoCRM) → EspoCRM changed → Upload 3. If both changed → Conflict → Resolve by timestamp 4. If neither changed → Skip Args: espo_doc: Document from EspoCRM (can be None if not exists) windows_file: File info from Windows (can be None if not exists) advo_history: History entry from Advoware (can be None if not exists) Returns: SyncAction with decision """ self._log("Performing 3-way merge") # Case 1: File only in Windows → CREATE in EspoCRM if windows_file and not espo_doc: return SyncAction( action='CREATE', reason='File exists in Windows but not in EspoCRM', source='Windows', needs_upload=False, needs_download=True ) # Case 2: File only in EspoCRM → DELETE (file was deleted from Windows/Advoware) if espo_doc and not windows_file: # Check if also not in History (means it was deleted in Advoware) if not advo_history: return SyncAction( action='DELETE', reason='File deleted from Windows and Advoware History', source='Both', needs_upload=False, needs_download=False ) else: # Still in History but not in Windows - Upload not implemented return SyncAction( action='UPLOAD_WINDOWS', reason='File exists in EspoCRM/History but not in Windows', source='EspoCRM', needs_upload=True, needs_download=False ) # Case 3: File in both → Compare hashes and USNs if espo_doc and windows_file: # Extract comparison fields windows_usn = windows_file.get('usn', 0) windows_blake3 = windows_file.get('blake3Hash', '') espo_sync_usn = espo_doc.get('sync_usn', 0) espo_sync_hash = espo_doc.get('syncHash', '') # Check if Windows changed windows_changed = windows_usn != espo_sync_usn # Check if EspoCRM changed espo_changed = ( windows_blake3 and espo_sync_hash and windows_blake3.lower() != espo_sync_hash.lower() ) # Case 3a: Both changed → Conflict if windows_changed and espo_changed: return self.resolve_conflict(espo_doc, windows_file) # Case 3b: Only Windows changed → Download if windows_changed: return SyncAction( action='UPDATE_ESPO', reason=f'Windows changed (USN: {espo_sync_usn} → {windows_usn})', source='Windows', needs_upload=False, needs_download=True ) # Case 3c: Only EspoCRM changed → Upload if espo_changed: return SyncAction( action='UPLOAD_WINDOWS', reason='EspoCRM changed (hash mismatch)', source='EspoCRM', needs_upload=True, needs_download=False ) # Case 3d: Neither changed → Skip return SyncAction( action='SKIP', reason='No changes detected', source='None', needs_upload=False, needs_download=False ) # Case 4: File in neither → Skip return SyncAction( action='SKIP', reason='File does not exist in any system', source='None', needs_upload=False, needs_download=False ) def resolve_conflict( self, espo_doc: Dict[str, Any], windows_file: Dict[str, Any] ) -> SyncAction: """ Resolve conflict when both Windows and EspoCRM changed. Strategy: Newest timestamp wins. Args: espo_doc: Document from EspoCRM windows_file: File info from Windows Returns: SyncAction with conflict resolution """ self._log("⚠️ Conflict detected: Both Windows and EspoCRM changed", level='warning') # Get timestamps try: # EspoCRM modified timestamp espo_modified_str = espo_doc.get('modifiedAt', espo_doc.get('createdAt', '')) espo_modified = datetime.fromisoformat(espo_modified_str.replace('Z', '+00:00')) # Windows modified timestamp windows_modified_str = windows_file.get('modified', '') windows_modified = datetime.fromisoformat(windows_modified_str.replace('Z', '+00:00')) # Compare timestamps if espo_modified > windows_modified: self._log(f"Conflict resolution: EspoCRM wins (newer: {espo_modified} > {windows_modified})") return SyncAction( action='UPLOAD_WINDOWS', reason=f'Conflict: EspoCRM newer ({espo_modified} > {windows_modified})', source='EspoCRM', needs_upload=True, needs_download=False ) else: self._log(f"Conflict resolution: Windows wins (newer: {windows_modified} >= {espo_modified})") return SyncAction( action='UPDATE_ESPO', reason=f'Conflict: Windows newer ({windows_modified} >= {espo_modified})', source='Windows', needs_upload=False, needs_download=True ) except Exception as e: self._log(f"Error parsing timestamps for conflict resolution: {e}", level='error') # Fallback: Windows wins (safer to preserve data on filesystem) return SyncAction( action='UPDATE_ESPO', reason='Conflict: Timestamp parse failed, defaulting to Windows', source='Windows', needs_upload=False, needs_download=True ) def should_sync_metadata( self, espo_doc: Dict[str, Any], advo_history: Dict[str, Any] ) -> Tuple[bool, Dict[str, Any]]: """ Check if metadata needs update in EspoCRM. Compares History metadata (text, art, hNr) with EspoCRM fields. Always syncs metadata changes even if file content hasn't changed. Args: espo_doc: Document from EspoCRM advo_history: History entry from Advoware Returns: (needs_update: bool, updates: Dict) - Updates to apply if needed """ updates = {} # Map History fields to correct EspoCRM field names history_text = advo_history.get('text', '') history_art = advo_history.get('art', '') history_hnr = advo_history.get('hNr') espo_bemerkung = espo_doc.get('advowareBemerkung', '') espo_art = espo_doc.get('advowareArt', '') espo_hnr = espo_doc.get('hnr') # Check if different - sync metadata independently of file changes if history_text != espo_bemerkung: updates['advowareBemerkung'] = history_text if history_art != espo_art: updates['advowareArt'] = history_art if history_hnr is not None and history_hnr != espo_hnr: updates['hnr'] = history_hnr # Always update lastSyncTimestamp when metadata changes (EspoCRM format) if len(updates) > 0: updates['lastSyncTimestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S') needs_update = len(updates) > 0 if needs_update: self._log(f"Metadata needs update: {list(updates.keys())}") return needs_update, updates