- Implement tests to ensure the case tree contains parent and child branches while preventing cycles. - Verify that the timeline UI utilizes tree filters and cursor paging. - Confirm that the relation tree is displayed as a separate tab with appropriate search filters and inline module content. Enhance time tracking with prepaid card usage alerts - Create a migration to track alerts for prepaid card usage at 80% and 100%. - Implement triggers to generate cases when usage thresholds are reached, ensuring proper notifications and case management. Introduce audit logging for time entry edits - Modify the sync log to include time entry updates in the audit stream without altering existing records. Establish tracking for vTiger case imports and exports - Create tables to track cases imported from vTiger into Hub and exports from Hub to vTiger, ensuring idempotency and proper reference management.
1269 lines
52 KiB
Python
1269 lines
52 KiB
Python
"""
|
||
vTiger Cloud CRM Integration Service
|
||
Handles subscription and sales order data retrieval
|
||
"""
|
||
import logging
|
||
import json
|
||
import re
|
||
import html as html_lib
|
||
import aiohttp
|
||
import base64
|
||
from decimal import Decimal
|
||
from typing import List, Dict, Optional, Any
|
||
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")
|
||
|
||
self.last_query_status: Optional[int] = None
|
||
self.last_query_error: Optional[Dict] = None
|
||
|
||
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)
|
||
|
||
@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")
|
||
|
||
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()
|
||
self.last_query_status = None
|
||
self.last_query_error = None
|
||
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()
|
||
self.last_query_status = response.status
|
||
|
||
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")
|
||
self.last_query_error = None
|
||
return result
|
||
else:
|
||
self.last_query_error = data.get('error')
|
||
logger.error(f"❌ vTiger query failed: {self.last_query_error}")
|
||
return []
|
||
else:
|
||
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}")
|
||
logger.error(f"Query: {query_string}")
|
||
logger.error(f"Response: {text[:500]}")
|
||
return []
|
||
except Exception as e:
|
||
self.last_query_status = None
|
||
self.last_query_error = {"message": str(e)}
|
||
logger.error(f"❌ vTiger query error: {e}")
|
||
return []
|
||
|
||
async def describe_module(self, module_name: str) -> Dict[str, Any]:
|
||
"""Return vTiger module metadata (field names, editability and requirements)."""
|
||
module = str(module_name or "").strip()
|
||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
|
||
raise ValueError("Ugyldigt vTiger-modul")
|
||
if not self.rest_endpoint:
|
||
raise ValueError("VTIGER_URL not configured")
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(
|
||
f"{self.rest_endpoint}/describe",
|
||
params={"elementType": module},
|
||
auth=self._get_auth(),
|
||
timeout=aiohttp.ClientTimeout(total=30),
|
||
) as response:
|
||
text = await response.text()
|
||
try:
|
||
payload = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
raise RuntimeError(f"Ugyldigt svar fra vTiger describe ({response.status})") from exc
|
||
if response.status != 200 or not payload.get("success"):
|
||
error = payload.get("error", {}).get("message") or f"HTTP {response.status}"
|
||
raise RuntimeError(f"vTiger kunne ikke hente modulbeskrivelse: {error}")
|
||
return payload.get("result") or {}
|
||
|
||
async def create_record(self, module_name: str, fields: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Create one record using vTiger's REST API create contract."""
|
||
module = str(module_name or "").strip()
|
||
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
|
||
raise ValueError("Ugyldigt vTiger-modul")
|
||
if not isinstance(fields, dict) or not fields:
|
||
raise ValueError("Tom vTiger-post")
|
||
if not self.rest_endpoint:
|
||
raise ValueError("VTIGER_URL not configured")
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
f"{self.rest_endpoint}/create",
|
||
params={"elementType": module, "element": json.dumps(fields, ensure_ascii=False)},
|
||
auth=self._get_auth(),
|
||
timeout=aiohttp.ClientTimeout(total=30),
|
||
) as response:
|
||
text = await response.text()
|
||
try:
|
||
payload = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
excerpt = re.sub(r"\s+", " ", text).strip()[:300]
|
||
suffix = f": {excerpt}" if excerpt else " (tomt svar)"
|
||
raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc
|
||
if response.status != 200 or not payload.get("success"):
|
||
error = payload.get("error", {}).get("message") or f"HTTP {response.status}"
|
||
raise RuntimeError(f"vTiger kunne ikke oprette posten: {error}")
|
||
return payload.get("result") or {}
|
||
|
||
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
|
||
"""Retrieve the original bytes for a vTiger file resource."""
|
||
safe_id = self._sanitize_vtiger_id(resource_id)
|
||
if not safe_id or not self.rest_endpoint:
|
||
raise ValueError("Ugyldigt vTiger fil-id eller manglende VTIGER_URL")
|
||
self.last_query_status = None
|
||
self.last_query_error = None
|
||
try:
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.get(
|
||
f"{self.rest_endpoint}/files_retrieve",
|
||
params={"id": safe_id},
|
||
auth=self._get_auth(),
|
||
) as response:
|
||
self.last_query_status = response.status
|
||
data = await response.json(content_type=None)
|
||
if response.status != 200 or not data.get("success"):
|
||
self.last_query_error = data.get("error") or {"message": f"HTTP {response.status}"}
|
||
return None
|
||
result = data.get("result") or {}
|
||
if isinstance(result, list):
|
||
result = result[0] if result else {}
|
||
encoded = result.get("filecontents") or ""
|
||
try:
|
||
content = base64.b64decode(encoded, validate=True)
|
||
except Exception as exc:
|
||
self.last_query_error = {"message": "Ugyldigt base64-filindhold"}
|
||
raise ValueError("vTiger returnerede ugyldigt filindhold") from exc
|
||
return {**result, "content": content}
|
||
except Exception as exc:
|
||
if not self.last_query_error:
|
||
self.last_query_error = {"message": str(exc)}
|
||
return None
|
||
|
||
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:
|
||
# 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
|
||
|
||
auth = self._get_auth()
|
||
|
||
# Build payload with current data + updates
|
||
# Include essential fields for vTiger update validation
|
||
payload = {
|
||
'id': account_id,
|
||
'accountname': current_account.get('accountname'),
|
||
'assigned_user_id': current_account.get('assigned_user_id'),
|
||
'modifiedtime': current_account.get('modifiedtime'),
|
||
**update_data
|
||
}
|
||
|
||
logger.info(f"🔄 vTiger update payload: {payload}")
|
||
|
||
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')}")
|
||
logger.error(f"Full response: {text}")
|
||
return False
|
||
except json.JSONDecodeError:
|
||
logger.error(f"❌ Invalid JSON in update response: {text[:200]}")
|
||
return False
|
||
else:
|
||
logger.error(f"❌ vTiger update HTTP error {response.status}")
|
||
logger.error(f"Full response: {text[:2000]}")
|
||
return False
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Error updating vTiger account {account_id}: {e}")
|
||
return False
|
||
|
||
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 []
|
||
|
||
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
|
||
|
||
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
|
||
|
||
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
|
||
|
||
async def update_record_fields(self, module_name: str, record_id: str, updates: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""Update a small allow-listed field set on one vTiger record.
|
||
|
||
This generic variant is used for explicitly requested single-record
|
||
migrations. Callers must validate both the module and field names.
|
||
"""
|
||
module = str(module_name or "").strip()
|
||
safe_id = self._sanitize_vtiger_id(record_id)
|
||
if not module or not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", module):
|
||
raise ValueError("Ugyldigt vTiger-modul")
|
||
if not safe_id or not isinstance(updates, dict) or not updates:
|
||
raise ValueError("Ugyldigt vTiger-id eller tom ændring")
|
||
if not self.rest_endpoint:
|
||
raise ValueError("VTIGER_URL not configured")
|
||
|
||
current_rows = await self.query(f"SELECT * FROM {module} WHERE id='{safe_id}' LIMIT 1;")
|
||
if not current_rows:
|
||
raise ValueError(f"vTiger-posten {safe_id} blev ikke fundet")
|
||
# The Vtiger REST API's revise method is intended for partial changes:
|
||
# POST /revise?element=<JSON string>. The update endpoint has a
|
||
# different contract and may return an empty HTTP 400 when called with
|
||
# the wrapped {elementType, element} payload used by other adapters.
|
||
element = {"id": safe_id, **updates}
|
||
|
||
async with aiohttp.ClientSession() as session:
|
||
async with session.post(
|
||
f"{self.rest_endpoint}/revise",
|
||
params={"element": json.dumps(element)},
|
||
auth=self._get_auth(),
|
||
timeout=aiohttp.ClientTimeout(total=30),
|
||
) as response:
|
||
text = await response.text()
|
||
try:
|
||
result = json.loads(text)
|
||
except json.JSONDecodeError as exc:
|
||
excerpt = re.sub(r"\s+", " ", text).strip()[:300]
|
||
suffix = f": {excerpt}" if excerpt else " (tomt svar)"
|
||
raise RuntimeError(f"Ugyldigt svar fra vTiger ({response.status}){suffix}") from exc
|
||
if response.status != 200 or not result.get("success"):
|
||
error = result.get("error", {}).get("message") or f"HTTP {response.status}"
|
||
raise RuntimeError(f"vTiger kunne ikke opdatere posten: {error}")
|
||
return result.get("result") or {}
|
||
|
||
async def get_service_contracts(self, account_id: Optional[str] = None, active_only: bool = True) -> List[Dict]:
|
||
"""
|
||
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:
|
||
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})")
|
||
else:
|
||
logger.info(f"🔍 Fetching all service contracts (active_only={active_only})")
|
||
|
||
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 []
|
||
|
||
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,
|
||
},
|
||
}
|
||
|
||
|
||
# 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
|