2025-12-19 13:09:42 +01:00
|
|
|
"""
|
|
|
|
|
System Sync Router
|
|
|
|
|
API endpoints for syncing data between vTiger, e-conomic and Hub
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
|
|
|
|
from fastapi import APIRouter, HTTPException
|
|
|
|
|
from typing import Dict, Any
|
|
|
|
|
from app.core.database import execute_query
|
|
|
|
|
from app.services.vtiger_service import get_vtiger_service
|
|
|
|
|
import re
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def normalize_name(name: str) -> str:
|
|
|
|
|
"""Normalize company name for matching"""
|
|
|
|
|
if not name:
|
|
|
|
|
return ""
|
|
|
|
|
# Remove common suffixes and punctuation
|
|
|
|
|
name = re.sub(r'\b(A/S|ApS|I/S|IVS|v/)\b', '', name, flags=re.IGNORECASE)
|
|
|
|
|
name = re.sub(r'[^\w\s]', '', name) # Remove punctuation
|
|
|
|
|
return name.lower().strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sync/vtiger")
|
|
|
|
|
async def sync_from_vtiger() -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Sync companies from vTiger Accounts module
|
|
|
|
|
Matches by CVR number or normalized company name
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("🔄 Starting vTiger accounts sync...")
|
|
|
|
|
|
|
|
|
|
vtiger = get_vtiger_service()
|
|
|
|
|
|
|
|
|
|
# Query vTiger for all accounts with CVR or name
|
2025-12-19 15:28:25 +01:00
|
|
|
query = "SELECT id, accountname, email1, siccode, cf_accounts_cvr, cf_854, website1, bill_city, bill_code, bill_country FROM Accounts WHERE accountname != '' LIMIT 1000;"
|
2025-12-19 13:09:42 +01:00
|
|
|
|
|
|
|
|
accounts = await vtiger.query(query)
|
|
|
|
|
logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger")
|
|
|
|
|
|
|
|
|
|
created_count = 0
|
|
|
|
|
updated_count = 0
|
|
|
|
|
skipped_count = 0
|
|
|
|
|
|
|
|
|
|
for account in accounts:
|
|
|
|
|
vtiger_id = account.get('id')
|
|
|
|
|
name = account.get('accountname', '').strip()
|
|
|
|
|
cvr = account.get('cf_accounts_cvr') or account.get('siccode')
|
|
|
|
|
economic_customer_number = account.get('cf_854') # Custom field for e-conomic number
|
|
|
|
|
|
|
|
|
|
if not name:
|
|
|
|
|
skipped_count += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Clean CVR number
|
|
|
|
|
if cvr:
|
|
|
|
|
cvr = re.sub(r'\D', '', str(cvr))[:8] # Remove non-digits, max 8 chars
|
|
|
|
|
if len(cvr) != 8:
|
|
|
|
|
cvr = None
|
|
|
|
|
|
|
|
|
|
# Try to find existing customer by vTiger ID or CVR
|
|
|
|
|
existing = None
|
|
|
|
|
if vtiger_id:
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
"SELECT id FROM customers WHERE vtiger_id = %s",
|
|
|
|
|
(vtiger_id,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing and cvr:
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
"SELECT id FROM customers WHERE cvr_number = %s",
|
|
|
|
|
(cvr,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing:
|
|
|
|
|
# Match by normalized name
|
|
|
|
|
normalized = normalize_name(name)
|
|
|
|
|
all_customers = execute_query("SELECT id, name FROM customers")
|
|
|
|
|
for customer in all_customers:
|
|
|
|
|
if normalize_name(customer['name']) == normalized:
|
|
|
|
|
existing = [customer]
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
# Update existing customer
|
|
|
|
|
update_fields = []
|
|
|
|
|
params = []
|
|
|
|
|
|
|
|
|
|
if vtiger_id:
|
|
|
|
|
update_fields.append("vtiger_id = %s")
|
|
|
|
|
params.append(vtiger_id)
|
|
|
|
|
|
|
|
|
|
if cvr:
|
|
|
|
|
update_fields.append("cvr_number = %s")
|
|
|
|
|
params.append(cvr)
|
|
|
|
|
|
|
|
|
|
if economic_customer_number:
|
|
|
|
|
update_fields.append("economic_customer_number = %s")
|
|
|
|
|
params.append(int(economic_customer_number))
|
|
|
|
|
|
|
|
|
|
update_fields.append("last_synced_at = NOW()")
|
|
|
|
|
|
|
|
|
|
if update_fields:
|
|
|
|
|
params.append(existing[0]['id'])
|
|
|
|
|
query = f"UPDATE customers SET {', '.join(update_fields)} WHERE id = %s"
|
|
|
|
|
execute_query(query, tuple(params))
|
|
|
|
|
updated_count += 1
|
|
|
|
|
logger.debug(f"✅ Updated: {name}")
|
|
|
|
|
else:
|
|
|
|
|
# Create new customer
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO customers
|
|
|
|
|
(name, vtiger_id, cvr_number, economic_customer_number,
|
|
|
|
|
email_domain, city, postal_code, country, website, last_synced_at)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
email_domain = account.get('email1', '').split('@')[-1] if '@' in account.get('email1', '') else None
|
|
|
|
|
city = account.get('bill_city')
|
|
|
|
|
postal_code = account.get('bill_code')
|
|
|
|
|
country = account.get('bill_country') or 'DK'
|
|
|
|
|
website = account.get('website1')
|
|
|
|
|
|
|
|
|
|
result = execute_query(insert_query, (
|
|
|
|
|
name,
|
|
|
|
|
vtiger_id,
|
|
|
|
|
cvr,
|
|
|
|
|
int(economic_customer_number) if economic_customer_number else None,
|
|
|
|
|
email_domain,
|
|
|
|
|
city,
|
|
|
|
|
postal_code,
|
|
|
|
|
country,
|
|
|
|
|
website
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
created_count += 1
|
|
|
|
|
logger.debug(f"✨ Created: {name}")
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ vTiger sync complete: {created_count} created, {updated_count} updated, {skipped_count} skipped")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "success",
|
|
|
|
|
"created": created_count,
|
|
|
|
|
"updated": updated_count,
|
|
|
|
|
"skipped": skipped_count,
|
|
|
|
|
"total_processed": len(accounts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ vTiger sync error: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sync/vtiger-contacts")
|
|
|
|
|
async def sync_vtiger_contacts() -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Sync contacts from vTiger Contacts module
|
|
|
|
|
Links to existing Hub customers by Account ID
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("🔄 Starting vTiger contacts sync...")
|
|
|
|
|
|
|
|
|
|
vtiger = get_vtiger_service()
|
|
|
|
|
|
|
|
|
|
# Query vTiger for contacts
|
2025-12-19 15:28:25 +01:00
|
|
|
query = "SELECT id, firstname, lastname, email, phone, mobile, title, department, account_id FROM Contacts WHERE firstname != '' OR lastname != '' LIMIT 1000;"
|
2025-12-19 13:09:42 +01:00
|
|
|
|
|
|
|
|
contacts = await vtiger.query(query)
|
|
|
|
|
logger.info(f"📥 Fetched {len(contacts)} contacts from vTiger")
|
|
|
|
|
|
|
|
|
|
created_count = 0
|
|
|
|
|
updated_count = 0
|
|
|
|
|
skipped_count = 0
|
|
|
|
|
|
|
|
|
|
for contact in contacts:
|
|
|
|
|
vtiger_contact_id = contact.get('id')
|
|
|
|
|
first_name = contact.get('firstname', '').strip()
|
|
|
|
|
last_name = contact.get('lastname', '').strip()
|
|
|
|
|
|
|
|
|
|
if not (first_name or last_name):
|
|
|
|
|
skipped_count += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Find existing contact by vTiger ID
|
|
|
|
|
existing = None
|
|
|
|
|
if vtiger_contact_id:
|
|
|
|
|
existing = execute_query(
|
|
|
|
|
"SELECT id FROM contacts WHERE vtiger_id = %s",
|
|
|
|
|
(vtiger_contact_id,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
contact_data = {
|
|
|
|
|
'first_name': first_name,
|
|
|
|
|
'last_name': last_name,
|
|
|
|
|
'email': contact.get('email'),
|
|
|
|
|
'phone': contact.get('phone'),
|
|
|
|
|
'mobile': contact.get('mobile'),
|
|
|
|
|
'title': contact.get('title'),
|
|
|
|
|
'department': contact.get('department'),
|
|
|
|
|
'vtiger_id': vtiger_contact_id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if existing:
|
|
|
|
|
# Update existing contact
|
|
|
|
|
update_query = """
|
|
|
|
|
UPDATE contacts
|
|
|
|
|
SET first_name = %s, last_name = %s, email = %s, phone = %s,
|
|
|
|
|
mobile = %s, title = %s, department = %s, updated_at = NOW()
|
|
|
|
|
WHERE id = %s
|
|
|
|
|
"""
|
|
|
|
|
execute_query(update_query, (
|
|
|
|
|
contact_data['first_name'],
|
|
|
|
|
contact_data['last_name'],
|
|
|
|
|
contact_data['email'],
|
|
|
|
|
contact_data['phone'],
|
|
|
|
|
contact_data['mobile'],
|
|
|
|
|
contact_data['title'],
|
|
|
|
|
contact_data['department'],
|
|
|
|
|
existing[0]['id']
|
|
|
|
|
))
|
|
|
|
|
updated_count += 1
|
|
|
|
|
contact_id = existing[0]['id']
|
|
|
|
|
else:
|
|
|
|
|
# Create new contact
|
|
|
|
|
insert_query = """
|
|
|
|
|
INSERT INTO contacts
|
|
|
|
|
(first_name, last_name, email, phone, mobile, title, department, vtiger_id)
|
|
|
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
RETURNING id
|
|
|
|
|
"""
|
|
|
|
|
result = execute_query(insert_query, (
|
|
|
|
|
contact_data['first_name'],
|
|
|
|
|
contact_data['last_name'],
|
|
|
|
|
contact_data['email'],
|
|
|
|
|
contact_data['phone'],
|
|
|
|
|
contact_data['mobile'],
|
|
|
|
|
contact_data['title'],
|
|
|
|
|
contact_data['department'],
|
|
|
|
|
contact_data['vtiger_id']
|
|
|
|
|
))
|
|
|
|
|
|
|
|
|
|
if result:
|
|
|
|
|
created_count += 1
|
|
|
|
|
contact_id = result[0]['id']
|
|
|
|
|
else:
|
|
|
|
|
skipped_count += 1
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
# Link contact to customer if account_id exists
|
|
|
|
|
account_id = contact.get('account_id')
|
|
|
|
|
if account_id and contact_id:
|
|
|
|
|
# Find customer by vTiger account ID
|
|
|
|
|
customer = execute_query(
|
|
|
|
|
"SELECT id FROM customers WHERE vtiger_id = %s",
|
|
|
|
|
(account_id,)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if customer:
|
|
|
|
|
# Check if relationship exists
|
|
|
|
|
existing_rel = execute_query(
|
|
|
|
|
"SELECT id FROM contact_companies WHERE contact_id = %s AND customer_id = %s",
|
|
|
|
|
(contact_id, customer[0]['id'])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if not existing_rel:
|
|
|
|
|
# Create relationship
|
|
|
|
|
execute_query(
|
|
|
|
|
"INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES (%s, %s, false)",
|
|
|
|
|
(contact_id, customer[0]['id'])
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(f"✅ vTiger contacts sync complete: {created_count} created, {updated_count} updated, {skipped_count} skipped")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "success",
|
|
|
|
|
"created": created_count,
|
|
|
|
|
"updated": updated_count,
|
|
|
|
|
"skipped": skipped_count,
|
|
|
|
|
"total_processed": len(contacts)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ vTiger contacts sync error: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sync/economic")
|
|
|
|
|
async def sync_from_economic() -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Sync customer numbers from e-conomic
|
|
|
|
|
Matches Hub customers to e-conomic by CVR number or normalized name
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("🔄 Starting e-conomic sync...")
|
|
|
|
|
|
|
|
|
|
# Note: This requires adding get_customers() method to economic_service.py
|
|
|
|
|
# For now, return a placeholder response
|
|
|
|
|
|
|
|
|
|
logger.warning("⚠️ e-conomic sync not fully implemented yet")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "not_implemented",
|
|
|
|
|
"message": "e-conomic customer sync requires get_customers() method in economic_service.py",
|
|
|
|
|
"matched": 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ e-conomic sync error: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/sync/cvr-to-economic")
|
|
|
|
|
async def sync_cvr_to_economic() -> Dict[str, Any]:
|
|
|
|
|
"""
|
|
|
|
|
Find customers in Hub with CVR but without e-conomic customer number
|
|
|
|
|
Search e-conomic for matching CVR and update Hub
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
logger.info("🔄 Starting CVR to e-conomic sync...")
|
|
|
|
|
|
|
|
|
|
# Find customers with CVR but no economic_customer_number
|
|
|
|
|
customers = execute_query("""
|
|
|
|
|
SELECT id, name, cvr_number
|
|
|
|
|
FROM customers
|
|
|
|
|
WHERE cvr_number IS NOT NULL
|
|
|
|
|
AND cvr_number != ''
|
|
|
|
|
AND economic_customer_number IS NULL
|
|
|
|
|
LIMIT 100
|
|
|
|
|
""")
|
|
|
|
|
|
|
|
|
|
logger.info(f"📥 Found {len(customers)} customers with CVR but no e-conomic number")
|
|
|
|
|
|
|
|
|
|
# Note: This requires e-conomic API search functionality
|
|
|
|
|
# For now, return placeholder
|
|
|
|
|
|
|
|
|
|
logger.warning("⚠️ CVR to e-conomic sync not fully implemented yet")
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"status": "not_implemented",
|
|
|
|
|
"message": "Requires e-conomic API search by CVR functionality",
|
|
|
|
|
"found": 0,
|
|
|
|
|
"candidates": len(customers)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"❌ CVR to e-conomic sync error: {e}")
|
|
|
|
|
raise HTTPException(status_code=500, detail=str(e))
|