- Added a new column `subscriptions_locked` to the `customers` table to manage subscription access. - Implemented a script to create new modules from a template, including updates to various files (module.json, README.md, router.py, views.py, and migration SQL). - Developed a script to import BMC Office subscriptions from an Excel file into the database, including error handling and statistics reporting. - Created a script to lookup and update missing CVR numbers using the CVR.dk API. - Implemented a script to relink Hub customers to e-conomic customer numbers based on name matching. - Developed scripts to sync CVR numbers from Simply-CRM and vTiger to the local customers database.
523 lines
20 KiB
Python
523 lines
20 KiB
Python
"""
|
|
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()
|