""" Service Contract Migration Wizard ================================== Wizard for migrating vTiger service contracts to Hub archive system. Workflow: 1. User selects service contract from vTiger 2. Load contract's cases + timelogs 3. For each case: archive to tticket_archived_tickets 4. For each timelog: transfer hours to customer's klippekort (top-up) 5. Wizard displays progress with dry-run support Dry-Run Mode: - All operations logged but not committed - Database transactions rolled back - UI shows what would happen """ import logging import json import re from datetime import datetime from decimal import Decimal from typing import List, Dict, Optional, Any, Tuple from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.config import settings from app.services.vtiger_service import get_vtiger_service from app.ticket.backend.klippekort_service import KlippekortService logger = logging.getLogger(__name__) class ServiceContractWizardService: """Service for vTiger service contract migration wizard""" @staticmethod async def get_active_contracts() -> List[Dict[str, Any]]: """ Fetch list of active service contracts from vTiger Returns: List of contracts with id, contract_number, subject, account_id, etc. """ try: vtiger_service = get_vtiger_service() contracts = await vtiger_service.get_service_contracts() logger.info(f"✅ Fetched {len(contracts)} active service contracts from vTiger") return contracts except Exception as e: logger.error(f"❌ Error fetching service contracts: {e}") return [] @staticmethod async def load_contract_detailed_data( contract_id: str, account_id: str = "" ) -> Dict[str, Any]: """ Load all data for a service contract: cases + timelogs Args: contract_id: vTiger service contract ID (e.g., "75x123") - required account_id: vTiger account ID (e.g., "3x760") - optional, will try to extract from contract Returns: Dict with contract info + cases + timelogs """ try: vtiger_service = get_vtiger_service() # Fetch contract details (without account filter if account_id not provided) contracts = await vtiger_service.get_service_contracts(account_id if account_id else None) contract = next((c for c in contracts if c.get('id') == contract_id), None) if not contract: logger.error(f"❌ Service contract {contract_id} not found") return {} # Extract account_id from contract if not provided if not account_id: account_id = ( contract.get('account_id') or contract.get('accountid') or contract.get('cf_service_contracts_account') or contract.get('sc_related_to') or "" ) if not account_id: logger.error(f"❌ Could not determine account_id for contract {contract_id}") return {} logger.info(f"ℹ️ Extracted account_id={account_id} from contract") # Fetch cases linked to contract cases = await vtiger_service.get_service_contract_cases(contract_id) logger.info(f"✅ Found {len(cases)} cases for contract {contract_id}") # Fetch timelogs linked to contract timelogs = await vtiger_service.get_service_contract_timelogs(contract_id) logger.info(f"✅ Found {len(timelogs)} timelogs for contract {contract_id}") # Get account info for klippekort lookup account = await vtiger_service.get_account(account_id) # Map vTiger account to Hub customer customer_id = ServiceContractWizardService._lookup_customer_id(account_id) if not customer_id: logger.warning(f"⚠️ No Hub customer found for vTiger account {account_id}") # Get available klippekort for customer available_cards = KlippekortService.get_active_cards_for_customer(customer_id) if customer_id else [] return { 'contract_id': contract_id, 'contract_number': contract.get('contract_number') or contract.get('contract_no', ''), 'subject': contract.get('subject', ''), 'account_id': account_id, 'account_name': account.get('accountname', '') if account else '', 'customer_id': customer_id, 'cases': cases, 'timelogs': timelogs, 'available_cards': available_cards, 'total_items': len(cases) + len(timelogs), } except Exception as e: logger.error(f"❌ Error loading contract data: {e}") return {} @staticmethod def _lookup_customer_id(vtiger_account_id: str) -> Optional[int]: """ Map vTiger account ID to Hub customer ID via tmodule_customers table Args: vtiger_account_id: vTiger account ID (e.g., "3x760") Returns: Hub customer ID or None """ try: result = execute_query_single( "SELECT id FROM tmodule_customers WHERE vtiger_id = %s LIMIT 1", (vtiger_account_id,) ) return result['id'] if result else None except Exception as e: logger.error(f"❌ Error looking up customer ID: {e}") return None @staticmethod def archive_case( case_data: Dict[str, Any], contract_id: str, dry_run: bool = False ) -> Tuple[bool, str, Optional[int]]: """ Archive a single case to tticket_archived_tickets Args: case_data: Case dict from vTiger (id, title, description, etc.) contract_id: Service contract ID (for reference) dry_run: If True, log only without commit Returns: (success: bool, message: str, archived_id: int or None) """ try: case_id = case_data.get('id') title = case_data.get('title', case_data.get('subject', 'Untitled')) description = case_data.get('description', '') status = case_data.get('ticketstatus', case_data.get('status', 'Open')) priority = case_data.get('priority', 'Normal') if dry_run: logger.info(f"🔍 DRY RUN: Would archive case {case_id}: '{title}'") return (True, f"[DRY RUN] Would archive: {title}", None) # Archive to tticket_archived_tickets archived_id = execute_insert( """ INSERT INTO tticket_archived_tickets ( source_system, external_id, ticket_number, title, description, status, priority, source_created_at, raw_data ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( 'vtiger_service_contract', case_id, f"SC:{contract_id}", # Reference to service contract title, description, status, priority, datetime.now(), json.dumps(case_data) ) ) logger.info(f"✅ Archived case {case_id} to tticket_archived_tickets (ID: {archived_id})") return (True, f"Archived: {title}", archived_id) except Exception as e: logger.error(f"❌ Error archiving case: {e}") return (False, f"Error: {str(e)}", None) @staticmethod def _extract_timelog_hours(timelog_data: Dict[str, Any]) -> Tuple[Decimal, Decimal]: """ Extract and normalize timelog hours from vTiger payload. Returns: (normalized_hours, raw_hours) """ raw_value = None field_used = None for key in ("time_spent", "duration", "total_hours", "hours"): value = timelog_data.get(key) if value not in (None, ""): raw_value = value field_used = key break # vTiger's 'duration' field is in seconds, convert to minutes if field_used == "duration" and raw_value: try: seconds = Decimal(str(raw_value)) raw_minutes = seconds / Decimal(60) except Exception: raw_minutes = ServiceContractWizardService._parse_timelog_minutes(raw_value) else: raw_minutes = ServiceContractWizardService._parse_timelog_minutes(raw_value) normalized = raw_minutes / Decimal(60) return normalized, raw_minutes @staticmethod def _parse_timelog_minutes(raw_value: Any) -> Decimal: """Parse vTiger timelog time spent into minutes.""" if raw_value in (None, ""): return Decimal(0) if isinstance(raw_value, Decimal): return raw_value if isinstance(raw_value, (int, float)): return Decimal(str(raw_value)) raw_str = str(raw_value).strip().lower() if not raw_str: return Decimal(0) time_match = re.match(r"^(\d+):(\d+)(?::(\d+))?$", raw_str) if time_match: hours = int(time_match.group(1)) minutes = int(time_match.group(2)) seconds = int(time_match.group(3) or 0) return Decimal(hours * 60 + minutes) + (Decimal(seconds) / Decimal(60)) total_minutes = Decimal(0) has_unit = False unit_patterns = [ (r"(\d+(?:[\.,]\d+)?)\s*(?:h|hour|hours)\b", Decimal(60)), (r"(\d+(?:[\.,]\d+)?)\s*(?:m|min|minute|minutes)\b", Decimal(1)), (r"(\d+(?:[\.,]\d+)?)\s*(?:s|sec|second|seconds)\b", Decimal(1) / Decimal(60)), ] for pattern, multiplier in unit_patterns: match = re.search(pattern, raw_str) if match: has_unit = True value = Decimal(match.group(1).replace(",", ".")) total_minutes += value * multiplier if has_unit: return total_minutes try: return Decimal(raw_str.replace(",", ".")) except Exception: return Decimal(0) @staticmethod def _apply_rounding_for_card(card_id: int, hours: Decimal) -> Decimal: """ Apply rounding rules for a prepaid card or fallback to timetracking settings. """ from decimal import ROUND_CEILING, ROUND_DOWN, ROUND_HALF_UP card = execute_query_single( "SELECT rounding_minutes FROM tticket_prepaid_cards WHERE id = %s", (card_id,) ) rounding_minutes = int(card.get('rounding_minutes') or 0) if card else 0 if rounding_minutes > 0: interval = Decimal(rounding_minutes) / Decimal(60) return (hours / interval).to_integral_value(rounding=ROUND_CEILING) * interval if settings.TIMETRACKING_AUTO_ROUND: increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) method = settings.TIMETRACKING_ROUND_METHOD if method == "down": return (hours / increment).to_integral_value(rounding=ROUND_DOWN) * increment if method == "nearest": return (hours / increment).to_integral_value(rounding=ROUND_HALF_UP) * increment return (hours / increment).to_integral_value(rounding=ROUND_CEILING) * increment return hours @staticmethod def transfer_timelog_to_klippekort( timelog_data: Dict[str, Any], card_id: int, customer_id: int, contract_id: str, dry_run: bool = False ) -> Tuple[bool, str, Optional[Dict]]: """ Transfer timelog hours to customer's klippekort via top-up Args: timelog_data: Timelog dict from vTiger (id, hours, description, etc.) card_id: Klippekort card ID to transfer to customer_id: Hub customer ID contract_id: Service contract ID (for reference) dry_run: If True, calculate but don't commit Returns: (success: bool, message: str, transaction_result: dict or None) """ try: timelog_id = timelog_data.get('id') hours, raw_minutes = ServiceContractWizardService._extract_timelog_hours(timelog_data) description = timelog_data.get('description', '') work_date = timelog_data.get('workdate', '') if raw_minutes > 0: logger.info( f"ℹ️ Normalized timelog {timelog_id} minutes {raw_minutes} to hours {hours}" ) if hours <= 0: logger.warning(f"⚠️ Skipping timelog {timelog_id} with {hours} hours") return (False, f"Skipped: 0 hours", None) # Verify card exists and belongs to customer card = KlippekortService.get_card(card_id) if not card or card['customer_id'] != customer_id: msg = f"Card {card_id} not found or doesn't belong to customer {customer_id}" logger.error(f"❌ {msg}") return (False, msg, None) rounded_hours = ServiceContractWizardService._apply_rounding_for_card(card_id, hours) if dry_run: logger.info( f"🔍 DRY RUN: Would transfer {rounded_hours}h to card {card_id} from timelog {timelog_id}" ) return (True, f"[DRY RUN] Would transfer {rounded_hours}h to card", None) # Top-up klippekort with timelog hours transaction = KlippekortService.top_up_card( card_id, rounded_hours, user_id=None, note=f"SC migration: {description} (vTiger {timelog_id}) from {work_date}" ) logger.info(f"✅ Transferred {rounded_hours}h from timelog {timelog_id} to card {card_id}") return (True, f"Transferred {rounded_hours}h to card {card.get('card_number', '')}", transaction) except Exception as e: logger.error(f"❌ Error transferring timelog: {e}") return (False, f"Error: {str(e)}", None) @staticmethod def get_wizard_summary( contract_data: Dict[str, Any], actions: List[Dict[str, Any]], dry_run: bool = False ) -> Dict[str, Any]: """ Generate summary report of wizard actions Args: contract_data: Contract info (contract_id, subject, etc.) actions: List of action dicts from wizard steps dry_run: Whether wizard ran in dry-run mode Returns: Summary dict with counts and status """ archived_count = sum(1 for a in actions if a.get('type') == 'archive' and a.get('success')) transferred_count = sum(1 for a in actions if a.get('type') == 'transfer' and a.get('success')) failed_count = sum(1 for a in actions if not a.get('success')) return { 'contract_id': contract_data.get('contract_id', ''), 'contract_number': contract_data.get('contract_number', ''), 'subject': contract_data.get('subject', ''), 'dry_run': dry_run, 'total_items_processed': len(actions), 'cases_archived': archived_count, 'timelogs_transferred': transferred_count, 'failed_items': failed_count, 'status': 'completed_with_errors' if failed_count > 0 else 'completed', 'timestamp': datetime.now().isoformat(), }