bmc_hub/app/timetracking/backend/service_contract_wizard.py

419 lines
16 KiB
Python
Raw Normal View History

"""
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(),
}