2025-12-11 23:14:20 +01:00
|
|
|
|
"""
|
|
|
|
|
|
vTiger Cloud CRM Integration Service
|
|
|
|
|
|
Handles subscription and sales order data retrieval
|
|
|
|
|
|
"""
|
|
|
|
|
|
import logging
|
2025-12-13 12:06:28 +01:00
|
|
|
|
import json
|
2026-05-12 08:41:13 +02:00
|
|
|
|
import re
|
|
|
|
|
|
import html as html_lib
|
2025-12-11 23:14:20 +01:00
|
|
|
|
import aiohttp
|
2026-05-12 08:41:13 +02:00
|
|
|
|
from decimal import Decimal
|
|
|
|
|
|
from typing import List, Dict, Optional, Any
|
2025-12-11 23:14:20 +01:00
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class VTigerService:
|
|
|
|
|
|
"""Service for integrating with vTiger Cloud CRM via REST API"""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self):
|
|
|
|
|
|
self.base_url = getattr(settings, 'VTIGER_URL', None)
|
|
|
|
|
|
self.username = getattr(settings, 'VTIGER_USERNAME', None)
|
|
|
|
|
|
self.api_key = getattr(settings, 'VTIGER_API_KEY', None)
|
|
|
|
|
|
|
|
|
|
|
|
# REST API endpoint
|
|
|
|
|
|
if self.base_url:
|
|
|
|
|
|
self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default"
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.rest_endpoint = None
|
|
|
|
|
|
|
|
|
|
|
|
if not all([self.base_url, self.username, self.api_key]):
|
|
|
|
|
|
logger.warning("⚠️ vTiger credentials not fully configured")
|
2026-02-17 08:29:05 +01:00
|
|
|
|
|
|
|
|
|
|
self.last_query_status: Optional[int] = None
|
|
|
|
|
|
self.last_query_error: Optional[Dict] = None
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
def _get_auth(self):
|
|
|
|
|
|
"""Get HTTP Basic Auth credentials"""
|
|
|
|
|
|
if not self.api_key:
|
|
|
|
|
|
raise ValueError("VTIGER_API_KEY not configured")
|
|
|
|
|
|
return aiohttp.BasicAuth(self.username, self.api_key)
|
2026-05-12 08:41:13 +02:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _sanitize_vtiger_id(raw_value: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
"""Allow-list known vTiger id characters before embedding in query strings."""
|
|
|
|
|
|
if not raw_value:
|
|
|
|
|
|
return None
|
|
|
|
|
|
candidate = str(raw_value).strip()
|
|
|
|
|
|
if re.match(r"^[A-Za-z0-9_\-x]+$", candidate):
|
|
|
|
|
|
return candidate
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_timelog_hours(timelog_data: Dict[str, Any]) -> Decimal:
|
|
|
|
|
|
"""Extract hours from common vTiger timelog fields and normalize to decimal 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
|
|
|
|
|
|
|
|
|
|
|
|
if raw_value in (None, ""):
|
|
|
|
|
|
return Decimal("0")
|
|
|
|
|
|
|
|
|
|
|
|
if field_used == "duration":
|
|
|
|
|
|
try:
|
|
|
|
|
|
seconds = Decimal(str(raw_value))
|
|
|
|
|
|
return (seconds / Decimal(3600)).quantize(Decimal("0.01"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
raw_str = str(raw_value).strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
time_match = re.match(r"^(\d+):(\d+)(?::(\d+))?$", raw_str)
|
|
|
|
|
|
if time_match:
|
|
|
|
|
|
hours = Decimal(int(time_match.group(1)))
|
|
|
|
|
|
minutes = Decimal(int(time_match.group(2))) / Decimal(60)
|
|
|
|
|
|
seconds = Decimal(int(time_match.group(3) or 0)) / Decimal(3600)
|
|
|
|
|
|
return (hours + minutes + seconds).quantize(Decimal("0.01"))
|
|
|
|
|
|
|
|
|
|
|
|
if "h" in raw_str or "m" in raw_str or "s" in raw_str:
|
|
|
|
|
|
total_hours = Decimal("0")
|
|
|
|
|
|
hours_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*h", raw_str)
|
|
|
|
|
|
mins_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*m", raw_str)
|
|
|
|
|
|
secs_match = re.search(r"(\d+(?:[\.,]\d+)?)\s*s", raw_str)
|
|
|
|
|
|
|
|
|
|
|
|
if hours_match:
|
|
|
|
|
|
total_hours += Decimal(hours_match.group(1).replace(",", "."))
|
|
|
|
|
|
if mins_match:
|
|
|
|
|
|
total_hours += Decimal(mins_match.group(1).replace(",", ".")) / Decimal(60)
|
|
|
|
|
|
if secs_match:
|
|
|
|
|
|
total_hours += Decimal(secs_match.group(1).replace(",", ".")) / Decimal(3600)
|
|
|
|
|
|
|
|
|
|
|
|
return total_hours.quantize(Decimal("0.01"))
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
return Decimal(raw_str.replace(",", ".")).quantize(Decimal("0.01"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return Decimal("0")
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
async def query(self, query_string: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Execute a query on vTiger REST API
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
query_string: SQL-like query (e.g., "SELECT * FROM Accounts;")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of records
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
auth = self._get_auth()
|
2026-02-17 08:29:05 +01:00
|
|
|
|
self.last_query_status = None
|
|
|
|
|
|
self.last_query_error = None
|
2025-12-11 23:14:20 +01:00
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
async with session.get(
|
|
|
|
|
|
f"{self.rest_endpoint}/query",
|
|
|
|
|
|
params={"query": query_string},
|
|
|
|
|
|
auth=auth
|
|
|
|
|
|
) as response:
|
|
|
|
|
|
text = await response.text()
|
2026-02-17 08:29:05 +01:00
|
|
|
|
self.last_query_status = response.status
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
# vTiger returns text/json instead of application/json
|
|
|
|
|
|
import json
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
|
except json.JSONDecodeError as e:
|
|
|
|
|
|
logger.error(f"❌ Invalid JSON in query response: {text[:200]}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
if data.get('success'):
|
|
|
|
|
|
result = data.get('result', [])
|
|
|
|
|
|
logger.info(f"✅ Query returned {len(result)} records")
|
2026-02-17 08:29:05 +01:00
|
|
|
|
self.last_query_error = None
|
2025-12-11 23:14:20 +01:00
|
|
|
|
return result
|
|
|
|
|
|
else:
|
2026-02-17 08:29:05 +01:00
|
|
|
|
self.last_query_error = data.get('error')
|
|
|
|
|
|
logger.error(f"❌ vTiger query failed: {self.last_query_error}")
|
2025-12-11 23:14:20 +01:00
|
|
|
|
return []
|
|
|
|
|
|
else:
|
2026-02-17 08:29:05 +01:00
|
|
|
|
try:
|
|
|
|
|
|
parsed = json.loads(text) if text else {}
|
|
|
|
|
|
self.last_query_error = parsed.get('error')
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
self.last_query_error = None
|
|
|
|
|
|
if response.status == 429:
|
|
|
|
|
|
logger.warning(f"⚠️ vTiger query rate-limited (HTTP {response.status})")
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"❌ vTiger query HTTP error {response.status}")
|
2025-12-11 23:14:20 +01:00
|
|
|
|
logger.error(f"Query: {query_string}")
|
|
|
|
|
|
logger.error(f"Response: {text[:500]}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
except Exception as e:
|
2026-02-17 08:29:05 +01:00
|
|
|
|
self.last_query_status = None
|
|
|
|
|
|
self.last_query_error = {"message": str(e)}
|
2025-12-11 23:14:20 +01:00
|
|
|
|
logger.error(f"❌ vTiger query error: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2026-01-08 18:28:00 +01:00
|
|
|
|
async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch a single account by ID from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
account_id: vTiger account ID (e.g., "3x760")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Account record or None
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not account_id:
|
|
|
|
|
|
logger.warning("⚠️ No account ID provided")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
query = f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;"
|
|
|
|
|
|
results = await self.query(query)
|
|
|
|
|
|
|
|
|
|
|
|
if results and len(results) > 0:
|
|
|
|
|
|
logger.info(f"✅ Found account {account_id}")
|
|
|
|
|
|
return results[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"⚠️ No account found with ID {account_id}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching account {account_id}: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def update_account(self, account_id: str, update_data: Dict) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update an account in vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
account_id: vTiger account ID (e.g., "3x760")
|
|
|
|
|
|
update_data: Dictionary of fields to update
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
True if successful, False otherwise
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-01-08 18:57:04 +01:00
|
|
|
|
# Fetch current account first - vTiger requires modifiedtime for updates
|
|
|
|
|
|
current_account = await self.get_account_by_id(account_id)
|
|
|
|
|
|
if not current_account:
|
|
|
|
|
|
logger.error(f"❌ Account {account_id} not found for update")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-01-08 18:28:00 +01:00
|
|
|
|
auth = self._get_auth()
|
|
|
|
|
|
|
2026-01-08 18:57:04 +01:00
|
|
|
|
# Build payload with current data + updates
|
|
|
|
|
|
# Include essential fields for vTiger update validation
|
2026-01-08 18:28:00 +01:00
|
|
|
|
payload = {
|
|
|
|
|
|
'id': account_id,
|
2026-01-08 18:57:04 +01:00
|
|
|
|
'accountname': current_account.get('accountname'),
|
|
|
|
|
|
'assigned_user_id': current_account.get('assigned_user_id'),
|
|
|
|
|
|
'modifiedtime': current_account.get('modifiedtime'),
|
2026-01-08 18:28:00 +01:00
|
|
|
|
**update_data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-08 18:57:04 +01:00
|
|
|
|
logger.info(f"🔄 vTiger update payload: {payload}")
|
|
|
|
|
|
|
2026-01-08 18:28:00 +01:00
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
async with session.post(
|
|
|
|
|
|
f"{self.rest_endpoint}/update",
|
|
|
|
|
|
json=payload,
|
|
|
|
|
|
auth=auth
|
|
|
|
|
|
) as response:
|
|
|
|
|
|
text = await response.text()
|
|
|
|
|
|
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
import json
|
|
|
|
|
|
try:
|
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
|
if data.get('success'):
|
|
|
|
|
|
logger.info(f"✅ Updated vTiger account {account_id}")
|
|
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"❌ vTiger update failed: {data.get('error')}")
|
2026-01-08 18:57:04 +01:00
|
|
|
|
logger.error(f"Full response: {text}")
|
2026-01-08 18:28:00 +01:00
|
|
|
|
return False
|
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
|
logger.error(f"❌ Invalid JSON in update response: {text[:200]}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
2026-01-08 18:57:04 +01:00
|
|
|
|
logger.error(f"❌ vTiger update HTTP error {response.status}")
|
|
|
|
|
|
logger.error(f"Full response: {text[:2000]}")
|
2026-01-08 18:28:00 +01:00
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating vTiger account {account_id}: {e}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
|
async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch sales orders for a customer from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
vtiger_account_id: vTiger account ID (e.g., "3x760")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of sales order records
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not vtiger_account_id:
|
|
|
|
|
|
logger.warning("⚠️ No vTiger account ID provided")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Query for sales orders linked to this account
|
|
|
|
|
|
query = f"SELECT * FROM SalesOrder WHERE account_id='{vtiger_account_id}';"
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔍 Fetching sales orders for vTiger account {vtiger_account_id}")
|
|
|
|
|
|
orders = await self.query(query)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Found {len(orders)} sales orders")
|
|
|
|
|
|
return orders
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching sales orders: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
async def get_customer_subscriptions(self, vtiger_account_id: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch subscriptions for a customer from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
vtiger_account_id: vTiger account ID (e.g., "3x760")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of subscription records
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not vtiger_account_id:
|
|
|
|
|
|
logger.warning("⚠️ No vTiger account ID provided")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Query for subscriptions linked to this account (note: module name is singular "Subscription")
|
|
|
|
|
|
query = f"SELECT * FROM Subscription WHERE account_id='{vtiger_account_id}';"
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔍 Fetching subscriptions for vTiger account {vtiger_account_id}")
|
|
|
|
|
|
subscriptions = await self.query(query)
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Found {len(subscriptions)} subscriptions")
|
|
|
|
|
|
return subscriptions
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching subscriptions: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
|
async def get_subscription(self, subscription_id: str) -> Optional[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch full subscription details with LineItems from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
subscription_id: vTiger subscription ID (e.g., "72x123")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Subscription record with LineItems array or None
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
auth = self._get_auth()
|
|
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
url = f"{self.rest_endpoint}/retrieve?id={subscription_id}"
|
|
|
|
|
|
|
|
|
|
|
|
async with session.get(url, auth=auth) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
text = await response.text()
|
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
|
if data.get('success'):
|
|
|
|
|
|
logger.info(f"✅ Retrieved subscription {subscription_id}")
|
|
|
|
|
|
return data.get('result')
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"❌ vTiger API error: {data.get('error')}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"❌ HTTP {response.status} retrieving subscription")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error retrieving subscription: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
async def get_account(self, vtiger_account_id: str) -> Optional[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch account details from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
vtiger_account_id: vTiger account ID (e.g., "3x760")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Account record with BMC Låst field (cf_accounts_bmclst) or None
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not vtiger_account_id:
|
|
|
|
|
|
logger.warning("⚠️ No vTiger account ID provided")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Query for account by ID
|
|
|
|
|
|
query = f"SELECT * FROM Accounts WHERE id='{vtiger_account_id}';"
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"🔍 Fetching account details for {vtiger_account_id}")
|
|
|
|
|
|
accounts = await self.query(query)
|
|
|
|
|
|
|
|
|
|
|
|
if accounts:
|
|
|
|
|
|
logger.info(f"✅ Found account: {accounts[0].get('accountname', 'Unknown')}")
|
|
|
|
|
|
return accounts[0]
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.warning(f"⚠️ No account found with ID {vtiger_account_id}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching account: {e}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
|
async def test_connection(self) -> bool:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Test vTiger connection using /me endpoint
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
True if connection successful
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured in .env")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
auth = self._get_auth()
|
|
|
|
|
|
logger.info(f"🔑 Testing vTiger connection...")
|
|
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
async with session.get(
|
|
|
|
|
|
f"{self.rest_endpoint}/me",
|
|
|
|
|
|
auth=auth
|
|
|
|
|
|
) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
# vTiger returns text/json instead of application/json
|
|
|
|
|
|
text = await response.text()
|
|
|
|
|
|
import json
|
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
|
|
|
|
|
|
|
if data.get('success'):
|
|
|
|
|
|
user_name = data['result'].get('user_name')
|
|
|
|
|
|
logger.info(f"✅ vTiger connection successful (user: {user_name})")
|
|
|
|
|
|
return True
|
|
|
|
|
|
else:
|
|
|
|
|
|
logger.error(f"❌ vTiger API returned success=false: {data}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
|
logger.error(f"❌ vTiger connection failed: HTTP {response.status}: {error_text}")
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ vTiger connection error: {e}")
|
|
|
|
|
|
return False
|
2025-12-13 12:06:28 +01:00
|
|
|
|
|
|
|
|
|
|
async def create_subscription(
|
|
|
|
|
|
self,
|
|
|
|
|
|
account_id: str,
|
|
|
|
|
|
subject: str,
|
|
|
|
|
|
startdate: str,
|
|
|
|
|
|
generateinvoiceevery: str,
|
|
|
|
|
|
subscriptionstatus: str = "Active",
|
|
|
|
|
|
enddate: Optional[str] = None,
|
|
|
|
|
|
products: Optional[List[Dict]] = None
|
|
|
|
|
|
) -> Dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Create a new subscription in vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
account_id: vTiger account ID (e.g., "3x123")
|
|
|
|
|
|
subject: Subscription subject/name
|
|
|
|
|
|
startdate: Start date (YYYY-MM-DD)
|
|
|
|
|
|
generateinvoiceevery: Frequency ("Monthly", "Quarterly", "Yearly")
|
|
|
|
|
|
subscriptionstatus: Status ("Active", "Cancelled", "Stopped")
|
|
|
|
|
|
enddate: End date (YYYY-MM-DD), optional
|
|
|
|
|
|
products: List of products [{"productid": "id", "quantity": 1, "listprice": 100}]
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
auth = self._get_auth()
|
|
|
|
|
|
|
|
|
|
|
|
# Build subscription data
|
|
|
|
|
|
subscription_data = {
|
|
|
|
|
|
"account_id": account_id,
|
|
|
|
|
|
"subject": subject,
|
|
|
|
|
|
"startdate": startdate,
|
|
|
|
|
|
"generateinvoiceevery": generateinvoiceevery,
|
|
|
|
|
|
"subscriptionstatus": subscriptionstatus,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if enddate:
|
|
|
|
|
|
subscription_data["enddate"] = enddate
|
|
|
|
|
|
|
|
|
|
|
|
# Add products if provided
|
|
|
|
|
|
if products:
|
|
|
|
|
|
subscription_data["products"] = products
|
|
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
create_url = f"{self.rest_endpoint}/create"
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"elementType": "Subscription",
|
|
|
|
|
|
"element": subscription_data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"📤 Creating subscription: {subject}")
|
|
|
|
|
|
|
|
|
|
|
|
async with session.post(create_url, json=payload, auth=auth) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
data = await response.json()
|
|
|
|
|
|
if data.get("success"):
|
|
|
|
|
|
result = data.get("result", {})
|
|
|
|
|
|
logger.info(f"✅ Created subscription: {result.get('id')}")
|
|
|
|
|
|
return result
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_msg = data.get("error", {}).get("message", "Unknown error")
|
|
|
|
|
|
raise Exception(f"vTiger API error: {error_msg}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
|
raise Exception(f"HTTP {response.status}: {error_text}")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error creating subscription: {e}")
|
|
|
|
|
|
raise
|
|
|
|
|
|
|
|
|
|
|
|
async def update_subscription(self, subscription_id: str, updates: Dict, line_items: List[Dict] = None) -> Dict:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Update a subscription in vTiger with optional line item price updates
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
subscription_id: vTiger subscription ID (e.g., "72x123")
|
|
|
|
|
|
updates: Dictionary of fields to update (subject, startdate, etc.)
|
|
|
|
|
|
line_items: Optional list of line items with updated prices
|
|
|
|
|
|
Each item: {"productid": "6x123", "quantity": "3", "listprice": "299.00"}
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not self.rest_endpoint:
|
|
|
|
|
|
raise ValueError("VTIGER_URL not configured")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
auth = self._get_auth()
|
|
|
|
|
|
|
|
|
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
|
# For custom fields (cf_*), we need to retrieve first for context
|
|
|
|
|
|
if any(k.startswith('cf_') for k in updates.keys()):
|
|
|
|
|
|
logger.info(f"📥 Retrieving subscription {subscription_id} for custom field update")
|
|
|
|
|
|
current_sub = await self.get_subscription(subscription_id)
|
|
|
|
|
|
if current_sub:
|
|
|
|
|
|
# Only include essential fields + custom field update
|
|
|
|
|
|
essential_fields = ['id', 'account_id', 'subject', 'startdate',
|
|
|
|
|
|
'generateinvoiceevery', 'subscriptionstatus']
|
|
|
|
|
|
element_data = {
|
|
|
|
|
|
k: current_sub[k]
|
|
|
|
|
|
for k in essential_fields
|
|
|
|
|
|
if k in current_sub
|
|
|
|
|
|
}
|
|
|
|
|
|
element_data.update(updates)
|
|
|
|
|
|
element_data['id'] = subscription_id
|
|
|
|
|
|
else:
|
|
|
|
|
|
element_data = {"id": subscription_id, **updates}
|
|
|
|
|
|
else:
|
|
|
|
|
|
element_data = {"id": subscription_id, **updates}
|
|
|
|
|
|
|
|
|
|
|
|
update_url = f"{self.rest_endpoint}/update"
|
|
|
|
|
|
|
|
|
|
|
|
# Add LineItems if provided
|
|
|
|
|
|
if line_items:
|
|
|
|
|
|
element_data["LineItems"] = line_items
|
|
|
|
|
|
logger.info(f"📝 Updating {len(line_items)} line items")
|
|
|
|
|
|
|
|
|
|
|
|
payload = {
|
|
|
|
|
|
"elementType": "Subscription",
|
|
|
|
|
|
"element": element_data
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"📤 Updating subscription {subscription_id}: {list(updates.keys())}")
|
|
|
|
|
|
logger.debug(f"Payload: {json.dumps(payload, indent=2)}")
|
|
|
|
|
|
|
|
|
|
|
|
async with session.post(update_url, json=payload, auth=auth) as response:
|
|
|
|
|
|
if response.status == 200:
|
|
|
|
|
|
text = await response.text()
|
|
|
|
|
|
data = json.loads(text)
|
|
|
|
|
|
if data.get("success"):
|
|
|
|
|
|
result = data.get("result", {})
|
|
|
|
|
|
logger.info(f"✅ Updated subscription: {subscription_id}")
|
|
|
|
|
|
return result
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_msg = data.get("error", {}).get("message", "Unknown error")
|
|
|
|
|
|
logger.error(f"❌ vTiger error response: {data.get('error')}")
|
|
|
|
|
|
raise Exception(f"vTiger API error: {error_msg}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
error_text = await response.text()
|
|
|
|
|
|
logger.error(f"❌ vTiger HTTP error: {error_text[:500]}")
|
|
|
|
|
|
raise Exception(f"HTTP {response.status}: {error_text}")
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error updating subscription: {e}")
|
|
|
|
|
|
raise
|
2026-02-10 14:40:38 +01:00
|
|
|
|
|
2026-05-12 08:41:13 +02:00
|
|
|
|
async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]:
|
2026-02-10 14:40:38 +01:00
|
|
|
|
"""
|
|
|
|
|
|
Fetch service contracts from vTiger
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
account_id: Optional - filter by account ID (e.g., "3x760")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of service contract records with account_id included
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
2026-05-12 08:41:13 +02:00
|
|
|
|
query_filters = []
|
|
|
|
|
|
safe_account_id = self._sanitize_vtiger_id(account_id)
|
|
|
|
|
|
|
|
|
|
|
|
if account_id and not safe_account_id:
|
|
|
|
|
|
logger.warning("⚠️ Rejected unsafe account_id for service contract query")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
if safe_account_id:
|
|
|
|
|
|
query_filters.append(f"sc_related_to='{safe_account_id}'")
|
|
|
|
|
|
|
|
|
|
|
|
if active_only:
|
|
|
|
|
|
query_filters.append("contract_status='Active'")
|
|
|
|
|
|
|
|
|
|
|
|
where_clause = f" WHERE {' AND '.join(query_filters)}" if query_filters else ""
|
|
|
|
|
|
query = f"SELECT * FROM ServiceContracts{where_clause};"
|
|
|
|
|
|
|
|
|
|
|
|
if safe_account_id:
|
|
|
|
|
|
logger.info(f"🔍 Fetching service contracts for account {safe_account_id} (active_only={active_only})")
|
2026-02-10 14:40:38 +01:00
|
|
|
|
else:
|
2026-05-12 08:41:13 +02:00
|
|
|
|
logger.info(f"🔍 Fetching all service contracts (active_only={active_only})")
|
2026-02-10 14:40:38 +01:00
|
|
|
|
|
|
|
|
|
|
contracts = await self.query(query)
|
|
|
|
|
|
logger.info(f"✅ Found {len(contracts)} service contracts")
|
|
|
|
|
|
|
|
|
|
|
|
# Normalize fields used by the wizard
|
|
|
|
|
|
for contract in contracts:
|
|
|
|
|
|
if 'account_id' not in contract:
|
|
|
|
|
|
contract['account_id'] = (
|
|
|
|
|
|
contract.get('accountid')
|
|
|
|
|
|
or contract.get('cf_service_contracts_account')
|
|
|
|
|
|
or contract.get('sc_related_to')
|
|
|
|
|
|
or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if 'contract_number' not in contract:
|
|
|
|
|
|
contract['contract_number'] = contract.get('contract_no', '')
|
|
|
|
|
|
|
|
|
|
|
|
if not contract['account_id']:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
f"⚠️ Contract {contract.get('id')} has no account_id, filling null"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
return contracts
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching service contracts: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
async def get_service_contract_cases(self, contract_id: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch cases linked to a service contract
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
contract_id: vTiger service contract ID (e.g., "75x123")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of case records linked to contract
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Query cases linked via service contract reference
|
|
|
|
|
|
query = (
|
|
|
|
|
|
"SELECT * FROM Cases WHERE "
|
|
|
|
|
|
f"servicecontract_id='{contract_id}' OR "
|
|
|
|
|
|
f"parent_id='{contract_id}';"
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"🔍 Fetching cases for service contract {contract_id}")
|
|
|
|
|
|
|
|
|
|
|
|
cases = await self.query(query)
|
|
|
|
|
|
logger.info(f"✅ Found {len(cases)} cases for contract")
|
|
|
|
|
|
return cases
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching contract cases: {e}")
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
async def get_service_contract_timelogs(self, contract_id: str) -> List[Dict]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Fetch time logs linked to a service contract
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
contract_id: vTiger service contract ID (e.g., "75x123")
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
List of timelog records linked to contract
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Query timelogs linked via service contract reference
|
|
|
|
|
|
query = (
|
|
|
|
|
|
"SELECT * FROM Timelog WHERE "
|
|
|
|
|
|
f"relatedto='{contract_id}';"
|
|
|
|
|
|
)
|
|
|
|
|
|
logger.info(f"🔍 Fetching timelogs for service contract {contract_id}")
|
|
|
|
|
|
|
|
|
|
|
|
timelogs = await self.query(query)
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: timelogs may be linked to cases instead of the contract
|
|
|
|
|
|
if not timelogs:
|
|
|
|
|
|
cases = await self.get_service_contract_cases(contract_id)
|
|
|
|
|
|
case_ids = [case.get('id') for case in cases if case.get('id')]
|
|
|
|
|
|
|
|
|
|
|
|
if case_ids:
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
f"ℹ️ No direct timelogs found; querying by {len(case_ids)} case IDs"
|
|
|
|
|
|
)
|
|
|
|
|
|
timelog_map = {}
|
|
|
|
|
|
chunk_size = 20
|
|
|
|
|
|
|
|
|
|
|
|
for index in range(0, len(case_ids), chunk_size):
|
|
|
|
|
|
chunk = case_ids[index:index + chunk_size]
|
|
|
|
|
|
ids = "', '".join(chunk)
|
|
|
|
|
|
chunk_query = (
|
|
|
|
|
|
"SELECT * FROM Timelog WHERE "
|
|
|
|
|
|
f"relatedto IN ('{ids}');"
|
|
|
|
|
|
)
|
|
|
|
|
|
chunk_rows = await self.query(chunk_query)
|
|
|
|
|
|
for row in chunk_rows:
|
|
|
|
|
|
if row.get('id'):
|
|
|
|
|
|
timelog_map[row['id']] = row
|
|
|
|
|
|
|
|
|
|
|
|
timelogs = list(timelog_map.values())
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ Found {len(timelogs)} timelogs for contract")
|
|
|
|
|
|
return timelogs
|
|
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"❌ Error fetching contract timelogs: {e}")
|
|
|
|
|
|
return []
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
2026-05-12 08:41:13 +02:00
|
|
|
|
async def get_service_contract_customers(self) -> List[Dict[str, str]]:
|
|
|
|
|
|
"""Fetch account list that has at least one service contract."""
|
|
|
|
|
|
contracts = await self.get_service_contracts(active_only=False)
|
|
|
|
|
|
account_ids = sorted({
|
|
|
|
|
|
c.get("account_id")
|
|
|
|
|
|
for c in contracts
|
|
|
|
|
|
if c.get("account_id")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if not account_ids:
|
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
customers: Dict[str, Dict[str, str]] = {}
|
|
|
|
|
|
chunk_size = 20
|
|
|
|
|
|
for i in range(0, len(account_ids), chunk_size):
|
|
|
|
|
|
chunk = account_ids[i:i + chunk_size]
|
|
|
|
|
|
ids = "', '".join(chunk)
|
|
|
|
|
|
query = (
|
|
|
|
|
|
"SELECT id, accountname FROM Accounts "
|
|
|
|
|
|
f"WHERE id IN ('{ids}');"
|
|
|
|
|
|
)
|
|
|
|
|
|
rows = await self.query(query)
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
account_id = row.get("id")
|
|
|
|
|
|
if account_id:
|
|
|
|
|
|
customers[account_id] = {
|
|
|
|
|
|
"account_id": account_id,
|
|
|
|
|
|
"account_name": row.get("accountname") or account_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback names for accounts not returned by Accounts query
|
|
|
|
|
|
for account_id in account_ids:
|
|
|
|
|
|
if account_id not in customers:
|
|
|
|
|
|
customers[account_id] = {
|
|
|
|
|
|
"account_id": account_id,
|
|
|
|
|
|
"account_name": account_id,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return sorted(customers.values(), key=lambda x: x.get("account_name", "").lower())
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_closed_case(status: Optional[str]) -> bool:
|
|
|
|
|
|
if not status:
|
|
|
|
|
|
return False
|
|
|
|
|
|
normalized = status.strip().lower()
|
|
|
|
|
|
return normalized in {"closed", "resolved", "done", "completed", "lukket"}
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _first_non_empty(record: Dict[str, Any], keys: tuple[str, ...]) -> str:
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
value = record.get(key)
|
|
|
|
|
|
if value not in (None, ""):
|
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
|
if text:
|
|
|
|
|
|
return text
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _html_to_text(value: Optional[str]) -> str:
|
|
|
|
|
|
"""Convert HTML snippets from CRM fields into readable plain text."""
|
|
|
|
|
|
if value in (None, ""):
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
text = str(value)
|
|
|
|
|
|
text = re.sub(r"(?i)<br\s*/?>", "\n", text)
|
|
|
|
|
|
text = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", text)
|
|
|
|
|
|
text = re.sub(r"(?is)<[^>]+>", " ", text)
|
|
|
|
|
|
text = html_lib.unescape(text)
|
|
|
|
|
|
text = text.replace("\xa0", " ")
|
|
|
|
|
|
text = re.sub(r"[ \t]+", " ", text)
|
|
|
|
|
|
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
|
|
|
|
return text.strip()
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _extract_vtiger_record_id(raw_id: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
if not raw_id:
|
|
|
|
|
|
return None
|
|
|
|
|
|
text = str(raw_id).strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if re.match(r"^\d+x\d+$", text):
|
|
|
|
|
|
return text.split("x", 1)[1]
|
|
|
|
|
|
if text.isdigit():
|
|
|
|
|
|
return text
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _build_vtiger_record_url(self, module: str, raw_id: Optional[str]) -> str:
|
|
|
|
|
|
if not self.base_url:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
record_id = self._extract_vtiger_record_id(raw_id)
|
|
|
|
|
|
if not record_id:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
base = str(self.base_url).rstrip("/")
|
|
|
|
|
|
if module == "Cases":
|
|
|
|
|
|
return f"{base}/view/detail?module=Cases&id={record_id}&viewtype=summary"
|
|
|
|
|
|
if module == "Timelog":
|
|
|
|
|
|
return f"{base}/view/detail?module=Timelog&id={record_id}&viewtype=summary"
|
|
|
|
|
|
return f"{base}/index.php?module={module}&view=Detail&record={record_id}"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _to_initials(value: Optional[str]) -> str:
|
|
|
|
|
|
if not value:
|
|
|
|
|
|
return "--"
|
|
|
|
|
|
text = str(value).strip()
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return "--"
|
|
|
|
|
|
|
|
|
|
|
|
# Raw vTiger entity IDs are not human initials.
|
|
|
|
|
|
if re.match(r"^\d+x\d+$", text):
|
|
|
|
|
|
return "--"
|
|
|
|
|
|
|
|
|
|
|
|
tokens = [token for token in re.split(r"[^A-Za-z0-9]+", text) if token]
|
|
|
|
|
|
if not tokens:
|
|
|
|
|
|
return "--"
|
|
|
|
|
|
|
|
|
|
|
|
if len(tokens) == 1:
|
|
|
|
|
|
return tokens[0][:2].upper()
|
|
|
|
|
|
|
|
|
|
|
|
return f"{tokens[0][0]}{tokens[1][0]}".upper()
|
|
|
|
|
|
|
|
|
|
|
|
async def _resolve_user_initials(self, user_refs: List[str]) -> Dict[str, str]:
|
|
|
|
|
|
initials_by_ref: Dict[str, str] = {}
|
|
|
|
|
|
safe_ids = []
|
|
|
|
|
|
for raw_ref in user_refs:
|
|
|
|
|
|
safe_ref = self._sanitize_vtiger_id(raw_ref)
|
|
|
|
|
|
if safe_ref:
|
|
|
|
|
|
safe_ids.append(safe_ref)
|
|
|
|
|
|
|
|
|
|
|
|
if not safe_ids:
|
|
|
|
|
|
return initials_by_ref
|
|
|
|
|
|
|
|
|
|
|
|
unique_ids = sorted(set(safe_ids))
|
|
|
|
|
|
chunk_size = 20
|
|
|
|
|
|
for index in range(0, len(unique_ids), chunk_size):
|
|
|
|
|
|
chunk = unique_ids[index:index + chunk_size]
|
|
|
|
|
|
id_list = "', '".join(chunk)
|
|
|
|
|
|
rows = await self.query(
|
|
|
|
|
|
"SELECT id, first_name, last_name, user_name FROM Users "
|
|
|
|
|
|
f"WHERE id IN ('{id_list}');"
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
user_id = row.get("id")
|
|
|
|
|
|
if not user_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
full_name = " ".join(
|
|
|
|
|
|
part for part in [row.get("first_name"), row.get("last_name")] if part
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
source = full_name or row.get("user_name") or user_id
|
|
|
|
|
|
initials_by_ref[user_id] = self._to_initials(source)
|
|
|
|
|
|
|
|
|
|
|
|
# Some timelog owners are groups, not user records.
|
|
|
|
|
|
group_rows = await self.query(
|
|
|
|
|
|
"SELECT id, groupname FROM Groups "
|
|
|
|
|
|
f"WHERE id IN ('{id_list}');"
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in group_rows:
|
|
|
|
|
|
group_id = row.get("id")
|
|
|
|
|
|
if not group_id or group_id in initials_by_ref:
|
|
|
|
|
|
continue
|
|
|
|
|
|
initials_by_ref[group_id] = self._to_initials(row.get("groupname") or group_id)
|
|
|
|
|
|
|
|
|
|
|
|
return initials_by_ref
|
|
|
|
|
|
|
|
|
|
|
|
async def _resolve_contact_names(self, contact_refs: List[str]) -> Dict[str, str]:
|
|
|
|
|
|
names_by_ref: Dict[str, str] = {}
|
|
|
|
|
|
safe_ids = []
|
|
|
|
|
|
|
|
|
|
|
|
for raw_ref in contact_refs:
|
|
|
|
|
|
if not raw_ref:
|
|
|
|
|
|
continue
|
|
|
|
|
|
text = str(raw_ref).strip()
|
|
|
|
|
|
safe_ref = self._sanitize_vtiger_id(text)
|
|
|
|
|
|
if safe_ref and re.match(r"^\d+x\d+$", safe_ref):
|
|
|
|
|
|
safe_ids.append(safe_ref)
|
|
|
|
|
|
elif text:
|
|
|
|
|
|
names_by_ref[text] = text
|
|
|
|
|
|
|
|
|
|
|
|
if not safe_ids:
|
|
|
|
|
|
return names_by_ref
|
|
|
|
|
|
|
|
|
|
|
|
unique_ids = sorted(set(safe_ids))
|
|
|
|
|
|
chunk_size = 20
|
|
|
|
|
|
for index in range(0, len(unique_ids), chunk_size):
|
|
|
|
|
|
chunk = unique_ids[index:index + chunk_size]
|
|
|
|
|
|
id_list = "', '".join(chunk)
|
|
|
|
|
|
rows = await self.query(
|
|
|
|
|
|
"SELECT id, firstname, lastname, salutationtype FROM Contacts "
|
|
|
|
|
|
f"WHERE id IN ('{id_list}');"
|
|
|
|
|
|
)
|
|
|
|
|
|
for row in rows:
|
|
|
|
|
|
contact_id = row.get("id")
|
|
|
|
|
|
if not contact_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
parts = [
|
|
|
|
|
|
str(row.get("salutationtype") or "").strip(),
|
|
|
|
|
|
str(row.get("firstname") or "").strip(),
|
|
|
|
|
|
str(row.get("lastname") or "").strip(),
|
|
|
|
|
|
]
|
|
|
|
|
|
full_name = " ".join(part for part in parts if part)
|
|
|
|
|
|
names_by_ref[contact_id] = full_name or contact_id
|
|
|
|
|
|
|
|
|
|
|
|
return names_by_ref
|
|
|
|
|
|
|
|
|
|
|
|
async def get_service_contract_report_data(self, account_id: str, contract_id: str) -> Dict[str, Any]:
|
|
|
|
|
|
"""Build report payload for selected customer + service contract."""
|
|
|
|
|
|
safe_account_id = self._sanitize_vtiger_id(account_id)
|
|
|
|
|
|
safe_contract_id = self._sanitize_vtiger_id(contract_id)
|
|
|
|
|
|
if not safe_account_id or not safe_contract_id:
|
|
|
|
|
|
raise ValueError("Invalid account_id or contract_id")
|
|
|
|
|
|
|
|
|
|
|
|
contract_rows = await self.query(
|
|
|
|
|
|
f"SELECT * FROM ServiceContracts WHERE id='{safe_contract_id}' LIMIT 1;"
|
|
|
|
|
|
)
|
|
|
|
|
|
if not contract_rows:
|
|
|
|
|
|
raise ValueError("Service contract was not found")
|
|
|
|
|
|
|
|
|
|
|
|
contract = contract_rows[0]
|
|
|
|
|
|
contract_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 contract is linked to a different customer, return clear validation error.
|
|
|
|
|
|
if contract_account_id and contract_account_id != safe_account_id:
|
|
|
|
|
|
raise ValueError("Service contract does not belong to selected customer")
|
|
|
|
|
|
|
|
|
|
|
|
account = await self.get_account_by_id(safe_account_id)
|
|
|
|
|
|
account_name = (account or {}).get("accountname") or safe_account_id
|
|
|
|
|
|
|
|
|
|
|
|
cases = await self.get_service_contract_cases(safe_contract_id)
|
|
|
|
|
|
timelogs = await self.get_service_contract_timelogs(safe_contract_id)
|
|
|
|
|
|
|
|
|
|
|
|
contact_ref_candidates = [
|
|
|
|
|
|
str(
|
|
|
|
|
|
case.get("contact_id")
|
|
|
|
|
|
or case.get("contactid")
|
|
|
|
|
|
or case.get("parent_contact_id")
|
|
|
|
|
|
or case.get("contactname")
|
|
|
|
|
|
or case.get("cf_contact_person")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
for case in cases
|
|
|
|
|
|
if (
|
|
|
|
|
|
case.get("contact_id")
|
|
|
|
|
|
or case.get("contactid")
|
|
|
|
|
|
or case.get("parent_contact_id")
|
|
|
|
|
|
or case.get("contactname")
|
|
|
|
|
|
or case.get("cf_contact_person")
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
contact_name_map = await self._resolve_contact_names(contact_ref_candidates)
|
|
|
|
|
|
|
|
|
|
|
|
user_ref_candidates = [
|
|
|
|
|
|
str(
|
|
|
|
|
|
log.get("assigned_user_id")
|
|
|
|
|
|
or log.get("modifiedby")
|
|
|
|
|
|
or log.get("created_user_id")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
for log in timelogs
|
|
|
|
|
|
if (
|
|
|
|
|
|
log.get("assigned_user_id")
|
|
|
|
|
|
or log.get("modifiedby")
|
|
|
|
|
|
or log.get("created_user_id")
|
|
|
|
|
|
)
|
|
|
|
|
|
]
|
|
|
|
|
|
user_initials_map = await self._resolve_user_initials(user_ref_candidates)
|
|
|
|
|
|
|
|
|
|
|
|
case_map: Dict[str, Dict[str, Any]] = {}
|
|
|
|
|
|
report_cases: List[Dict[str, Any]] = []
|
|
|
|
|
|
|
|
|
|
|
|
for case in cases:
|
|
|
|
|
|
case_id = case.get("id")
|
|
|
|
|
|
if not case_id:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
case_payload = {
|
|
|
|
|
|
"id": case_id,
|
|
|
|
|
|
"cc_number": self._first_non_empty(
|
|
|
|
|
|
case,
|
|
|
|
|
|
("ticket_no", "case_no", "caseno", "ticketid", "cf_case_number")
|
|
|
|
|
|
) or case_id,
|
|
|
|
|
|
"title": self._first_non_empty(
|
|
|
|
|
|
case,
|
|
|
|
|
|
("ticket_title", "tickettitle", "subject", "title")
|
|
|
|
|
|
) or f"Case {case_id}",
|
|
|
|
|
|
"description": self._html_to_text(
|
|
|
|
|
|
self._first_non_empty(
|
|
|
|
|
|
case,
|
|
|
|
|
|
("description", "ticketdescription", "solution", "comments", "comment")
|
|
|
|
|
|
)
|
|
|
|
|
|
) or "Ingen beskrivelse",
|
|
|
|
|
|
"contact_person": contact_name_map.get(
|
|
|
|
|
|
str(
|
|
|
|
|
|
case.get("contact_id")
|
|
|
|
|
|
or case.get("contactid")
|
|
|
|
|
|
or case.get("parent_contact_id")
|
|
|
|
|
|
or case.get("contactname")
|
|
|
|
|
|
or case.get("cf_contact_person")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
),
|
|
|
|
|
|
self._first_non_empty(
|
|
|
|
|
|
case,
|
|
|
|
|
|
("contactname", "contact_id", "contactid", "cf_contact_person")
|
|
|
|
|
|
) or "-"
|
|
|
|
|
|
),
|
|
|
|
|
|
"vtiger_url": self._build_vtiger_record_url("Cases", case_id),
|
|
|
|
|
|
"status": case.get("ticketstatus") or case.get("status"),
|
|
|
|
|
|
"priority": case.get("ticketpriorities") or case.get("priority"),
|
|
|
|
|
|
"total_hours": Decimal("0"),
|
|
|
|
|
|
"timelog_count": 0,
|
|
|
|
|
|
"timelogs": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
case_map[case_id] = case_payload
|
|
|
|
|
|
report_cases.append(case_payload)
|
|
|
|
|
|
|
|
|
|
|
|
unmatched_case_id = "UNMAPPED"
|
|
|
|
|
|
for log in timelogs:
|
|
|
|
|
|
related_case_id = (
|
|
|
|
|
|
log.get("relatedto")
|
|
|
|
|
|
or log.get("case_id")
|
|
|
|
|
|
or log.get("ticket_id")
|
|
|
|
|
|
or log.get("parent_id")
|
|
|
|
|
|
)
|
|
|
|
|
|
raw_user = (
|
|
|
|
|
|
log.get("assigned_user_id")
|
|
|
|
|
|
or log.get("modifiedby")
|
|
|
|
|
|
or log.get("created_user_id")
|
|
|
|
|
|
or ""
|
|
|
|
|
|
)
|
|
|
|
|
|
hours = self._extract_timelog_hours(log)
|
|
|
|
|
|
timelog_payload = {
|
|
|
|
|
|
"id": log.get("id") or "",
|
|
|
|
|
|
"related_case_id": related_case_id,
|
|
|
|
|
|
"worked_date": log.get("date_start") or log.get("createdtime") or log.get("modifiedtime"),
|
|
|
|
|
|
"user_name": raw_user,
|
|
|
|
|
|
"employee_initials": user_initials_map.get(str(raw_user), self._to_initials(str(raw_user))),
|
|
|
|
|
|
"description": self._html_to_text(
|
|
|
|
|
|
self._first_non_empty(
|
|
|
|
|
|
log,
|
|
|
|
|
|
(
|
|
|
|
|
|
"description",
|
|
|
|
|
|
"subject",
|
|
|
|
|
|
"commentcontent",
|
|
|
|
|
|
"comments",
|
|
|
|
|
|
"details",
|
|
|
|
|
|
"note",
|
|
|
|
|
|
"notes",
|
|
|
|
|
|
"cf_timelog_description",
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
) or "Ingen beskrivelse",
|
|
|
|
|
|
"status": log.get("status"),
|
|
|
|
|
|
"billable": log.get("billable"),
|
|
|
|
|
|
"hours": hours,
|
|
|
|
|
|
"vtiger_url": self._build_vtiger_record_url("Timelog", log.get("id")),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if related_case_id in case_map:
|
|
|
|
|
|
target_case = case_map[related_case_id]
|
|
|
|
|
|
else:
|
|
|
|
|
|
if unmatched_case_id not in case_map:
|
|
|
|
|
|
unmatched_case = {
|
|
|
|
|
|
"id": unmatched_case_id,
|
|
|
|
|
|
"cc_number": "-",
|
|
|
|
|
|
"title": "Timelogs uden relateret case",
|
|
|
|
|
|
"description": "Ingen relateret sag fra servicekontrakten.",
|
|
|
|
|
|
"contact_person": "-",
|
|
|
|
|
|
"vtiger_url": "",
|
|
|
|
|
|
"status": "Unknown",
|
|
|
|
|
|
"priority": None,
|
|
|
|
|
|
"total_hours": Decimal("0"),
|
|
|
|
|
|
"timelog_count": 0,
|
|
|
|
|
|
"timelogs": [],
|
|
|
|
|
|
}
|
|
|
|
|
|
case_map[unmatched_case_id] = unmatched_case
|
|
|
|
|
|
report_cases.append(unmatched_case)
|
|
|
|
|
|
target_case = case_map[unmatched_case_id]
|
|
|
|
|
|
|
|
|
|
|
|
target_case["timelogs"].append(timelog_payload)
|
|
|
|
|
|
target_case["timelog_count"] += 1
|
|
|
|
|
|
target_case["total_hours"] = (target_case["total_hours"] + hours).quantize(Decimal("0.01"))
|
|
|
|
|
|
|
|
|
|
|
|
total_timelogs = sum(int(case_item["timelog_count"]) for case_item in report_cases)
|
|
|
|
|
|
total_hours = sum((case_item["total_hours"] for case_item in report_cases), Decimal("0")).quantize(Decimal("0.01"))
|
|
|
|
|
|
closed_cases = sum(
|
|
|
|
|
|
1 for case_item in report_cases
|
|
|
|
|
|
if self._is_closed_case(case_item.get("status"))
|
|
|
|
|
|
)
|
|
|
|
|
|
open_cases = max(0, len(report_cases) - closed_cases)
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"customer": {
|
|
|
|
|
|
"account_id": safe_account_id,
|
|
|
|
|
|
"account_name": account_name,
|
|
|
|
|
|
},
|
|
|
|
|
|
"contract": {
|
|
|
|
|
|
"id": safe_contract_id,
|
|
|
|
|
|
"contract_number": contract.get("contract_number") or contract.get("contract_no") or "",
|
|
|
|
|
|
"subject": contract.get("subject") or "",
|
|
|
|
|
|
"contract_status": contract.get("contract_status"),
|
|
|
|
|
|
"vtiger_url": self._build_vtiger_record_url("ServiceContracts", safe_contract_id),
|
|
|
|
|
|
},
|
|
|
|
|
|
"cases": report_cases,
|
|
|
|
|
|
"summary": {
|
|
|
|
|
|
"total_cases": len(report_cases),
|
|
|
|
|
|
"open_cases": open_cases,
|
|
|
|
|
|
"closed_cases": closed_cases,
|
|
|
|
|
|
"total_timelogs": total_timelogs,
|
|
|
|
|
|
"total_hours": total_hours,
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
# Singleton instance
|
|
|
|
|
|
_vtiger_service = None
|
|
|
|
|
|
|
|
|
|
|
|
def get_vtiger_service() -> VTigerService:
|
|
|
|
|
|
"""Get or create vTiger service singleton"""
|
|
|
|
|
|
global _vtiger_service
|
|
|
|
|
|
if _vtiger_service is None:
|
|
|
|
|
|
_vtiger_service = VTigerService()
|
|
|
|
|
|
return _vtiger_service
|