2025-12-06 02:22:01 +01:00
"""
Customers Router
API endpoints for customer management
Adapted from OmniSync for BMC Hub
"""
from fastapi import APIRouter , HTTPException , Query
from typing import List , Optional , Dict
from pydantic import BaseModel
import logging
2025-12-16 15:36:11 +01:00
from app . core . database import execute_query , execute_query_single
2025-12-06 02:22:01 +01:00
from app . services . cvr_service import get_cvr_service
logger = logging . getLogger ( __name__ )
router = APIRouter ( )
# Pydantic Models
class CustomerBase ( BaseModel ) :
name : str
cvr_number : Optional [ str ] = None
email : Optional [ str ] = None
phone : Optional [ str ] = None
address : Optional [ str ] = None
city : Optional [ str ] = None
postal_code : Optional [ str ] = None
country : Optional [ str ] = " DK "
website : Optional [ str ] = None
is_active : Optional [ bool ] = True
invoice_email : Optional [ str ] = None
mobile_phone : Optional [ str ] = None
class CustomerCreate ( CustomerBase ) :
pass
class CustomerUpdate ( BaseModel ) :
name : Optional [ str ] = None
cvr_number : Optional [ str ] = None
email : Optional [ str ] = None
phone : Optional [ str ] = None
address : Optional [ str ] = None
city : Optional [ str ] = None
postal_code : Optional [ str ] = None
country : Optional [ str ] = None
website : Optional [ str ] = None
is_active : Optional [ bool ] = None
invoice_email : Optional [ str ] = None
mobile_phone : Optional [ str ] = None
class ContactCreate ( BaseModel ) :
first_name : str
last_name : str
email : Optional [ str ] = None
phone : Optional [ str ] = None
mobile : Optional [ str ] = None
title : Optional [ str ] = None
department : Optional [ str ] = None
is_primary : Optional [ bool ] = False
role : Optional [ str ] = None
@router.get ( " /customers " )
async def list_customers (
limit : int = Query ( default = 50 , ge = 1 , le = 1000 ) ,
offset : int = Query ( default = 0 , ge = 0 ) ,
search : Optional [ str ] = Query ( default = None ) ,
source : Optional [ str ] = Query ( default = None ) , # 'vtiger', 'local', or None
is_active : Optional [ bool ] = Query ( default = None )
) :
"""
List customers with pagination and filtering
Args :
limit : Maximum number of customers to return
offset : Number of customers to skip
search : Search term for name , email , cvr , phone , city
source : Filter by source ( ' vtiger ' or ' local ' )
is_active : Filter by active status
"""
2025-12-16 15:36:11 +01:00
# Build query with primary contact info
2025-12-06 02:22:01 +01:00
query = """
SELECT
c . * ,
2025-12-16 15:36:11 +01:00
COUNT ( DISTINCT cc . contact_id ) as contact_count ,
CONCAT ( pc . first_name , ' ' , pc . last_name ) as contact_name ,
pc . email as contact_email ,
COALESCE ( pc . mobile , pc . phone ) as contact_phone
2025-12-06 02:22:01 +01:00
FROM customers c
LEFT JOIN contact_companies cc ON cc . customer_id = c . id
2025-12-16 15:36:11 +01:00
LEFT JOIN LATERAL (
SELECT con . first_name , con . last_name , con . email , con . phone , con . mobile
FROM contact_companies ccomp
JOIN contacts con ON ccomp . contact_id = con . id
WHERE ccomp . customer_id = c . id
ORDER BY ccomp . is_primary DESC , con . id ASC
LIMIT 1
) pc ON true
2025-12-06 02:22:01 +01:00
WHERE 1 = 1
"""
params = [ ]
# Add search filter
if search :
query + = """ AND (
c . name ILIKE % s OR
c . email ILIKE % s OR
c . cvr_number ILIKE % s OR
c . phone ILIKE % s OR
c . city ILIKE % s
) """
search_term = f " % { search } % "
params . extend ( [ search_term ] * 5 )
# Add source filter
if source == ' vtiger ' :
query + = " AND c.vtiger_id IS NOT NULL "
elif source == ' local ' :
query + = " AND c.vtiger_id IS NULL "
# Add active filter
if is_active is not None :
query + = " AND c.is_active = %s "
params . append ( is_active )
query + = """
2025-12-16 15:36:11 +01:00
GROUP BY c . id , pc . first_name , pc . last_name , pc . email , pc . phone , pc . mobile
2025-12-06 02:22:01 +01:00
ORDER BY c . name
LIMIT % s OFFSET % s
"""
params . extend ( [ limit , offset ] )
rows = execute_query ( query , tuple ( params ) )
# Get total count
count_query = " SELECT COUNT(*) as total FROM customers WHERE 1=1 "
count_params = [ ]
if search :
count_query + = """ AND (
name ILIKE % s OR
email ILIKE % s OR
cvr_number ILIKE % s OR
phone ILIKE % s OR
city ILIKE % s
) """
count_params . extend ( [ search_term ] * 5 )
if source == ' vtiger ' :
count_query + = " AND vtiger_id IS NOT NULL "
elif source == ' local ' :
count_query + = " AND vtiger_id IS NULL "
if is_active is not None :
count_query + = " AND is_active = %s "
count_params . append ( is_active )
2025-12-16 15:36:11 +01:00
count_result = execute_query_single ( count_query , tuple ( count_params ) )
2025-12-06 02:22:01 +01:00
total = count_result [ ' total ' ] if count_result else 0
return {
" customers " : rows or [ ] ,
" total " : total ,
" limit " : limit ,
" offset " : offset
}
2026-01-05 11:34:39 +01:00
@router.get ( " /customers/verify-linking " )
async def verify_customer_linking ( ) :
"""
🔍 Verificer kunde - linking på tværs af systemer .
Krydstjek :
1. tmodule_customers → Hub customers ( via hub_customer_id )
2. Hub customers → e - conomic ( via economic_customer_number )
3. tmodule_customers → e - conomic ( via economic_customer_number match )
"""
try :
logger . info ( " 🔍 Starting customer linking verification... " )
# 1. Tmodule customers overview
tmodule_stats = execute_query_single ( """
SELECT
COUNT ( * ) as total ,
COUNT ( hub_customer_id ) as linked_to_hub ,
COUNT ( economic_customer_number ) as has_economic_number
FROM tmodule_customers
""" )
# 2. Hub customers overview
hub_stats = execute_query_single ( """
SELECT
COUNT ( * ) as total ,
COUNT ( economic_customer_number ) as has_economic_number ,
COUNT ( CASE WHEN economic_customer_number IS NOT NULL
AND economic_customer_number : : text != ' '
THEN 1 END ) as valid_economic_number
FROM customers
""" )
# 3. Find tmodule customers UDEN Hub link
tmodule_unlinked = execute_query ( """
SELECT id , name , economic_customer_number , email
FROM tmodule_customers
WHERE hub_customer_id IS NULL
ORDER BY name
LIMIT 20
""" )
# 4. Find tmodule customers med Hub link MEN Hub customer mangler economic number
tmodule_linked_but_no_economic = execute_query ( """
SELECT
tc . id as tmodule_id ,
tc . name as tmodule_name ,
tc . economic_customer_number as tmodule_economic ,
c . id as hub_id ,
c . name as hub_name ,
c . economic_customer_number as hub_economic
FROM tmodule_customers tc
JOIN customers c ON tc . hub_customer_id = c . id
WHERE c . economic_customer_number IS NULL
OR c . economic_customer_number : : text = ' '
LIMIT 20
""" )
# 5. Find economic number mismatches
economic_mismatches = execute_query ( """
SELECT
tc . id as tmodule_id ,
tc . name as tmodule_name ,
tc . economic_customer_number as tmodule_economic ,
c . id as hub_id ,
c . name as hub_name ,
c . economic_customer_number as hub_economic
FROM tmodule_customers tc
JOIN customers c ON tc . hub_customer_id = c . id
WHERE tc . economic_customer_number IS NOT NULL
AND c . economic_customer_number IS NOT NULL
AND tc . economic_customer_number : : text != c . economic_customer_number : : text
LIMIT 20
""" )
# 6. Find Hub customers der kunne matches på navn men ikke er linket
potential_name_matches = execute_query ( """
SELECT
tc . id as tmodule_id ,
tc . name as tmodule_name ,
tc . economic_customer_number as tmodule_economic ,
c . id as hub_id ,
c . name as hub_name ,
c . economic_customer_number as hub_economic
FROM tmodule_customers tc
JOIN customers c ON LOWER ( TRIM ( tc . name ) ) = LOWER ( TRIM ( c . name ) )
WHERE tc . hub_customer_id IS NULL
LIMIT 20
""" )
# 7. Beregn health score
tmodule_link_pct = ( tmodule_stats [ ' linked_to_hub ' ] / tmodule_stats [ ' total ' ] * 100 ) if tmodule_stats [ ' total ' ] > 0 else 0
hub_economic_pct = ( hub_stats [ ' valid_economic_number ' ] / hub_stats [ ' total ' ] * 100 ) if hub_stats [ ' total ' ] > 0 else 0
health_score = ( tmodule_link_pct * 0.6 ) + ( hub_economic_pct * 0.4 )
if health_score > = 90 :
health_status = " excellent "
elif health_score > = 75 :
health_status = " good "
elif health_score > = 50 :
health_status = " fair "
else :
health_status = " poor "
result = {
" status " : " success " ,
" health " : {
" score " : round ( health_score , 1 ) ,
" status " : health_status ,
" description " : f " { round ( tmodule_link_pct , 1 ) } % tmodule linked, { round ( hub_economic_pct , 1 ) } % hub has economic numbers "
} ,
" tmodule_customers " : {
" total " : tmodule_stats [ ' total ' ] ,
" linked_to_hub " : tmodule_stats [ ' linked_to_hub ' ] ,
" has_economic_number " : tmodule_stats [ ' has_economic_number ' ] ,
" link_percentage " : round ( tmodule_link_pct , 1 )
} ,
" hub_customers " : {
" total " : hub_stats [ ' total ' ] ,
" has_economic_number " : hub_stats [ ' valid_economic_number ' ] ,
" economic_percentage " : round ( hub_economic_pct , 1 )
} ,
" issues " : {
" tmodule_unlinked_count " : len ( tmodule_unlinked ) ,
" tmodule_unlinked_sample " : tmodule_unlinked [ : 5 ] ,
" hub_missing_economic_count " : len ( tmodule_linked_but_no_economic ) ,
" hub_missing_economic_sample " : tmodule_linked_but_no_economic [ : 5 ] ,
" economic_mismatches_count " : len ( economic_mismatches ) ,
" economic_mismatches_sample " : economic_mismatches [ : 5 ] ,
" potential_name_matches_count " : len ( potential_name_matches ) ,
" potential_name_matches_sample " : potential_name_matches [ : 5 ]
} ,
" recommendations " : [ ]
}
# Generer anbefalinger
if len ( tmodule_unlinked ) > 0 :
result [ " recommendations " ] . append ( {
" issue " : " unlinked_tmodule_customers " ,
" count " : len ( tmodule_unlinked ) ,
" action " : " POST /api/v1/timetracking/sync/relink-customers " ,
" description " : " Kør re-linking for at matche på economic_customer_number eller navn "
} )
if len ( tmodule_linked_but_no_economic ) > 0 :
result [ " recommendations " ] . append ( {
" issue " : " hub_customers_missing_economic_number " ,
" count " : len ( tmodule_linked_but_no_economic ) ,
" action " : " POST /api/v1/customers/sync-economic-from-simplycrm " ,
" description " : " Sync e-conomic numre fra Simply-CRM "
} )
if len ( economic_mismatches ) > 0 :
result [ " recommendations " ] . append ( {
" issue " : " economic_number_mismatches " ,
" count " : len ( economic_mismatches ) ,
" action " : " Manual review required " ,
" description " : " Forskellige e-conomic numre i tmodule vs hub - tjek data manuelt "
} )
if len ( potential_name_matches ) > 0 :
result [ " recommendations " ] . append ( {
" issue " : " potential_name_matches " ,
" count " : len ( potential_name_matches ) ,
" action " : " POST /api/v1/timetracking/sync/relink-customers " ,
" description " : " Disse kunder kunne linkes på navn "
} )
logger . info ( f " ✅ Verification complete - Health: { health_status } ( { health_score : .1f } %) " )
return result
except Exception as e :
logger . error ( f " ❌ Verification failed: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-06 02:22:01 +01:00
@router.get ( " /customers/ {customer_id} " )
async def get_customer ( customer_id : int ) :
2025-12-13 12:06:28 +01:00
""" Get single customer by ID with contact count and vTiger BMC Låst status """
2025-12-06 02:22:01 +01:00
# Get customer
2025-12-16 15:36:11 +01:00
customer = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT * FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Get contact count
2025-12-16 15:36:11 +01:00
contact_count_result = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
contact_count = contact_count_result [ ' count ' ] if contact_count_result else 0
2025-12-13 12:06:28 +01:00
# Get BMC Låst from vTiger if customer has vtiger_id
bmc_locked = False
if customer . get ( ' vtiger_id ' ) :
try :
from app . services . vtiger_service import get_vtiger_service
vtiger = get_vtiger_service ( )
account = await vtiger . get_account ( customer [ ' vtiger_id ' ] )
if account :
# cf_accounts_bmclst is the BMC Låst field (checkbox: 1 = locked, 0 = not locked)
bmc_locked = account . get ( ' cf_accounts_bmclst ' ) == ' 1 '
except Exception as e :
logger . error ( f " ❌ Error fetching BMC Låst status: { e } " )
2025-12-06 02:22:01 +01:00
return {
* * customer ,
2025-12-13 12:06:28 +01:00
' contact_count ' : contact_count ,
' bmc_locked ' : bmc_locked
2025-12-06 02:22:01 +01:00
}
@router.post ( " /customers " )
async def create_customer ( customer : CustomerCreate ) :
""" Create a new customer """
try :
customer_id = execute_insert (
""" INSERT INTO customers
( name , cvr_number , email , phone , address , city , postal_code ,
country , website , is_active , invoice_email , mobile_phone )
VALUES ( % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s , % s )
RETURNING id """ ,
(
customer . name ,
customer . cvr_number ,
customer . email ,
customer . phone ,
customer . address ,
customer . city ,
customer . postal_code ,
customer . country ,
customer . website ,
customer . is_active ,
customer . invoice_email ,
customer . mobile_phone
)
)
logger . info ( f " ✅ Created customer { customer_id } : { customer . name } " )
# Fetch and return created customer
2025-12-16 15:36:11 +01:00
created = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT * FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
return created
except Exception as e :
logger . error ( f " ❌ Failed to create customer: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.put ( " /customers/ {customer_id} " )
async def update_customer ( customer_id : int , update : CustomerUpdate ) :
""" Update customer information """
# Verify customer exists
2025-12-16 15:36:11 +01:00
existing = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT id FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
if not existing :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Build dynamic UPDATE query
updates = [ ]
params = [ ]
update_dict = update . dict ( exclude_unset = True )
for field , value in update_dict . items ( ) :
updates . append ( f " { field } = %s " )
params . append ( value )
if not updates :
raise HTTPException ( status_code = 400 , detail = " No fields to update " )
params . append ( customer_id )
query = f " UPDATE customers SET { ' , ' . join ( updates ) } , updated_at = CURRENT_TIMESTAMP WHERE id = %s "
try :
execute_update ( query , tuple ( params ) )
logger . info ( f " ✅ Updated customer { customer_id } " )
# Fetch and return updated customer
2025-12-16 15:36:11 +01:00
updated = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT * FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
return updated
except Exception as e :
logger . error ( f " ❌ Failed to update customer { customer_id } : { e } " )
2025-12-23 15:36:17 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2026-01-05 11:08:49 +01:00
@router.post ( " /customers/sync-economic-from-simplycrm " )
async def sync_economic_numbers_from_simplycrm ( ) :
"""
🔗 Sync e - conomic customer numbers fra Simply - CRM til Hub customers .
Henter cf_854 ( economic_customer_number ) fra Simply - CRM accounts og
opdaterer matching Hub customers baseret på navn .
"""
try :
from app . services . simplycrm_service import SimplyCRMService
logger . info ( " 🚀 Starting e-conomic number sync from Simply-CRM... " )
stats = {
" simplycrm_accounts " : 0 ,
" accounts_with_economic_number " : 0 ,
" hub_customers_updated " : 0 ,
" hub_customers_not_found " : 0 ,
" errors " : 0
}
async with SimplyCRMService ( ) as simplycrm :
# Hent alle accounts fra Simply-CRM
logger . info ( " 📥 Fetching accounts from Simply-CRM... " )
2026-01-05 11:28:45 +01:00
# Først: Tjek hvilke felter der er tilgængelige
try :
test_query = " SELECT * FROM Accounts LIMIT 1; "
test_result = await simplycrm . query ( test_query )
if test_result :
logger . info ( f " 📋 Available fields: { list ( test_result [ 0 ] . keys ( ) ) } " )
except Exception as e :
logger . warning ( f " ⚠️ Could not fetch sample fields: { e } " )
# Query med standard felter + economic_acc_number
query = " SELECT id, accountname, economic_acc_number FROM Accounts LIMIT 5000; "
2026-01-05 11:08:49 +01:00
accounts = await simplycrm . query ( query )
stats [ " simplycrm_accounts " ] = len ( accounts )
logger . info ( f " ✅ Found { len ( accounts ) } accounts in Simply-CRM " )
if not accounts :
return {
" status " : " success " ,
" message " : " No accounts found in Simply-CRM " ,
" stats " : stats
}
# Filter accounts med economic customer number
2026-01-05 11:28:45 +01:00
accounts_with_economic = [ ]
for acc in accounts :
economic_number = acc . get ( ' economic_acc_number ' )
if economic_number and str ( economic_number ) . strip ( ) not in [ ' ' , ' 0 ' , ' null ' , ' NULL ' ] :
accounts_with_economic . append ( {
' accountname ' : acc . get ( ' accountname ' ) ,
' economic_number ' : str ( economic_number ) . strip ( )
} )
2026-01-05 11:08:49 +01:00
stats [ " accounts_with_economic_number " ] = len ( accounts_with_economic )
logger . info ( f " ✅ { len ( accounts_with_economic ) } accounts have e-conomic customer numbers " )
# Map company name → economic_customer_number
name_to_economic = { }
2026-01-05 11:28:45 +01:00
for acc_data in accounts_with_economic :
company_name = acc_data [ ' accountname ' ] . strip ( )
economic_number = acc_data [ ' economic_number ' ]
2026-01-05 11:08:49 +01:00
if company_name and economic_number :
# Normalize navn til lowercase for matching
name_key = company_name . lower ( )
name_to_economic [ name_key ] = {
' original_name ' : company_name ,
' economic_customer_number ' : economic_number
}
logger . info ( f " 📊 Mapped { len ( name_to_economic ) } unique company names " )
# Hent alle Hub customers
hub_customers = execute_query ( " SELECT id, name FROM customers " )
logger . info ( f " 📊 Found { len ( hub_customers ) } customers in Hub " )
# Match og opdater
for customer in hub_customers :
customer_name_key = customer [ ' name ' ] . strip ( ) . lower ( )
if customer_name_key in name_to_economic :
economic_data = name_to_economic [ customer_name_key ]
economic_number = economic_data [ ' economic_customer_number ' ]
try :
execute_query (
""" UPDATE customers
SET economic_customer_number = % s ,
last_synced_at = NOW ( )
WHERE id = % s """ ,
( economic_number , customer [ ' id ' ] )
)
logger . info ( f " ✅ Updated { customer [ ' name ' ] } → e-conomic # { economic_number } " )
stats [ " hub_customers_updated " ] + = 1
except Exception as update_error :
logger . error ( f " ❌ Failed to update customer { customer [ ' id ' ] } : { update_error } " )
stats [ " errors " ] + = 1
else :
stats [ " hub_customers_not_found " ] + = 1
logger . info ( f " ✅ Sync complete: { stats } " )
# Auto-link tmodule_customers after sync
try :
logger . info ( " 🔗 Running auto-link for timetracking customers... " )
link_results = execute_query ( " SELECT * FROM link_tmodule_customers_to_hub() " )
logger . info ( f " ✅ Linked { len ( link_results ) } timetracking customers " )
stats [ " tmodule_customers_linked " ] = len ( link_results )
except Exception as link_error :
logger . warning ( f " ⚠️ Auto-linking failed (non-critical): { link_error } " )
return {
" status " : " success " ,
" message " : f " Synced { stats [ ' hub_customers_updated ' ] } customers with e-conomic numbers from Simply-CRM " ,
" stats " : stats
}
except Exception as e :
logger . error ( f " ❌ Simply-CRM sync failed: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-23 15:36:17 +01:00
@router.post ( " /customers/ {customer_id} /link-economic " )
async def link_economic_customer ( customer_id : int , link_request : dict ) :
""" Manually link customer to e-conomic customer number """
try :
economic_customer_number = link_request . get ( ' economic_customer_number ' )
if not economic_customer_number :
raise HTTPException ( status_code = 400 , detail = " economic_customer_number required " )
# Get customer
customer = execute_query_single (
" SELECT id, name FROM customers WHERE id = %s " ,
( customer_id , ) )
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Update economic customer number
execute_query (
" UPDATE customers SET economic_customer_number = %s , last_synced_at = NOW() WHERE id = %s " ,
( economic_customer_number , customer_id )
)
logger . info ( f " ✅ Linked customer { customer_id } ( { customer [ ' name ' ] } ) to e-conomic # { economic_customer_number } " )
return {
" status " : " success " ,
" message " : f " Kunde linket til e-conomic kundenr. { economic_customer_number } " ,
" customer_id " : customer_id ,
" economic_customer_number " : economic_customer_number
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to link customer { customer_id } to e-conomic: { e } " )
2025-12-23 15:39:35 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /customers/ {customer_id} /search-economic " )
async def search_economic_for_customer ( customer_id : int , query : Optional [ str ] = None ) :
""" Search e-conomic for matching customers by name """
try :
from app . services . economic_service import EconomicService
# Get customer
customer = execute_query_single (
" SELECT id, name FROM customers WHERE id = %s " ,
( customer_id , ) )
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Use provided query or customer name
search_query = query or customer [ ' name ' ]
# Search in e-conomic
economic = EconomicService ( )
results = await economic . search_customer_by_name ( search_query )
return {
" status " : " success " ,
" customer_id " : customer_id ,
" customer_name " : customer [ ' name ' ] ,
" search_query " : search_query ,
" results " : [
{
" customerNumber " : r . get ( ' customerNumber ' ) ,
" name " : r . get ( ' name ' ) ,
" corporateIdentificationNumber " : r . get ( ' corporateIdentificationNumber ' ) ,
" email " : r . get ( ' email ' ) ,
" city " : r . get ( ' city ' )
}
for r in results
] ,
" count " : len ( results )
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Failed to search e-conomic for customer { customer_id } : { e } " )
2025-12-06 02:22:01 +01:00
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-13 12:06:28 +01:00
@router.post ( " /customers/ {customer_id} /subscriptions/lock " )
async def lock_customer_subscriptions ( customer_id : int , lock_request : dict ) :
""" Lock/unlock subscriptions for customer in local DB - BMC Låst status controlled in vTiger """
try :
locked = lock_request . get ( ' locked ' , False )
# Get customer
2025-12-16 15:36:11 +01:00
customer = execute_query_single (
2025-12-13 12:06:28 +01:00
" SELECT id, name FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-13 12:06:28 +01:00
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Update local database only
execute_update (
" UPDATE customers SET subscriptions_locked = %s WHERE id = %s " ,
( locked , customer_id )
)
logger . info ( f " ✅ Updated local subscriptions_locked= { locked } for customer { customer_id } " )
return {
" status " : " success " ,
" message " : f " Abonnementer er nu { ' låst ' if locked else ' låst op ' } i BMC Hub " ,
" customer_id " : customer_id ,
" note " : " BMC Låst status i vTiger skal sættes manuelt i vTiger "
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error locking subscriptions: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-06 02:22:01 +01:00
@router.get ( " /customers/ {customer_id} /contacts " )
async def get_customer_contacts ( customer_id : int ) :
""" Get all contacts for a specific customer """
2025-12-16 15:36:11 +01:00
rows = execute_query_single ( """
2025-12-06 02:22:01 +01:00
SELECT
c . * ,
cc . is_primary ,
cc . role ,
cc . notes
FROM contacts c
JOIN contact_companies cc ON c . id = cc . contact_id
WHERE cc . customer_id = % s AND c . is_active = TRUE
ORDER BY cc . is_primary DESC , c . first_name , c . last_name
""" , (customer_id,))
return rows or [ ]
@router.post ( " /customers/ {customer_id} /contacts " )
async def create_customer_contact ( customer_id : int , contact : ContactCreate ) :
""" Create a new contact for a customer """
# Verify customer exists
customer = execute_query (
" SELECT id FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-06 02:22:01 +01:00
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
try :
# Create contact
contact_id = execute_insert (
""" INSERT INTO contacts
( first_name , last_name , email , phone , mobile , title , department )
VALUES ( % s , % s , % s , % s , % s , % s , % s )
RETURNING id """ ,
(
contact . first_name ,
contact . last_name ,
contact . email ,
contact . phone ,
contact . mobile ,
contact . title ,
contact . department
)
)
# Link contact to customer
execute_insert (
""" INSERT INTO contact_companies
( contact_id , customer_id , is_primary , role )
VALUES ( % s , % s , % s , % s ) """ ,
( contact_id , customer_id , contact . is_primary , contact . role )
)
logger . info ( f " ✅ Created contact { contact_id } for customer { customer_id } " )
# Fetch and return created contact
2025-12-16 15:36:11 +01:00
created = execute_query_single (
2025-12-06 02:22:01 +01:00
" SELECT * FROM contacts WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( contact_id , ) )
2025-12-06 02:22:01 +01:00
return created
except Exception as e :
logger . error ( f " ❌ Failed to create contact: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /cvr/ {cvr_number} " )
async def lookup_cvr ( cvr_number : str ) :
""" Lookup company information by CVR number """
cvr_service = get_cvr_service ( )
result = await cvr_service . lookup_by_cvr ( cvr_number )
if not result :
raise HTTPException ( status_code = 404 , detail = " CVR number not found " )
return result
2025-12-11 23:14:20 +01:00
@router.get ( " /customers/ {customer_id} /subscriptions " )
async def get_customer_subscriptions ( customer_id : int ) :
"""
Get subscriptions and sales orders for a customer
Returns data from vTiger :
1. Recurring Sales Orders ( enable_recurring = 1 )
2. Sales Orders with recurring_frequency ( open status )
3. Recent Invoices for context
"""
from app . services . vtiger_service import get_vtiger_service
# Get customer with vTiger ID
2025-12-16 15:36:11 +01:00
customer = execute_query_single (
2025-12-11 23:14:20 +01:00
" SELECT id, name, vtiger_id FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-11 23:14:20 +01:00
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
vtiger_id = customer . get ( ' vtiger_id ' )
if not vtiger_id :
logger . warning ( f " ⚠️ Customer { customer_id } has no vTiger ID " )
return {
" status " : " no_vtiger_link " ,
" message " : " Kunde er ikke synkroniseret med vTiger " ,
" recurring_orders " : [ ] ,
" sales_orders " : [ ] ,
" invoices " : [ ]
}
try :
vtiger = get_vtiger_service ( )
# Fetch all sales orders
logger . info ( f " 🔍 Fetching sales orders for vTiger account { vtiger_id } " )
all_orders = await vtiger . get_customer_sales_orders ( vtiger_id )
# Fetch subscriptions from vTiger
logger . info ( f " 🔍 Fetching subscriptions for vTiger account { vtiger_id } " )
subscriptions = await vtiger . get_customer_subscriptions ( vtiger_id )
# Filter sales orders into categories
recurring_orders = [ ]
frequency_orders = [ ]
all_open_orders = [ ]
for order in all_orders :
# Skip closed/cancelled orders
status = order . get ( ' sostatus ' , ' ' ) . lower ( )
if status in [ ' closed ' , ' cancelled ' ] :
continue
all_open_orders . append ( order )
# Check if recurring is enabled
enable_recurring = order . get ( ' enable_recurring ' )
recurring_frequency = order . get ( ' recurring_frequency ' , ' ' ) . strip ( )
if enable_recurring == ' 1 ' or enable_recurring == 1 :
recurring_orders . append ( order )
elif recurring_frequency :
frequency_orders . append ( order )
# Filter subscriptions by status
active_subscriptions = [ ]
expired_subscriptions = [ ]
for sub in subscriptions :
status = sub . get ( ' sub_status ' , ' ' ) . lower ( )
if status in [ ' cancelled ' , ' expired ' ] :
expired_subscriptions . append ( sub )
else :
active_subscriptions . append ( sub )
2025-12-13 12:06:28 +01:00
# Fetch Simply-CRM sales orders (open orders from old system)
# NOTE: Simply-CRM has DIFFERENT IDs than vTiger Cloud! Must match by name or CVR.
simplycrm_sales_orders = [ ]
try :
from app . services . simplycrm_service import SimplyCRMService
async with SimplyCRMService ( ) as simplycrm :
# First, find the Simply-CRM account by name
customer_name = customer . get ( ' name ' , ' ' ) . strip ( )
if customer_name :
# Search for account in Simply-CRM by name
account_query = f " SELECT id FROM Accounts WHERE accountname= ' { customer_name } ' ; "
simplycrm_accounts = await simplycrm . query ( account_query )
if simplycrm_accounts and len ( simplycrm_accounts ) > 0 :
simplycrm_account_id = simplycrm_accounts [ 0 ] . get ( ' id ' )
logger . info ( f " 🔍 Found Simply-CRM account: { simplycrm_account_id } for ' { customer_name } ' " )
# Query open sales orders from Simply-CRM using the correct ID
# Note: Simply-CRM returns one row per line item, so we need to group them
query = f " SELECT * FROM SalesOrder WHERE account_id= ' { simplycrm_account_id } ' ; "
all_simplycrm_orders = await simplycrm . query ( query )
2025-12-16 15:36:11 +01:00
logger . info ( f " 🔍 Simply-CRM raw query returned { len ( all_simplycrm_orders or [ ] ) } orders for account { simplycrm_account_id } " )
2025-12-13 12:06:28 +01:00
# Group line items by order ID
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
orders_dict = { }
2025-12-16 15:36:11 +01:00
filtered_closed = 0
filtered_no_freq = 0
2025-12-13 12:06:28 +01:00
for row in ( all_simplycrm_orders or [ ] ) :
status = row . get ( ' sostatus ' , ' ' ) . lower ( )
if status in [ ' closed ' , ' cancelled ' ] :
2025-12-16 15:36:11 +01:00
filtered_closed + = 1
logger . debug ( f " ⏭️ Skipping closed order: { row . get ( ' subject ' , ' N/A ' ) } ( { status } ) " )
2025-12-13 12:06:28 +01:00
continue
# MUST have recurring_frequency to be a subscription
recurring_frequency = row . get ( ' recurring_frequency ' , ' ' ) . strip ( )
if not recurring_frequency :
2025-12-16 15:36:11 +01:00
filtered_no_freq + = 1
logger . debug ( f " ⏭️ Skipping order without frequency: { row . get ( ' subject ' , ' N/A ' ) } " )
2025-12-13 12:06:28 +01:00
continue
2025-12-16 15:36:11 +01:00
logger . info ( f " ✅ Including order: { row . get ( ' subject ' , ' N/A ' ) } - { recurring_frequency } ( { status } ) " )
2025-12-13 12:06:28 +01:00
order_id = row . get ( ' id ' )
if order_id not in orders_dict :
# First occurrence - create order object
orders_dict [ order_id ] = dict ( row )
orders_dict [ order_id ] [ ' lineItems ' ] = [ ]
# Add line item if productid exists
if row . get ( ' productid ' ) :
# Fetch product name
product_name = ' Unknown Product '
try :
product_query = f " SELECT productname FROM Products WHERE id= ' { row . get ( ' productid ' ) } ' ; "
product_result = await simplycrm . query ( product_query )
if product_result and len ( product_result ) > 0 :
product_name = product_result [ 0 ] . get ( ' productname ' , product_name )
except :
pass
orders_dict [ order_id ] [ ' lineItems ' ] . append ( {
' productid ' : row . get ( ' productid ' ) ,
' product_name ' : product_name ,
' quantity ' : row . get ( ' quantity ' ) ,
' listprice ' : row . get ( ' listprice ' ) ,
' netprice ' : float ( row . get ( ' quantity ' , 0 ) ) * float ( row . get ( ' listprice ' , 0 ) ) ,
' comment ' : row . get ( ' comment ' , ' ' )
} )
simplycrm_sales_orders = list ( orders_dict . values ( ) )
2025-12-16 15:36:11 +01:00
logger . info ( f " 📥 Found { len ( simplycrm_sales_orders ) } unique recurring orders in Simply-CRM (filtered out: { filtered_closed } closed, { filtered_no_freq } without frequency) " )
2025-12-13 12:06:28 +01:00
else :
logger . info ( f " ℹ ️ No Simply-CRM account found for ' { customer_name } ' " )
except Exception as e :
logger . warning ( f " ⚠️ Could not fetch Simply-CRM sales orders: { e } " )
2025-12-11 23:14:20 +01:00
# Fetch BMC Office subscriptions from local database
bmc_office_query = """
SELECT * FROM bmc_office_subscription_totals
WHERE customer_id = % s AND active = true
ORDER BY start_date DESC
"""
bmc_office_subs = execute_query ( bmc_office_query , ( customer_id , ) ) or [ ]
2025-12-13 12:06:28 +01:00
logger . info ( f " ✅ Found { len ( recurring_orders ) } recurring orders, { len ( frequency_orders ) } frequency orders, { len ( all_open_orders ) } vTiger orders, { len ( simplycrm_sales_orders ) } Simply-CRM orders, { len ( active_subscriptions ) } active subscriptions, { len ( bmc_office_subs ) } BMC Office subscriptions " )
2025-12-11 23:14:20 +01:00
return {
" status " : " success " ,
" customer_id " : customer_id ,
" customer_name " : customer [ ' name ' ] ,
" vtiger_id " : vtiger_id ,
" recurring_orders " : recurring_orders ,
2025-12-13 12:06:28 +01:00
" sales_orders " : simplycrm_sales_orders , # Open sales orders from Simply-CRM
2025-12-11 23:14:20 +01:00
" subscriptions " : active_subscriptions , # Active subscriptions from vTiger Subscriptions module
" expired_subscriptions " : expired_subscriptions ,
" bmc_office_subscriptions " : bmc_office_subs , # Local BMC Office subscriptions
" last_updated " : " real-time "
}
except Exception as e :
logger . error ( f " ❌ Error fetching subscriptions: { e } " )
raise HTTPException ( status_code = 500 , detail = f " Failed to fetch subscriptions: { str ( e ) } " )
2025-12-13 12:06:28 +01:00
class SubscriptionCreate ( BaseModel ) :
subject : str
account_id : str # vTiger account ID
startdate : str # YYYY-MM-DD
enddate : Optional [ str ] = None # YYYY-MM-DD
generateinvoiceevery : str # "Monthly", "Quarterly", "Yearly"
subscriptionstatus : Optional [ str ] = " Active "
products : List [ Dict ] # [{"productid": "id", "quantity": 1, "listprice": 100}]
class SubscriptionUpdate ( BaseModel ) :
subject : Optional [ str ] = None
startdate : Optional [ str ] = None
enddate : Optional [ str ] = None
generateinvoiceevery : Optional [ str ] = None
subscriptionstatus : Optional [ str ] = None
products : Optional [ List [ Dict ] ] = None
@router.post ( " /customers/ {customer_id} /subscriptions " )
async def create_subscription ( customer_id : int , subscription : SubscriptionCreate ) :
""" Create new subscription in vTiger """
try :
# Get customer's vTiger ID
customer = execute_query (
" SELECT vtiger_id FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-13 12:06:28 +01:00
if not customer or not customer . get ( ' vtiger_id ' ) :
raise HTTPException ( status_code = 404 , detail = " Customer not linked to vTiger " )
# Create subscription in vTiger
from app . services . vtiger_service import VTigerService
async with VTigerService ( ) as vtiger :
result = await vtiger . create_subscription (
account_id = customer [ ' vtiger_id ' ] ,
subject = subscription . subject ,
startdate = subscription . startdate ,
enddate = subscription . enddate ,
generateinvoiceevery = subscription . generateinvoiceevery ,
subscriptionstatus = subscription . subscriptionstatus ,
products = subscription . products
)
logger . info ( f " ✅ Created subscription { result . get ( ' id ' ) } for customer { customer_id } " )
return { " status " : " success " , " subscription " : result }
except Exception as e :
logger . error ( f " ❌ Error creating subscription: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /subscriptions/ {subscription_id} " )
async def get_subscription_details ( subscription_id : str ) :
""" Get full subscription details with line items from vTiger """
try :
from app . services . vtiger_service import get_vtiger_service
vtiger = get_vtiger_service ( )
subscription = await vtiger . get_subscription ( subscription_id )
if not subscription :
raise HTTPException ( status_code = 404 , detail = " Subscription not found " )
return { " status " : " success " , " subscription " : subscription }
except Exception as e :
logger . error ( f " ❌ Error fetching subscription: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.put ( " /subscriptions/ {subscription_id} " )
async def update_subscription ( subscription_id : str , subscription : SubscriptionUpdate ) :
""" Update subscription in vTiger including line items/prices """
try :
from app . services . vtiger_service import get_vtiger_service
vtiger = get_vtiger_service ( )
# Extract products/line items if provided
update_dict = subscription . dict ( exclude_unset = True )
line_items = update_dict . pop ( ' products ' , None )
result = await vtiger . update_subscription (
subscription_id = subscription_id ,
updates = update_dict ,
line_items = line_items
)
logger . info ( f " ✅ Updated subscription { subscription_id } " )
return { " status " : " success " , " subscription " : result }
except Exception as e :
logger . error ( f " ❌ Error updating subscription: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.delete ( " /subscriptions/ {subscription_id} " )
async def delete_subscription ( subscription_id : str , customer_id : int = None ) :
""" Delete (deactivate) subscription in vTiger - respects customer lock status """
try :
# Check if subscriptions are locked for this customer (if customer_id provided)
if customer_id :
2025-12-16 15:36:11 +01:00
customer = execute_query_single (
2025-12-13 12:06:28 +01:00
" SELECT subscriptions_locked FROM customers WHERE id = %s " ,
2025-12-16 15:36:11 +01:00
( customer_id , ) )
2025-12-13 12:06:28 +01:00
if customer and customer . get ( ' subscriptions_locked ' ) :
raise HTTPException (
status_code = 403 ,
detail = " Abonnementer er låst for denne kunde. Kan kun redigeres direkte i vTiger. "
)
from app . services . vtiger_service import get_vtiger_service
vtiger = get_vtiger_service ( )
# Set status to Cancelled instead of deleting
result = await vtiger . update_subscription (
subscription_id = subscription_id ,
updates = { " subscriptionstatus " : " Cancelled " } ,
line_items = None
)
logger . info ( f " ✅ Cancelled subscription { subscription_id } " )
return { " status " : " success " , " message " : " Subscription cancelled " }
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error deleting subscription: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
2025-12-16 22:07:20 +01:00
# Subscription Internal Comment Endpoints
class SubscriptionComment ( BaseModel ) :
comment : str
@router.post ( " /customers/ {customer_id} /subscription-comment " )
async def save_subscription_comment ( customer_id : int , data : SubscriptionComment ) :
""" Save internal comment about customer subscriptions """
try :
# Check if customer exists
customer = execute_query_single (
" SELECT id FROM customers WHERE id = %s " ,
( customer_id , )
)
if not customer :
raise HTTPException ( status_code = 404 , detail = " Customer not found " )
# Delete existing comment if any and insert new one in a single query
result = execute_query (
"""
WITH deleted AS (
DELETE FROM customer_notes
WHERE customer_id = % s AND note_type = ' subscription_comment '
)
INSERT INTO customer_notes ( customer_id , note_type , note , created_by , created_at )
VALUES ( % s , ' subscription_comment ' , % s , ' System ' , NOW ( ) )
RETURNING id , note , created_by , created_at
""" ,
( customer_id , customer_id , data . comment )
)
if not result or len ( result ) == 0 :
raise Exception ( " Failed to insert comment " )
row = result [ 0 ]
logger . info ( f " ✅ Saved subscription comment for customer { customer_id } " )
return {
" id " : row [ ' id ' ] ,
" comment " : row [ ' note ' ] ,
" created_by " : row [ ' created_by ' ] ,
" created_at " : row [ ' created_at ' ] . isoformat ( )
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error saving subscription comment: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )
@router.get ( " /customers/ {customer_id} /subscription-comment " )
async def get_subscription_comment ( customer_id : int ) :
""" Get internal comment about customer subscriptions """
try :
result = execute_query_single (
"""
SELECT id , note , created_by , created_at
FROM customer_notes
WHERE customer_id = % s AND note_type = ' subscription_comment '
ORDER BY created_at DESC
LIMIT 1
""" ,
( customer_id , )
)
if not result :
raise HTTPException ( status_code = 404 , detail = " No comment found " )
return {
" id " : result [ ' id ' ] ,
" comment " : result [ ' note ' ] ,
" created_by " : result [ ' created_by ' ] ,
" created_at " : result [ ' created_at ' ] . isoformat ( )
}
except HTTPException :
raise
except Exception as e :
logger . error ( f " ❌ Error fetching subscription comment: { e } " )
raise HTTPException ( status_code = 500 , detail = str ( e ) )