333 lines
12 KiB
Python
333 lines
12 KiB
Python
"""
|
|
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', '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 → UPLOAD to Windows
|
|
if espo_doc and not windows_file:
|
|
return SyncAction(
|
|
action='UPLOAD_WINDOWS',
|
|
reason='File exists in EspoCRM 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
|
|
if len(updates) > 0:
|
|
updates['lastSyncTimestamp'] = datetime.now().isoformat()
|
|
|
|
needs_update = len(updates) > 0
|
|
|
|
if needs_update:
|
|
self._log(f"Metadata needs update: {list(updates.keys())}")
|
|
|
|
return needs_update, updates
|