bmc_hub/app/services/simplycrm_service.py

523 lines
20 KiB
Python
Raw Normal View History

"""
Simply-CRM Integration Service
Sync abonnementer, fakturaer og kunder fra Simply-CRM (VTiger fork)
Simply-CRM bruger webservice.php endpoint med challenge-token authentication:
- Endpoint: /webservice.php
- Auth: getchallenge + login med MD5 hash
- Moduler: Accounts, Invoice, Products, SalesOrder
"""
import logging
import json
import hashlib
import aiohttp
from typing import List, Dict, Optional, Any
from datetime import datetime, date
from app.core.config import settings
logger = logging.getLogger(__name__)
class SimplyCRMService:
"""Service for integrating with Simply-CRM via webservice.php (VTiger fork)"""
def __init__(self):
# Simply-CRM bruger OLD_VTIGER settings
self.base_url = getattr(settings, 'OLD_VTIGER_URL', None)
self.username = getattr(settings, 'OLD_VTIGER_USERNAME', None)
self.access_key = getattr(settings, 'OLD_VTIGER_ACCESS_KEY', None)
self.session_name: Optional[str] = None
self.session: Optional[aiohttp.ClientSession] = None
if not all([self.base_url, self.username, self.access_key]):
logger.warning("⚠️ Simply-CRM credentials not configured (OLD_VTIGER_* settings)")
async def __aenter__(self):
"""Context manager entry - create session and login"""
self.session = aiohttp.ClientSession()
await self.login()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit - close session"""
if self.session:
await self.session.close()
self.session = None
self.session_name = None
async def login(self) -> bool:
"""
Login to Simply-CRM using challenge-token authentication
Returns:
True if login successful
"""
if not self.base_url or not self.username or not self.access_key:
logger.error("❌ Simply-CRM credentials not configured")
return False
try:
if not self.session:
self.session = aiohttp.ClientSession()
# Step 1: Get challenge token
async with self.session.get(
f"{self.base_url}/webservice.php",
params={"operation": "getchallenge", "username": self.username},
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if not response.ok:
logger.error(f"❌ Simply-CRM challenge request failed: {response.status}")
return False
data = await response.json()
if not data.get("success"):
logger.error(f"❌ Simply-CRM challenge failed: {data}")
return False
token = data["result"]["token"]
# Step 2: Generate access key hash
access_key_hash = hashlib.md5(f"{token}{self.access_key}".encode()).hexdigest()
# Step 3: Login
async with self.session.post(
f"{self.base_url}/webservice.php",
data={
"operation": "login",
"username": self.username,
"accessKey": access_key_hash
},
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if not response.ok:
logger.error(f"❌ Simply-CRM login request failed: {response.status}")
return False
data = await response.json()
if not data.get("success"):
logger.error(f"❌ Simply-CRM login failed: {data}")
return False
self.session_name = data["result"]["sessionName"]
session_preview = self.session_name[:20] if self.session_name else "unknown"
logger.info(f"✅ Simply-CRM login successful (session: {session_preview}...)")
return True
except aiohttp.ClientError as e:
logger.error(f"❌ Simply-CRM connection error: {e}")
return False
except Exception as e:
logger.error(f"❌ Simply-CRM login error: {e}")
return False
async def test_connection(self) -> bool:
"""
Test Simply-CRM connection by logging in
Returns:
True if connection successful
"""
try:
async with aiohttp.ClientSession() as session:
self.session = session
result = await self.login()
self.session = None
return result
except Exception as e:
logger.error(f"❌ Simply-CRM connection test failed: {e}")
return False
async def _ensure_session(self):
"""Ensure we have an active session"""
if not self.session:
self.session = aiohttp.ClientSession()
if not self.session_name:
await self.login()
async def query(self, query_string: str) -> List[Dict]:
"""
Execute a query on Simply-CRM
Args:
query_string: SQL-like query (e.g., "SELECT * FROM Accounts LIMIT 100;")
Returns:
List of records
"""
await self._ensure_session()
if not self.session_name or not self.session:
logger.error("❌ Not logged in to Simply-CRM")
return []
try:
async with self.session.get(
f"{self.base_url}/webservice.php",
params={
"operation": "query",
"sessionName": self.session_name,
"query": query_string
},
timeout=aiohttp.ClientTimeout(total=60)
) as response:
if not response.ok:
logger.error(f"❌ Simply-CRM query failed: {response.status}")
return []
data = await response.json()
if not data.get("success"):
error = data.get("error", {})
logger.error(f"❌ Simply-CRM query error: {error}")
return []
result = data.get("result", [])
logger.debug(f"✅ Simply-CRM query returned {len(result)} records")
return result
except Exception as e:
logger.error(f"❌ Simply-CRM query error: {e}")
return []
async def retrieve(self, record_id: str) -> Optional[Dict]:
"""
Retrieve a specific record by ID
Args:
record_id: VTiger-style ID (e.g., "6x12345")
Returns:
Record dict or None
"""
await self._ensure_session()
if not self.session_name or not self.session:
return None
try:
async with self.session.get(
f"{self.base_url}/webservice.php",
params={
"operation": "retrieve",
"sessionName": self.session_name,
"id": record_id
},
timeout=aiohttp.ClientTimeout(total=30)
) as response:
if not response.ok:
return None
data = await response.json()
if data.get("success"):
return data.get("result")
return None
except Exception as e:
logger.error(f"❌ Simply-CRM retrieve error: {e}")
return None
# =========================================================================
# SUBSCRIPTIONS
# =========================================================================
async def fetch_subscriptions(self, limit: int = 100, offset: int = 0) -> List[Dict]:
"""
Fetch subscriptions from Simply-CRM via recurring SalesOrders
SalesOrders with enable_recurring='1' are the subscription source in Simply-CRM.
"""
query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};"
return await self.query(query)
async def fetch_active_subscriptions(self) -> List[Dict]:
"""
Fetch all active recurring SalesOrders (subscriptions)
Returns deduplicated list of unique SalesOrders.
"""
all_records = []
offset = 0
limit = 100
seen_ids = set()
while True:
query = f"SELECT * FROM SalesOrder WHERE enable_recurring = '1' LIMIT {offset}, {limit};"
batch = await self.query(query)
if not batch:
break
# Deduplicate by id (VTiger returns one row per line item)
for record in batch:
record_id = record.get('id')
if record_id and record_id not in seen_ids:
seen_ids.add(record_id)
all_records.append(record)
if len(batch) < limit:
break
offset += limit
logger.info(f"📊 Fetched {len(all_records)} unique recurring SalesOrders from Simply-CRM")
return all_records
# =========================================================================
# INVOICES
# =========================================================================
async def fetch_invoices(self, limit: int = 100, offset: int = 0) -> List[Dict]:
"""Fetch invoices from Simply-CRM"""
query = f"SELECT * FROM Invoice LIMIT {offset}, {limit};"
return await self.query(query)
async def fetch_invoices_by_account(self, account_id: str) -> List[Dict]:
"""Fetch all invoices for a specific account"""
query = f"SELECT * FROM Invoice WHERE account_id = '{account_id}';"
return await self.query(query)
async def fetch_invoice_with_lines(self, invoice_id: str) -> Optional[Dict]:
"""Fetch invoice with full line item details"""
return await self.retrieve(invoice_id)
async def fetch_recent_invoices(self, days: int = 30) -> List[Dict]:
"""Fetch invoices from the last N days"""
from datetime import datetime, timedelta
since_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
all_invoices = []
offset = 0
limit = 100
while True:
query = f"SELECT * FROM Invoice WHERE invoicedate >= '{since_date}' LIMIT {offset}, {limit};"
batch = await self.query(query)
if not batch:
break
all_invoices.extend(batch)
if len(batch) < limit:
break
offset += limit
logger.info(f"📊 Fetched {len(all_invoices)} invoices from last {days} days")
return all_invoices
# =========================================================================
# ACCOUNTS (CUSTOMERS)
# =========================================================================
async def fetch_accounts(self, limit: int = 100, offset: int = 0) -> List[Dict]:
"""Fetch accounts (customers) from Simply-CRM"""
query = f"SELECT * FROM Accounts LIMIT {offset}, {limit};"
return await self.query(query)
async def fetch_account_by_id(self, account_id: str) -> Optional[Dict]:
"""Fetch account by VTiger ID (e.g., '11x12345')"""
return await self.retrieve(account_id)
async def fetch_account_by_name(self, name: str) -> Optional[Dict]:
"""Find account by name"""
query = f"SELECT * FROM Accounts WHERE accountname = '{name}';"
results = await self.query(query)
return results[0] if results else None
async def fetch_account_by_cvr(self, cvr: str) -> Optional[Dict]:
"""Find account by CVR number"""
query = f"SELECT * FROM Accounts WHERE siccode = '{cvr}';"
results = await self.query(query)
if not results:
# Try vat_number field
query = f"SELECT * FROM Accounts WHERE vat_number = '{cvr}';"
results = await self.query(query)
return results[0] if results else None
async def fetch_all_accounts(self) -> List[Dict]:
"""Fetch all accounts (with pagination)"""
all_accounts = []
offset = 0
limit = 100
while True:
batch = await self.fetch_accounts(limit, offset)
if not batch:
break
all_accounts.extend(batch)
if len(batch) < limit:
break
offset += limit
logger.info(f"📊 Fetched {len(all_accounts)} accounts from Simply-CRM")
return all_accounts
# =========================================================================
# PRODUCTS
# =========================================================================
async def fetch_products(self, limit: int = 100, offset: int = 0) -> List[Dict]:
"""Fetch products from Simply-CRM"""
query = f"SELECT * FROM Products LIMIT {offset}, {limit};"
return await self.query(query)
async def fetch_product_by_number(self, product_number: str) -> Optional[Dict]:
"""Find product by product number"""
query = f"SELECT * FROM Products WHERE product_no = '{product_number}';"
results = await self.query(query)
return results[0] if results else None
# =========================================================================
# SYNC HELPERS
# =========================================================================
async def get_modified_since(self, module: str, since_date: str) -> List[Dict]:
"""Get records modified since a specific date"""
all_records = []
offset = 0
limit = 100
while True:
query = f"SELECT * FROM {module} WHERE modifiedtime >= '{since_date}' LIMIT {offset}, {limit};"
batch = await self.query(query)
if not batch:
break
all_records.extend(batch)
if len(batch) < limit:
break
offset += limit
return all_records
def extract_subscription_data(self, raw_salesorder: Dict) -> Dict:
"""
Extract and normalize subscription data from Simply-CRM SalesOrder format
SalesOrders with enable_recurring='1' are the subscription source.
Key fields:
- recurring_frequency: Monthly, Quarterly, Yearly
- start_period / end_period: Subscription period
- cf_abonnementsperiode_dato: Binding end date
- arr: Annual Recurring Revenue
- sostatus: Created, Approved, Lukket
"""
return {
'simplycrm_id': raw_salesorder.get('id'),
'salesorder_no': raw_salesorder.get('salesorder_no'),
'name': raw_salesorder.get('subject', ''),
'account_id': raw_salesorder.get('account_id'),
'status': self._map_salesorder_status(raw_salesorder.get('sostatus')),
'start_date': raw_salesorder.get('start_period'),
'end_date': raw_salesorder.get('end_period'),
'binding_end_date': raw_salesorder.get('cf_abonnementsperiode_dato'),
'billing_frequency': self._map_billing_frequency(raw_salesorder.get('recurring_frequency')),
'auto_invoicing': raw_salesorder.get('auto_invoicing'),
'last_recurring_date': raw_salesorder.get('last_recurring_date'),
'total_amount': self._parse_amount(raw_salesorder.get('hdnGrandTotal')),
'subtotal': self._parse_amount(raw_salesorder.get('hdnSubTotal')),
'arr': self._parse_amount(raw_salesorder.get('arr')),
'currency': 'DKK',
'modified_time': raw_salesorder.get('modifiedtime'),
'created_time': raw_salesorder.get('createdtime'),
'eco_order_number': raw_salesorder.get('eco_order_number'),
}
def _map_salesorder_status(self, status: Optional[str]) -> str:
"""Map Simply-CRM SalesOrder status to OmniSync status"""
if not status:
return 'active'
status_map = {
'Created': 'active',
'Approved': 'active',
'Delivered': 'active',
'Lukket': 'cancelled',
'Cancelled': 'cancelled',
}
return status_map.get(status, 'active')
def extract_invoice_data(self, raw_invoice: Dict) -> Dict:
"""Extract and normalize invoice data from Simply-CRM format"""
return {
'simplycrm_id': raw_invoice.get('id'),
'invoice_number': raw_invoice.get('invoice_no'),
'invoice_date': raw_invoice.get('invoicedate'),
'account_id': raw_invoice.get('account_id'),
'status': self._map_invoice_status(raw_invoice.get('invoicestatus')),
'subtotal': self._parse_amount(raw_invoice.get('hdnSubTotal')),
'tax_amount': self._parse_amount(raw_invoice.get('hdnTax')),
'total_amount': self._parse_amount(raw_invoice.get('hdnGrandTotal')),
'currency': raw_invoice.get('currency_id', 'DKK'),
'line_items': raw_invoice.get('LineItems', []),
'subscription_period': raw_invoice.get('cf_subscription_period'),
'is_subscription': raw_invoice.get('invoicestatus') == 'Auto Created',
'modified_time': raw_invoice.get('modifiedtime'),
}
def _map_subscription_status(self, status: Optional[str]) -> str:
"""Map Simply-CRM subscription status to OmniSync status"""
if not status:
return 'active'
status_map = {
'Active': 'active',
'Cancelled': 'cancelled',
'Expired': 'expired',
'Suspended': 'suspended',
'Pending': 'draft',
}
return status_map.get(status, 'active')
def _map_invoice_status(self, status: Optional[str]) -> str:
"""Map Simply-CRM invoice status"""
if not status:
return 'active'
status_map = {
'Created': 'active',
'Approved': 'active',
'Sent': 'active',
'Paid': 'paid',
'Cancelled': 'cancelled',
'Credit Invoice': 'credited',
'Auto Created': 'active',
}
return status_map.get(status, 'active')
def _map_billing_frequency(self, frequency: Optional[str]) -> str:
"""Map billing frequency to standard format"""
if not frequency:
return 'monthly'
freq_map = {
'Monthly': 'monthly',
'Quarterly': 'quarterly',
'Semi-annually': 'semi_annual',
'Annually': 'yearly',
'Yearly': 'yearly',
}
return freq_map.get(frequency, 'monthly')
def _parse_amount(self, value: Any) -> float:
"""Parse amount from string or number"""
if value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
try:
cleaned = str(value).replace(' ', '').replace(',', '.').replace('DKK', '').replace('kr', '')
return float(cleaned)
except (ValueError, TypeError):
return 0.0
# Singleton instance
simplycrm_service = SimplyCRMService()