419 lines
16 KiB
Python
419 lines
16 KiB
Python
|
|
"""
|
|||
|
|
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(),
|
|||
|
|
}
|