2025-12-19 13:09:42 +01:00
"""
System Sync Router
API endpoints for syncing data between vTiger , e - conomic and Hub
2025-12-24 10:34:13 +01:00
SYNC ARCHITECTURE - Field Ownership :
== == == == == == == == == == == == == == == == == == =
E - CONOMIC owns and syncs :
- economic_customer_number ( primary key from e - conomic )
- address , city , postal_code , country ( physical address )
- email_domain , website ( contact information )
- cvr_number ( used for matching only , not overwritten if already set )
vTIGER owns and syncs :
- vtiger_id ( primary key from vTiger )
- vtiger_account_no
- Contact records and contact - company relationships
HUB owns ( manual or first - sync only ) :
- name ( can be synced initially but not overwritten )
- cvr_number ( used for matching , set once )
- Tags , notes , custom fields
SYNC RULES :
== == == == == =
1. NEVER overwrite source ID if already set ( vtiger_id , economic_customer_number )
2026-02-22 03:27:40 +01:00
2. Matching is source - specific ( e - conomic : strict economic_customer_number only )
2025-12-24 10:34:13 +01:00
3. Re - sync is idempotent - can run multiple times safely
4. Contact relationships are REPLACED on sync ( not added )
5. Each sync only updates fields it owns
2025-12-19 13:09:42 +01:00
"""
import logging
2026-05-04 16:24:38 +02:00
from fastapi import APIRouter , Depends , HTTPException , Body
2025-12-19 13:09:42 +01:00
from typing import Dict , Any
from app . core . database import execute_query
2026-03-07 02:39:57 +01:00
from app . core . auth_dependencies import require_any_permission
2025-12-19 13:09:42 +01:00
from app . services . vtiger_service import get_vtiger_service
2026-05-02 11:02:29 +02:00
import aiohttp
2025-12-19 13:09:42 +01:00
import re
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
2026-03-07 02:39:57 +01:00
sync_admin_access = require_any_permission ( " users.manage " , " system.admin " )
2025-12-19 13:09:42 +01:00
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 ( )
2026-05-04 16:24:38 +02:00
def _normalize_sync_value ( value : Any ) - > str :
""" Normalize values so dry-run change detection is stable across None/empty/whitespace. """
if value is None :
return " "
return str ( value ) . strip ( )
def _build_customer_change_set ( current_values : Dict [ str , Any ] , new_values : Dict [ str , Any ] ) - > Dict [ str , Dict [ str , str ] ] :
""" Return only fields that would actually change on update. """
changes : Dict [ str , Dict [ str , str ] ] = { }
for field , new_value in new_values . items ( ) :
current_value = current_values . get ( field )
if _normalize_sync_value ( current_value ) != _normalize_sync_value ( new_value ) :
changes [ field ] = {
" from " : _normalize_sync_value ( current_value ) ,
" to " : _normalize_sync_value ( new_value ) ,
}
return changes
2026-05-02 11:02:29 +02:00
async def _economic_sync_apply_or_preview ( apply_changes : bool ) - > Dict [ str , Any ] :
"""
Build economic sync plan and optionally apply changes .
If apply_changes = False the function does a pure dry - run and returns a detailed plan .
If apply_changes = True it performs the same plan as DB writes .
"""
from app . services . economic_service import EconomicService
economic = EconomicService ( )
# Preflight check so UI gets a clear reason instead of a silent zero-result dry-run.
try :
async with aiohttp . ClientSession ( ) as session :
async with session . get (
f " { economic . api_url } /self " ,
headers = economic . _get_headers ( ) ,
) as preflight_response :
if preflight_response . status != 200 :
raw = await preflight_response . text ( )
logger . error ( " ❌ e-conomic preflight failed: %s - %s " , preflight_response . status , raw )
detail = f " e-conomic forbindelse fejlede ( { preflight_response . status } ) "
try :
payload = await preflight_response . json ( content_type = None )
message = payload . get ( " message " ) if isinstance ( payload , dict ) else None
code = payload . get ( " errorCode " ) if isinstance ( payload , dict ) else None
if code == " AgreementDeregistered " :
detail = (
" e-conomic aftalen er afregistreret (AgreementDeregistered). "
" Opret nyt grant token i e-conomic, opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env "
" og genstart API containeren. "
)
elif code == " InvalidGrantToken " :
detail = (
" e-conomic grant token er ugyldigt (InvalidGrantToken). "
" Opdater ECONOMIC_AGREEMENT_GRANT_TOKEN i .env og genstart API containeren. "
)
elif message and code :
detail = f " e-conomic forbindelse fejlede: { message } ( { code } ) "
elif message :
detail = f " e-conomic forbindelse fejlede: { message } "
except Exception :
pass
raise HTTPException ( status_code = 502 , detail = detail )
except ValueError as e :
raise HTTPException ( status_code = 400 , detail = str ( e ) )
except HTTPException :
raise
except Exception as e :
logger . error ( " ❌ e-conomic preflight exception: %s " , e )
raise HTTPException ( status_code = 502 , detail = " Kunne ikke kontakte e-conomic API " )
# 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 , strict = True )
if not customers :
break
all_customers . extend ( customers )
page + = 1
if len ( customers ) < 1000 :
break
economic_customers = all_customers
logger . info ( " 📥 Fetched %s customers from e-conomic ( %s pages) " , len ( economic_customers ) , page )
created_count = 0
updated_count = 0
skipped_count = 0
conflict_count = 0
would_create : list [ Dict [ str , Any ] ] = [ ]
would_update : list [ Dict [ str , Any ] ] = [ ]
conflicts : list [ Dict [ str , Any ] ] = [ ]
skipped : list [ Dict [ str , Any ] ] = [ ]
for eco_customer in economic_customers :
customer_number_raw = eco_customer . get ( ' customerNumber ' )
customer_number = str ( customer_number_raw ) . strip ( ) if customer_number_raw is not None else None
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
skipped . append ( {
" economic_customer_number " : customer_number ,
" name " : name or " (mangler navn) " ,
" reason " : " Manglende kundenummer eller navn "
} )
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 '
email_domain = email . split ( ' @ ' ) [ - 1 ] if ' @ ' in email else None
# Strict matching: ONLY match by economic_customer_number
existing = execute_query (
2026-05-04 16:24:38 +02:00
"""
SELECT id , name , email_domain , address , city , postal_code , country , website
FROM customers
WHERE economic_customer_number = % s
ORDER BY id
""" ,
2026-05-02 11:02:29 +02:00
( customer_number , )
)
if len ( existing ) > 1 :
conflict_count + = 1
skipped_count + = 1
duplicate_ids = [ row [ ' id ' ] for row in existing ]
conflicts . append ( {
" economic_customer_number " : customer_number ,
" name " : name ,
" duplicate_customer_ids " : duplicate_ids ,
" reason " : " Flere lokale kunder har samme e-conomic kundenummer "
} )
logger . error (
" ❌ Konflikt: e-conomic # %s matcher %s lokale kunder (ids: %s ) - springer over " ,
customer_number ,
len ( existing ) ,
" , " . join ( str ( i ) for i in duplicate_ids )
)
continue
if existing :
target_customer_id = existing [ 0 ] [ ' id ' ]
2026-05-04 16:24:38 +02:00
current_values = {
" email_domain " : existing [ 0 ] . get ( " email_domain " ) ,
" address " : existing [ 0 ] . get ( " address " ) ,
" city " : existing [ 0 ] . get ( " city " ) ,
" postal_code " : existing [ 0 ] . get ( " postal_code " ) ,
" country " : existing [ 0 ] . get ( " country " ) ,
" website " : existing [ 0 ] . get ( " website " ) ,
}
proposed_values = {
" email_domain " : email_domain ,
" address " : address ,
" city " : city ,
" postal_code " : zip_code ,
" country " : country ,
" website " : website ,
}
changes = _build_customer_change_set ( current_values , proposed_values )
2026-05-02 11:02:29 +02:00
would_update . append ( {
" customer_id " : target_customer_id ,
" customer_name " : existing [ 0 ] . get ( ' name ' ) ,
" economic_customer_number " : customer_number ,
2026-05-04 16:24:38 +02:00
" current_values " : current_values ,
" new_values " : proposed_values ,
" changes " : changes ,
" has_changes " : len ( changes ) > 0 ,
2026-05-02 11:02:29 +02:00
} )
if apply_changes :
update_query = """
UPDATE customers SET
economic_customer_number = % s ,
email_domain = % s ,
address = % s ,
city = % s ,
postal_code = % s ,
country = % s ,
website = % s ,
last_synced_at = NOW ( )
WHERE id = % s
"""
execute_query ( update_query , (
customer_number , email_domain , address , city , zip_code , country , website , target_customer_id
) )
logger . info (
" ✏️ Opdateret lokal kunde id= %s : %s (e-conomic # %s , CVR: %s ) " ,
target_customer_id ,
name ,
customer_number ,
cvr or ' ingen '
)
updated_count + = 1
else :
would_create . append ( {
" name " : name ,
" economic_customer_number " : customer_number ,
" cvr_number " : cvr ,
" email_domain " : email_domain ,
" address " : address ,
" city " : city ,
" postal_code " : zip_code ,
" country " : country ,
" website " : website ,
} )
if apply_changes :
insert_query = """
INSERT INTO customers
( name , economic_customer_number , cvr_number , email_domain ,
address , city , postal_code , country , website , last_synced_at )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , NOW ( ) )
RETURNING id
"""
result = execute_query ( insert_query , (
name , customer_number , cvr , email_domain , address , city , zip_code , country , website
) )
if result :
logger . info (
" ✨ Oprettet lokal kunde id= %s : %s (e-conomic # %s , CVR: %s ) " ,
result [ 0 ] [ ' id ' ] ,
name ,
customer_number ,
cvr or ' ingen '
)
else :
skipped_count + = 1
skipped . append ( {
" economic_customer_number " : customer_number ,
" name " : name ,
" reason " : " Insert RETURNING gav tomt resultat "
} )
continue
created_count + = 1
logger . info (
" ✅ e-conomic %s fuldført: %s oprettet, %s opdateret, %s konflikter, %s sprunget over af %s totalt " ,
" sync " if apply_changes else " dry-run " ,
created_count ,
updated_count ,
conflict_count ,
skipped_count ,
len ( economic_customers )
)
return {
" status " : " success " ,
" mode " : " apply " if apply_changes else " dry_run " ,
" created " : created_count ,
" updated " : updated_count ,
" conflicts " : conflict_count ,
" skipped " : skipped_count ,
" total_processed " : len ( economic_customers ) ,
" contacts " : {
" supported " : False ,
" message " : " Kontakt-sync fra e-conomic er ikke implementeret endnu i denne pipeline. Dry-run dækker firmaer. "
} ,
" preview " : {
" would_create " : would_create [ : 25 ] ,
" would_update " : would_update [ : 25 ] ,
" conflicts " : conflicts [ : 25 ] ,
" skipped " : skipped [ : 25 ] ,
" truncated " : {
" would_create_total " : len ( would_create ) ,
" would_update_total " : len ( would_update ) ,
" conflicts_total " : len ( conflicts ) ,
" skipped_total " : len ( skipped ) ,
" limit_per_list " : 25
}
}
}
2025-12-19 13:09:42 +01:00
@router.post ( " /sync/vtiger " )
2026-03-07 02:39:57 +01:00
async def sync_from_vtiger ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
2025-12-19 13:09:42 +01:00
"""
2025-12-22 13:02:24 +01:00
Link vTiger accounts to existing Hub customers
Matches by CVR or normalized name , updates vtiger_id
2025-12-19 13:09:42 +01:00
"""
try :
2025-12-22 13:02:24 +01:00
logger . info ( " 🔄 Starting vTiger link sync... " )
2025-12-19 13:09:42 +01:00
vtiger = get_vtiger_service ( )
2025-12-22 12:59:12 +01:00
# Fetch ALL accounts - vTiger query API doesn't support LIMIT/OFFSET
# Instead, use recursive queries based on ID to get all records
2025-12-22 12:53:11 +01:00
all_accounts = [ ]
2025-12-22 12:59:12 +01:00
last_id = None
batch_size = 100 # vTiger typically returns ~100 records per query
2025-12-19 13:09:42 +01:00
2025-12-22 12:53:11 +01:00
while True :
2025-12-22 12:59:12 +01:00
# 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 } ; "
2025-12-22 12:53:11 +01:00
batch = await vtiger . query ( query )
if not batch or len ( batch ) == 0 :
break
all_accounts . extend ( batch )
2025-12-22 12:59:12 +01:00
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 ) } ) " )
2025-12-22 12:53:11 +01:00
# 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
2025-12-19 13:09:42 +01:00
2025-12-22 13:02:24 +01:00
linked_count = 0
2025-12-19 13:09:42 +01:00
updated_count = 0
2025-12-22 13:02:24 +01:00
not_found_count = 0
2025-12-19 13:09:42 +01:00
for account in accounts :
vtiger_id = account . get ( ' id ' )
name = account . get ( ' accountname ' , ' ' ) . strip ( )
cvr = account . get ( ' cf_accounts_cvr ' ) or account . get ( ' siccode ' )
2025-12-22 13:02:24 +01:00
if not name or not vtiger_id :
not_found_count + = 1
2025-12-19 13:09:42 +01:00
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
2025-12-22 13:02:24 +01:00
# Find existing Hub customer by CVR or normalized name
2025-12-19 13:09:42 +01:00
existing = None
2025-12-22 14:13:44 +01:00
match_method = None
2025-12-22 13:02:24 +01:00
if cvr :
2025-12-19 13:09:42 +01:00
existing = execute_query (
2025-12-22 13:02:24 +01:00
" SELECT id, name, vtiger_id FROM customers WHERE cvr_number = %s " ,
2025-12-19 13:09:42 +01:00
( cvr , )
)
2025-12-22 14:13:44 +01:00
if existing :
match_method = " CVR "
2025-12-19 13:09:42 +01:00
if not existing :
# Match by normalized name
normalized = normalize_name ( name )
2025-12-22 13:02:24 +01:00
all_customers = execute_query ( " SELECT id, name, vtiger_id FROM customers " )
2025-12-19 13:09:42 +01:00
for customer in all_customers :
if normalize_name ( customer [ ' name ' ] ) == normalized :
existing = [ customer ]
2025-12-22 14:13:44 +01:00
match_method = " navn "
2025-12-19 13:09:42 +01:00
break
if existing :
2025-12-22 13:02:24 +01:00
# Link vTiger ID to existing customer
current_vtiger_id = existing [ 0 ] . get ( ' vtiger_id ' )
2025-12-22 14:15:17 +01:00
# Check if this vtiger_id is already assigned to another customer
vtiger_owner = execute_query (
" SELECT id, name FROM customers WHERE vtiger_id = %s " ,
( vtiger_id , )
)
if vtiger_owner and vtiger_owner [ 0 ] [ ' id ' ] != existing [ 0 ] [ ' id ' ] :
# vTiger ID already belongs to another customer - skip
logger . warning ( f " ⚠️ vTiger # { vtiger_id } allerede tilknyttet ' { vtiger_owner [ 0 ] [ ' name ' ] } ' - springer over ' { existing [ 0 ] [ ' name ' ] } ' " )
not_found_count + = 1
continue
2025-12-22 13:02:24 +01:00
if current_vtiger_id is None :
2025-12-24 10:34:13 +01:00
# Only set vtiger_id if it's currently NULL
2025-12-22 13:02:24 +01:00
execute_query (
" UPDATE customers SET vtiger_id = %s , last_synced_at = NOW() WHERE id = %s " ,
( vtiger_id , existing [ 0 ] [ ' id ' ] )
)
linked_count + = 1
2025-12-22 14:13:44 +01:00
logger . info ( f " 🔗 Linket: { existing [ 0 ] [ ' name ' ] } → vTiger # { vtiger_id } (match: { match_method } , CVR: { cvr or ' ingen ' } ) " )
2025-12-22 13:02:24 +01:00
elif current_vtiger_id != vtiger_id :
2025-12-24 10:34:13 +01:00
# SKIP if different vTiger ID - do NOT overwrite existing vtiger_id
logger . warning ( f " ⚠️ Springer over: { existing [ 0 ] [ ' name ' ] } har allerede vTiger # { current_vtiger_id } , vil ikke overskrive med # { vtiger_id } " )
not_found_count + = 1
continue
2025-12-22 13:02:24 +01:00
else :
# Already linked, just update timestamp
execute_query ( " UPDATE customers SET last_synced_at = NOW() WHERE id = %s " , ( existing [ 0 ] [ ' id ' ] , ) )
2025-12-19 13:09:42 +01:00
else :
2025-12-22 13:02:24 +01:00
not_found_count + = 1
2025-12-22 14:13:44 +01:00
logger . debug ( f " ⏭️ Ikke fundet i Hub: ' { name } ' (CVR: { cvr or ' ingen ' } , normalized: ' { normalize_name ( name ) } ' ) " )
2025-12-19 13:09:42 +01:00
2025-12-22 13:02:24 +01:00
logger . info ( f " ✅ vTiger link sync fuldført: { linked_count } nye linket, { updated_count } opdateret, { not_found_count } ikke fundet af { len ( accounts ) } totalt " )
2025-12-19 13:09:42 +01:00
return {
" status " : " success " ,
2025-12-22 13:02:24 +01:00
" linked " : linked_count ,
2025-12-19 13:09:42 +01:00
" updated " : updated_count ,
2025-12-22 13:02:24 +01:00
" not_found " : not_found_count ,
2025-12-19 13:09:42 +01:00
" 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 " )
2026-03-07 02:39:57 +01:00
async def sync_vtiger_contacts ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
2025-12-19 13:09:42 +01:00
"""
2025-12-22 15:05:40 +01:00
SIMPEL TILGANG - Sync contacts from vTiger and link to customers
Step 1 : Fetch all contacts from vTiger
Step 2 : Create / update contact in Hub
Step 3 : Link to customer IF account_id matches customer . vtiger_id
2025-12-19 13:09:42 +01:00
"""
try :
2025-12-22 15:05:40 +01:00
logger . info ( " 🔄 Starting vTiger contacts sync (SIMPEL VERSION)... " )
2025-12-19 13:09:42 +01:00
vtiger = get_vtiger_service ( )
2025-12-22 15:05:40 +01:00
# ===== STEP 1: FETCH ALL CONTACTS =====
logger . info ( " 📥 STEP 1: Fetching contacts from vTiger... " )
2025-12-22 13:24:41 +01:00
all_contacts = [ ]
last_id = None
batch_size = 100
while True :
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 ' )
2025-12-22 15:05:40 +01:00
logger . info ( f " → Batch: { len ( batch ) } contacts (ID: { last_id } , total: { len ( all_contacts ) } ) " )
2025-12-22 13:24:41 +01:00
if len ( batch ) < batch_size :
break
2025-12-19 13:09:42 +01:00
2025-12-22 15:05:40 +01:00
logger . info ( f " ✅ STEP 1 DONE: { len ( all_contacts ) } contacts fetched " )
# ===== STEP 2 & 3: PROCESS EACH CONTACT =====
logger . info ( " 🔄 STEP 2 & 3: Creating/updating contacts and linking to customers... " )
2025-12-19 13:09:42 +01:00
created_count = 0
updated_count = 0
skipped_count = 0
2025-12-22 13:24:41 +01:00
linked_count = 0
2025-12-22 15:20:29 +01:00
customers_created_count = 0
2025-12-22 15:05:40 +01:00
debug_count = 0
2025-12-19 13:09:42 +01:00
2025-12-22 15:05:40 +01:00
for i , contact in enumerate ( all_contacts , 1 ) :
vtiger_id = contact . get ( ' id ' )
first_name = ( contact . get ( ' firstname ' ) or ' ' ) . strip ( )
last_name = ( contact . get ( ' lastname ' ) or ' ' ) . strip ( )
email = contact . get ( ' email ' )
account_id = contact . get ( ' account_id ' ) # This is the vTiger Account ID
# Show progress every 100 contacts
if i % 100 == 0 :
logger . info ( f " → Progress: { i } / { len ( all_contacts ) } contacts processed... " )
2025-12-19 13:09:42 +01:00
2025-12-22 15:05:40 +01:00
# Must have name
if not first_name and not last_name :
2025-12-19 13:09:42 +01:00
skipped_count + = 1
continue
2025-12-22 15:05:40 +01:00
# --- STEP 2A: Check if contact exists ---
existing = execute_query (
" SELECT id FROM contacts WHERE vtiger_id = %s " ,
( vtiger_id , )
) if vtiger_id else None
2025-12-19 13:09:42 +01:00
2025-12-22 15:05:40 +01:00
# --- STEP 2B: Create or update contact ---
contact_id = None
2025-12-19 13:09:42 +01:00
if existing :
2025-12-22 15:05:40 +01:00
# UPDATE existing
execute_query ( """
2025-12-19 13:09:42 +01:00
UPDATE contacts
2025-12-22 15:05:40 +01:00
SET first_name = % s , last_name = % s , email = % s ,
phone = % s , mobile = % s , title = % s , department = % s ,
updated_at = NOW ( )
2025-12-19 13:09:42 +01:00
WHERE id = % s
2025-12-22 15:05:40 +01:00
""" , (
first_name , last_name , email ,
contact . get ( ' phone ' ) , contact . get ( ' mobile ' ) ,
contact . get ( ' title ' ) , contact . get ( ' department ' ) ,
2025-12-19 13:09:42 +01:00
existing [ 0 ] [ ' id ' ]
) )
contact_id = existing [ 0 ] [ ' id ' ]
2025-12-22 15:05:40 +01:00
updated_count + = 1
2025-12-19 13:09:42 +01:00
else :
2025-12-22 15:05:40 +01:00
# CREATE new
result = execute_query ( """
2025-12-19 13:09:42 +01:00
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
2025-12-22 15:05:40 +01:00
""" , (
first_name , last_name , email ,
contact . get ( ' phone ' ) , contact . get ( ' mobile ' ) ,
contact . get ( ' title ' ) , contact . get ( ' department ' ) ,
vtiger_id
2025-12-19 13:09:42 +01:00
) )
2025-12-22 15:05:40 +01:00
if result and len ( result ) > 0 :
2025-12-19 13:09:42 +01:00
contact_id = result [ 0 ] [ ' id ' ]
2025-12-22 15:05:40 +01:00
created_count + = 1
2025-12-19 13:09:42 +01:00
else :
skipped_count + = 1
continue
2025-12-22 15:05:40 +01:00
# --- DEBUG LOGGING FOR FIRST 20 CONTACTS ---
if debug_count < 20 :
logger . warning ( f " 🔍 DEBUG { debug_count + 1 } : Contact ' { first_name } { last_name } ' (vtiger_id= { vtiger_id } , contact_id= { contact_id } , account_id= { account_id } ) " )
debug_count + = 1
2025-12-22 14:34:07 +01:00
2025-12-22 15:05:40 +01:00
# --- STEP 3: LINK TO CUSTOMER ---
2025-12-22 14:41:44 +01:00
if not account_id :
2025-12-22 15:05:40 +01:00
# No account_id means contact is not linked to any account in vTiger
if debug_count < = 20 :
logger . warning ( f " ↳ No account_id - cannot link " )
2025-12-22 14:41:44 +01:00
continue
2025-12-22 15:05:40 +01:00
# Find Hub customer with matching vtiger_id
customer_result = execute_query (
2025-12-22 14:41:44 +01:00
" SELECT id, name FROM customers WHERE vtiger_id = %s " ,
( account_id , )
)
2025-12-22 15:05:40 +01:00
if not customer_result or len ( customer_result ) == 0 :
2025-12-22 15:20:29 +01:00
# FALLBACK: Create customer from vTiger account if not found
try :
account_query = f " SELECT id, accountname, email1, siccode, cf_accounts_cvr, website, bill_city, bill_code, bill_country FROM Accounts WHERE id = ' { account_id } ' ; "
account_data = await vtiger . query ( account_query )
if account_data and len ( account_data ) > 0 :
account = account_data [ 0 ]
account_name = account . get ( ' accountname ' , ' ' ) . strip ( )
cvr = account . get ( ' cf_accounts_cvr ' ) or account . get ( ' siccode ' )
if cvr :
cvr = re . sub ( r ' \ D ' , ' ' , str ( cvr ) ) [ : 8 ]
if len ( cvr ) != 8 :
cvr = None
# Check if customer already exists by CVR (to avoid duplicates)
if cvr :
existing_by_cvr = execute_query (
" SELECT id, name FROM customers WHERE cvr_number = %s " ,
( cvr , )
)
if existing_by_cvr :
# Found by CVR - update vtiger_id and use this customer
execute_query (
" UPDATE customers SET vtiger_id = %s , last_synced_at = NOW() WHERE id = %s " ,
( account_id , existing_by_cvr [ 0 ] [ ' id ' ] )
)
customer_id = existing_by_cvr [ 0 ] [ ' id ' ]
customer_name = existing_by_cvr [ 0 ] [ ' name ' ]
logger . info ( f " ✅ Matched by CVR and updated vtiger_id: { customer_name } → { account_id } " )
else :
# Create new customer from vTiger
country = ( account . get ( ' bill_country ' ) or ' DK ' ) . strip ( ) . upper ( ) [ : 2 ]
new_customer = execute_query ( """
INSERT INTO customers
( name , cvr_number , email , website , city , postal_code , country , vtiger_id , last_synced_at )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , NOW ( ) )
RETURNING id , name
""" , (
account_name ,
cvr ,
account . get ( ' email1 ' ) ,
account . get ( ' website ' ) ,
account . get ( ' bill_city ' ) ,
account . get ( ' bill_code ' ) ,
country ,
account_id
) )
if new_customer :
customer_id = new_customer [ 0 ] [ ' id ' ]
customer_name = new_customer [ 0 ] [ ' name ' ]
customers_created_count + = 1
logger . info ( f " ✨ Created customer from vTiger: { customer_name } (vtiger_id= { account_id } ) " )
else :
if debug_count < = 20 :
logger . warning ( f " ↳ Failed to create customer from account_id= { account_id } " )
continue
else :
# No CVR - create customer anyway (might be foreign company)
country = ( account . get ( ' bill_country ' ) or ' DK ' ) . strip ( ) . upper ( ) [ : 2 ]
new_customer = execute_query ( """
INSERT INTO customers
( name , email , website , city , postal_code , country , vtiger_id , last_synced_at )
VALUES ( % s , % s , % s , % s , % s , % s , % s , NOW ( ) )
RETURNING id , name
""" , (
account_name ,
account . get ( ' email1 ' ) ,
account . get ( ' website ' ) ,
account . get ( ' bill_city ' ) ,
account . get ( ' bill_code ' ) ,
country ,
account_id
) )
if new_customer :
customer_id = new_customer [ 0 ] [ ' id ' ]
customer_name = new_customer [ 0 ] [ ' name ' ]
customers_created_count + = 1
logger . info ( f " ✨ Created customer from vTiger (no CVR): { customer_name } (vtiger_id= { account_id } ) " )
else :
if debug_count < = 20 :
logger . warning ( f " ↳ Failed to create customer from account_id= { account_id } " )
continue
else :
if debug_count < = 20 :
logger . warning ( f " ↳ Account not found in vTiger: { account_id } " )
continue
except Exception as e :
logger . error ( f " ❌ Failed to fetch/create customer from vTiger account { account_id } : { e } " )
continue
else :
customer_id = customer_result [ 0 ] [ ' id ' ]
customer_name = customer_result [ 0 ] [ ' name ' ]
2025-12-22 15:05:40 +01:00
2025-12-24 10:34:13 +01:00
# DELETE existing links for this contact (we replace, not append)
# This ensures re-sync updates links to match current vTiger state
execute_query (
" DELETE FROM contact_companies WHERE contact_id = %s " ,
( contact_id , )
2025-12-22 15:05:40 +01:00
)
2025-12-24 10:34:13 +01:00
# CREATE new link from vTiger
2025-12-22 15:05:40 +01:00
execute_query (
" INSERT INTO contact_companies (contact_id, customer_id, is_primary) VALUES ( %s , %s , false) " ,
( contact_id , customer_id )
)
linked_count + = 1
if linked_count < = 10 : # Log first 10 successful links
logger . info ( f " 🔗 LINKED: { first_name } { last_name } → { customer_name } " )
2025-12-19 13:09:42 +01:00
2025-12-22 15:20:29 +01:00
logger . info ( f " ✅ SYNC COMPLETE: created= { created_count } , updated= { updated_count } , linked= { linked_count } , customers_created= { customers_created_count } , skipped= { skipped_count } , total= { len ( all_contacts ) } " )
2025-12-19 13:09:42 +01:00
return {
" status " : " success " ,
" created " : created_count ,
" updated " : updated_count ,
2025-12-22 13:24:41 +01:00
" linked " : linked_count ,
2025-12-22 15:20:29 +01:00
" customers_created " : customers_created_count ,
2025-12-19 13:09:42 +01:00
" skipped " : skipped_count ,
2025-12-22 15:05:40 +01:00
" total_processed " : len ( all_contacts )
2025-12-19 13:09:42 +01:00
}
except Exception as e :
2025-12-22 15:05:40 +01:00
logger . error ( f " ❌ vTiger contacts sync error: { e } " , exc_info = True )
2025-12-19 13:09:42 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.post ( " /sync/economic " )
2026-03-07 02:39:57 +01:00
async def sync_from_economic ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
2025-12-19 13:09:42 +01:00
"""
2025-12-22 13:02:24 +01:00
Sync customers from e - conomic ( PRIMARY SOURCE )
Creates / updates Hub customers with e - conomic data
2025-12-19 13:09:42 +01:00
"""
try :
2025-12-22 13:02:24 +01:00
logger . info ( " 🔄 Starting e-conomic customer sync (PRIMARY SOURCE)... " )
2026-05-02 11:02:29 +02:00
result = await _economic_sync_apply_or_preview ( apply_changes = True )
return result
except HTTPException :
raise
2025-12-19 13:09:42 +01:00
except Exception as e :
logger . error ( f " ❌ e-conomic sync error: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-05-02 11:02:29 +02:00
@router.post ( " /sync/economic/dry-run " )
@router.post ( " /sync/economic/dryrun " )
@router.post ( " /sync/economic/preview " )
async def sync_from_economic_dry_run ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
"""
Dry - run for e - conomic customer sync .
No database writes are performed ; returns a preview of intended actions .
"""
try :
logger . info ( " 🧪 Starting e-conomic customer sync DRY-RUN preview... " )
result = await _economic_sync_apply_or_preview ( apply_changes = False )
return result
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ e-conomic dry-run sync error: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-05-04 16:24:38 +02:00
@router.post ( " /sync/economic/apply-update " )
async def apply_single_economic_update (
payload : Dict [ str , Any ] = Body ( . . . ) ,
current_user : dict = Depends ( sync_admin_access )
) - > Dict [ str , Any ] :
"""
Apply one selected dry - run customer update with optional field overrides .
This lets admins adjust values on - the - fly from the preview UI .
"""
customer_id = payload . get ( " customer_id " )
economic_customer_number = payload . get ( " economic_customer_number " )
new_values = payload . get ( " new_values " ) or { }
overrides = payload . get ( " overrides " ) or { }
try :
customer_id = int ( customer_id )
except ( TypeError , ValueError ) :
raise HTTPException ( status_code = 400 , detail = " Ugyldigt customer_id " )
if not isinstance ( new_values , dict ) :
raise HTTPException ( status_code = 400 , detail = " new_values skal være et objekt " )
if not isinstance ( overrides , dict ) :
raise HTTPException ( status_code = 400 , detail = " overrides skal være et objekt " )
allowed_fields = { " email_domain " , " address " , " city " , " postal_code " , " country " , " website " }
invalid_override_fields = [ field for field in overrides . keys ( ) if field not in allowed_fields ]
if invalid_override_fields :
raise HTTPException (
status_code = 400 ,
detail = f " Ugyldige override felter: { ' , ' . join ( sorted ( invalid_override_fields ) ) } "
)
customer = execute_query (
"""
SELECT id , name , economic_customer_number , email_domain , address , city , postal_code , country , website
FROM customers
WHERE id = % s
""" ,
( customer_id , )
)
if not customer :
raise HTTPException ( status_code = 404 , detail = " Kunde ikke fundet " )
customer = customer [ 0 ]
if economic_customer_number and str ( customer . get ( " economic_customer_number " ) or " " ) != str ( economic_customer_number ) :
raise HTTPException (
status_code = 409 ,
detail = " Kundens e-conomic nummer matcher ikke preview-data. Kør dry-run igen. "
)
final_values : Dict [ str , Any ] = { }
for field in allowed_fields :
if field in overrides :
value = overrides . get ( field )
elif field in new_values :
value = new_values . get ( field )
else :
value = customer . get ( field )
normalized = _normalize_sync_value ( value )
if field == " country " :
normalized = ( normalized . upper ( ) [ : 2 ] or " DK " )
final_values [ field ] = normalized
update_query = """
UPDATE customers SET
email_domain = % s ,
address = % s ,
city = % s ,
postal_code = % s ,
country = % s ,
website = % s ,
last_synced_at = NOW ( )
WHERE id = % s
"""
execute_query (
update_query ,
(
final_values [ " email_domain " ] ,
final_values [ " address " ] ,
final_values [ " city " ] ,
final_values [ " postal_code " ] ,
final_values [ " country " ] ,
final_values [ " website " ] ,
customer_id ,
)
)
applied_changes = _build_customer_change_set (
{
" email_domain " : customer . get ( " email_domain " ) ,
" address " : customer . get ( " address " ) ,
" city " : customer . get ( " city " ) ,
" postal_code " : customer . get ( " postal_code " ) ,
" country " : customer . get ( " country " ) ,
" website " : customer . get ( " website " ) ,
} ,
final_values ,
)
logger . info (
" ✅ Applied on-the-fly e-conomic update for customer id= %s ( %s ), changed_fields= %s " ,
customer_id ,
customer . get ( " name " ) ,
" , " . join ( sorted ( applied_changes . keys ( ) ) ) or " none " ,
)
return {
" status " : " success " ,
" customer_id " : customer_id ,
" economic_customer_number " : customer . get ( " economic_customer_number " ) ,
" applied_changes " : applied_changes ,
" final_values " : final_values ,
}
2025-12-19 13:09:42 +01:00
@router.post ( " /sync/cvr-to-economic " )
2026-03-07 02:39:57 +01:00
async def sync_cvr_to_economic ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
2025-12-19 13:09:42 +01:00
"""
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... " )
2025-12-19 16:41:11 +01:00
from app . services . economic_service import EconomicService
economic = EconomicService ( )
2025-12-19 13:09:42 +01:00
# 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 " )
2025-12-19 16:41:11 +01:00
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 } ) " )
2025-12-19 13:09:42 +01:00
2025-12-19 16:41:11 +01:00
logger . info ( f " ✅ CVR søgning fuldført: { found_count } fundet, { linked_count } linket af { len ( customers ) } kontrolleret " )
2025-12-19 13:09:42 +01:00
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 ) )
2025-12-22 15:14:31 +01:00
@router.get ( " /sync/diagnostics " )
2026-03-07 02:39:57 +01:00
async def sync_diagnostics ( current_user : dict = Depends ( sync_admin_access ) ) - > Dict [ str , Any ] :
2025-12-22 15:14:31 +01:00
"""
Diagnostics : Check contact linking coverage
Shows why contacts aren ' t linking to customers
"""
try :
logger . info ( " 🔍 Running sync diagnostics... " )
# Check Hub data
hub_stats = execute_query ( """
SELECT
COUNT ( * ) as total_customers ,
COUNT ( vtiger_id ) as customers_with_vtiger
FROM customers
""" )
vtiger_ids_in_hub = execute_query (
" SELECT vtiger_id FROM customers WHERE vtiger_id IS NOT NULL "
)
hub_vtiger_set = set ( row [ ' vtiger_id ' ] for row in vtiger_ids_in_hub )
contact_stats = execute_query ( """
SELECT
COUNT ( * ) as total_contacts ,
COUNT ( vtiger_id ) as contacts_with_vtiger
FROM contacts
""" )
link_stats = execute_query ( """
SELECT COUNT ( * ) as total_links
FROM contact_companies
""" )
# Fetch sample contacts from vTiger
vtiger = get_vtiger_service ( )
query = " SELECT id, firstname, lastname, account_id FROM Contacts ORDER BY id LIMIT 200; "
contacts = await vtiger . query ( query )
# Analyze
with_account = [ c for c in contacts if c . get ( ' account_id ' ) ]
account_ids = set ( c [ ' account_id ' ] for c in with_account )
matched_accounts = [ aid for aid in account_ids if aid in hub_vtiger_set ]
unmatched_accounts = [ aid for aid in account_ids if aid not in hub_vtiger_set ]
result = {
" hub " : {
" total_customers " : hub_stats [ 0 ] [ ' total_customers ' ] ,
" customers_with_vtiger_id " : hub_stats [ 0 ] [ ' customers_with_vtiger ' ] ,
" unique_vtiger_ids " : len ( hub_vtiger_set ) ,
" total_contacts " : contact_stats [ 0 ] [ ' total_contacts ' ] ,
" contacts_with_vtiger_id " : contact_stats [ 0 ] [ ' contacts_with_vtiger ' ] ,
" total_links " : link_stats [ 0 ] [ ' total_links ' ]
} ,
" vtiger_sample " : {
" sample_size " : len ( contacts ) ,
" contacts_with_account_id " : len ( with_account ) ,
" unique_account_ids " : len ( account_ids ) ,
" account_ids_found_in_hub " : len ( matched_accounts ) ,
" account_ids_missing_in_hub " : len ( unmatched_accounts ) ,
" match_rate_percent " : round ( ( len ( matched_accounts ) / len ( account_ids ) * 100 ) , 1 ) if account_ids else 0
} ,
" examples " : {
" matched_account_ids " : matched_accounts [ : 5 ] ,
" unmatched_account_ids " : unmatched_accounts [ : 10 ]
} ,
" recommendation " : " "
}
# Add recommendation
match_rate = result [ ' vtiger_sample ' ] [ ' match_rate_percent ' ]
if match_rate < 50 :
result [ ' recommendation ' ] = f " ⚠️ LOW MATCH RATE ( { match_rate } %) - Kør først /sync/vtiger for at linke flere accounts "
elif match_rate < 90 :
result [ ' recommendation ' ] = f " ⚡ MEDIUM MATCH RATE ( { match_rate } %) - Nogen accounts mangler stadig at blive linket "
else :
result [ ' recommendation ' ] = f " ✅ HIGH MATCH RATE ( { match_rate } %) - De fleste contacts kan linkes "
logger . info ( f " ✅ Diagnostics complete: { result [ ' recommendation ' ] } " )
return result
except Exception as e :
logger . error ( f " ❌ Diagnostics error: { e } " , exc_info = True )
raise HTTPException ( status_code = 500 , detail = str ( e ) )