""" 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]: """ Link vTiger accounts to existing Hub customers Matches by CVR or normalized name, updates vtiger_id """ try: logger.info("🔄 Starting vTiger link sync...") vtiger = get_vtiger_service() # Fetch ALL accounts - vTiger query API doesn't support LIMIT/OFFSET # Instead, use recursive queries based on ID to get all records all_accounts = [] last_id = None batch_size = 100 # vTiger typically returns ~100 records per query while True: # Build query with ID filter to paginate if last_id is None: query = f"SELECT id, accountname, email1, siccode, cf_accounts_cvr, website, bill_city, bill_code, bill_country FROM Accounts ORDER BY id LIMIT {batch_size};" else: # Get records with ID > last_id to continue pagination query = f"SELECT id, accountname, email1, siccode, cf_accounts_cvr, website, bill_city, bill_code, bill_country FROM Accounts WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};" batch = await vtiger.query(query) if not batch or len(batch) == 0: break all_accounts.extend(batch) last_id = batch[-1].get('id') # Track last ID for next query logger.info(f"📥 Fetched batch: {len(batch)} accounts (last ID: {last_id}, total: {len(all_accounts)})") # If we got less than batch_size, we've reached the end if len(batch) < batch_size: break logger.info(f"📥 Fetched total of {len(all_accounts)} accounts from vTiger") accounts = all_accounts linked_count = 0 updated_count = 0 not_found_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') if not name or not vtiger_id: not_found_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 # Find existing Hub customer by CVR or normalized name existing = None match_method = None if cvr: existing = execute_query( "SELECT id, name, vtiger_id FROM customers WHERE cvr_number = %s", (cvr,) ) if existing: match_method = "CVR" if not existing: # Match by normalized name normalized = normalize_name(name) all_customers = execute_query("SELECT id, name, vtiger_id FROM customers") for customer in all_customers: if normalize_name(customer['name']) == normalized: existing = [customer] match_method = "navn" break if existing: # Link vTiger ID to existing customer current_vtiger_id = existing[0].get('vtiger_id') if current_vtiger_id is None: execute_query( "UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s", (vtiger_id, existing[0]['id']) ) linked_count += 1 logger.info(f"🔗 Linket: {existing[0]['name']} → vTiger #{vtiger_id} (match: {match_method}, CVR: {cvr or 'ingen'})") elif current_vtiger_id != vtiger_id: # Update if different vTiger ID execute_query( "UPDATE customers SET vtiger_id = %s, last_synced_at = NOW() WHERE id = %s", (vtiger_id, existing[0]['id']) ) updated_count += 1 logger.info(f"✏️ Opdateret vTiger ID: {existing[0]['name']} → {vtiger_id} (var: {current_vtiger_id})") else: # Already linked, just update timestamp execute_query("UPDATE customers SET last_synced_at = NOW() WHERE id = %s", (existing[0]['id'],)) else: not_found_count += 1 logger.debug(f"⏭️ Ikke fundet i Hub: '{name}' (CVR: {cvr or 'ingen'}, normalized: '{normalize_name(name)}')") logger.info(f"✅ vTiger link sync fuldført: {linked_count} nye linket, {updated_count} opdateret, {not_found_count} ikke fundet af {len(accounts)} totalt") return { "status": "success", "linked": linked_count, "updated": updated_count, "not_found": not_found_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() # Fetch ALL contacts with pagination (same as accounts) all_contacts = [] last_id = None batch_size = 100 while True: # Build query with ID filter to paginate if last_id is None: query = f"SELECT id, firstname, lastname, email, phone, mobile, title, department, account_id FROM Contacts ORDER BY id LIMIT {batch_size};" else: query = f"SELECT id, firstname, lastname, email, phone, mobile, title, department, account_id FROM Contacts WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};" batch = await vtiger.query(query) if not batch or len(batch) == 0: break all_contacts.extend(batch) last_id = batch[-1].get('id') logger.info(f"📥 Fetched batch: {len(batch)} contacts (last ID: {last_id}, total: {len(all_contacts)})") if len(batch) < batch_size: break logger.info(f"📥 Fetched total of {len(all_contacts)} contacts from vTiger") contacts = all_contacts created_count = 0 updated_count = 0 skipped_count = 0 linked_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 logger.debug(f"⏭️ Sprunget over: Intet navn (ID: {vtiger_contact_id})") 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'] logger.info(f"✏️ Opdateret kontakt: {first_name} {last_name} (Email: {contact_data['email'] or 'ingen'})") 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'] logger.info(f"✨ Oprettet kontakt: {first_name} {last_name} (Email: {contact_data['email'] or 'ingen'})") else: skipped_count += 1 logger.warning(f"⚠️ Kunne ikke oprette kontakt: {first_name} {last_name}") 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, name FROM customers WHERE vtiger_id = %s", (account_id,) ) if customer: customer_name = customer[0]['name'] # 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']) ) linked_count += 1 logger.info(f"🔗 Linket kontakt {first_name} {last_name} til firma: {customer_name}") else: logger.debug(f"⚠️ Kunde ikke fundet for account_id={account_id} (kontakt: {first_name} {last_name})") logger.info(f"✅ vTiger kontakt sync fuldført: {created_count} oprettet, {updated_count} opdateret, {linked_count} linket, {skipped_count} sprunget over af {len(contacts)} totalt") return { "status": "success", "created": created_count, "updated": updated_count, "linked": linked_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 customers from e-conomic (PRIMARY SOURCE) Creates/updates Hub customers with e-conomic data """ try: logger.info("🔄 Starting e-conomic customer sync (PRIMARY SOURCE)...") from app.services.economic_service import EconomicService economic = EconomicService() # Get all customers from e-conomic (max 1000 per page) all_customers = [] page = 0 while True: customers = await economic.get_customers(page=page, page_size=1000) if not customers: break all_customers.extend(customers) page += 1 if len(customers) < 1000: # Last page break economic_customers = all_customers logger.info(f"📥 Fetched {len(economic_customers)} customers from e-conomic ({page} pages)") created_count = 0 updated_count = 0 skipped_count = 0 for eco_customer in economic_customers: customer_number = eco_customer.get('customerNumber') cvr = eco_customer.get('corporateIdentificationNumber') name = eco_customer.get('name', '').strip() address = eco_customer.get('address', '') city = eco_customer.get('city', '') zip_code = eco_customer.get('zip', '') country = eco_customer.get('country', 'DK') email = eco_customer.get('email', '') website = eco_customer.get('website', '') if not customer_number or not name: skipped_count += 1 continue # Clean CVR if cvr: cvr = re.sub(r'\D', '', str(cvr))[:8] if len(cvr) != 8: cvr = None # Clean country code (max 2 chars for ISO codes) if country: country = str(country).strip().upper()[:2] if not country: country = 'DK' else: country = 'DK' # Extract email domain email_domain = email.split('@')[-1] if '@' in email else None # Check if customer exists by economic_customer_number OR CVR existing = execute_query( "SELECT id FROM customers WHERE economic_customer_number = %s", (customer_number,) ) # If not found by customer number, try CVR (to avoid duplicates) if not existing and cvr: existing = execute_query( "SELECT id FROM customers WHERE cvr_number = %s", (cvr,) ) if existing: # Update existing customer update_query = """ UPDATE customers SET name = %s, cvr_number = %s, email_domain = %s, city = %s, postal_code = %s, country = %s, website = %s, last_synced_at = NOW() WHERE id = %s """ execute_query(update_query, ( name, cvr, email_domain, city, zip_code, country, website, existing[0]['id'] )) updated_count += 1 logger.info(f"✏️ Opdateret: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})") else: # Create new customer from e-conomic insert_query = """ INSERT INTO customers (name, economic_customer_number, cvr_number, email_domain, city, postal_code, country, website, last_synced_at) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) RETURNING id """ result = execute_query(insert_query, ( name, customer_number, cvr, email_domain, city, zip_code, country, website )) if result: created_count += 1 logger.info(f"✨ Oprettet: {name} (e-conomic #{customer_number}, CVR: {cvr or 'ingen'})") else: skipped_count += 1 logger.info(f"✅ e-conomic sync fuldført: {created_count} oprettet, {updated_count} opdateret, {skipped_count} sprunget over af {len(economic_customers)} totalt") return { "status": "success", "created": created_count, "updated": updated_count, "skipped": skipped_count, "total_processed": len(economic_customers) } 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...") from app.services.economic_service import EconomicService economic = EconomicService() # 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") found_count = 0 linked_count = 0 for customer in customers: cvr = customer['cvr_number'] # Search e-conomic for this CVR eco_customer = await economic.search_customer_by_cvr(cvr) if eco_customer: customer_number = eco_customer.get('customerNumber') if customer_number: # Update Hub customer execute_query( "UPDATE customers SET economic_customer_number = %s, last_synced_at = NOW() WHERE id = %s", (customer_number, customer['id']) ) found_count += 1 linked_count += 1 logger.info(f"✅ Fundet og linket: {customer['name']} (CVR: {cvr}) → e-conomic kunde #{customer_number}") else: found_count += 1 logger.warning(f"⚠️ Fundet men mangler kundenummer: {customer['name']} (CVR: {cvr})") logger.info(f"✅ CVR søgning fuldført: {found_count} fundet, {linked_count} linket af {len(customers)} kontrolleret") 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))