1342 lines
52 KiB
Python
1342 lines
52 KiB
Python
"""
|
||
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
|
||
|
||
from app.core.database import execute_query, execute_query_single, execute_update
|
||
from app.services.cvr_service import get_cvr_service
|
||
from app.services.customer_activity_logger import CustomerActivityLogger
|
||
from app.services.customer_consistency import CustomerConsistencyService
|
||
|
||
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
|
||
"""
|
||
# Build query with primary contact info
|
||
query = """
|
||
SELECT
|
||
c.*,
|
||
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
|
||
FROM customers c
|
||
LEFT JOIN contact_companies cc ON cc.customer_id = c.id
|
||
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
|
||
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 += """
|
||
GROUP BY c.id, pc.first_name, pc.last_name, pc.email, pc.phone, pc.mobile
|
||
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)
|
||
|
||
count_result = execute_query_single(count_query, tuple(count_params))
|
||
total = count_result['total'] if count_result else 0
|
||
|
||
return {
|
||
"customers": rows or [],
|
||
"total": total,
|
||
"limit": limit,
|
||
"offset": offset
|
||
}
|
||
|
||
|
||
@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))
|
||
|
||
|
||
@router.get("/customers/{customer_id}")
|
||
async def get_customer(customer_id: int):
|
||
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
||
# Get customer
|
||
customer = execute_query_single(
|
||
"SELECT * FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
|
||
if not customer:
|
||
raise HTTPException(status_code=404, detail="Customer not found")
|
||
|
||
# Get contact count
|
||
contact_count_result = execute_query_single(
|
||
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
|
||
(customer_id,))
|
||
|
||
contact_count = contact_count_result['count'] if contact_count_result else 0
|
||
|
||
# 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}")
|
||
|
||
return {
|
||
**customer,
|
||
'contact_count': contact_count,
|
||
'bmc_locked': bmc_locked
|
||
}
|
||
|
||
|
||
@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}")
|
||
|
||
# Log activity
|
||
CustomerActivityLogger.log_created(customer_id, customer.name)
|
||
|
||
# Fetch and return created customer
|
||
created = execute_query_single(
|
||
"SELECT * FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
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
|
||
existing = execute_query_single(
|
||
"SELECT id, name FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
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)
|
||
fields_changed = list(update_dict.keys())
|
||
|
||
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}")
|
||
|
||
# Log activity
|
||
CustomerActivityLogger.log_updated(customer_id, existing['name'], fields_changed)
|
||
|
||
# Fetch and return updated customer
|
||
updated = execute_query_single(
|
||
"SELECT * FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
return updated
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to update customer {customer_id}: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/customers/{customer_id}/data-consistency")
|
||
async def check_customer_data_consistency(customer_id: int):
|
||
"""
|
||
🔍 Check data consistency across Hub, vTiger, and e-conomic
|
||
|
||
Returns discrepancies found between the three systems
|
||
"""
|
||
try:
|
||
from app.core.config import settings
|
||
|
||
if not settings.AUTO_CHECK_CONSISTENCY:
|
||
return {
|
||
"enabled": False,
|
||
"message": "Data consistency checking is disabled"
|
||
}
|
||
|
||
consistency_service = CustomerConsistencyService()
|
||
|
||
# Fetch data from all systems
|
||
all_data = await consistency_service.fetch_all_data(customer_id)
|
||
|
||
# Compare data
|
||
discrepancies = consistency_service.compare_data(all_data)
|
||
|
||
# Count actual discrepancies
|
||
discrepancy_count = sum(
|
||
1 for field_data in discrepancies.values()
|
||
if field_data['discrepancy']
|
||
)
|
||
|
||
return {
|
||
"enabled": True,
|
||
"customer_id": customer_id,
|
||
"discrepancy_count": discrepancy_count,
|
||
"discrepancies": discrepancies,
|
||
"systems_available": {
|
||
"hub": True,
|
||
"vtiger": all_data.get('vtiger') is not None,
|
||
"economic": all_data.get('economic') is not None
|
||
}
|
||
}
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to check consistency for customer {customer_id}: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/customers/{customer_id}/sync-field")
|
||
async def sync_customer_field(
|
||
customer_id: int,
|
||
field_name: str = Query(..., description="Hub field name to sync"),
|
||
source_system: str = Query(..., description="Source system: hub, vtiger, or economic"),
|
||
source_value: str = Query(..., description="The correct value to sync")
|
||
):
|
||
"""
|
||
🔄 Sync a single field across all systems
|
||
|
||
Takes the correct value from one system and updates the others
|
||
"""
|
||
try:
|
||
from app.core.config import settings
|
||
|
||
# Validate source system
|
||
if source_system not in ['hub', 'vtiger', 'economic']:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic"
|
||
)
|
||
|
||
consistency_service = CustomerConsistencyService()
|
||
|
||
# Perform sync
|
||
results = await consistency_service.sync_field(
|
||
customer_id=customer_id,
|
||
field_name=field_name,
|
||
source_system=source_system,
|
||
source_value=source_value
|
||
)
|
||
|
||
return {
|
||
"success": True,
|
||
"customer_id": customer_id,
|
||
"field_name": field_name,
|
||
"source_system": source_system,
|
||
"source_value": source_value,
|
||
"sync_results": results
|
||
}
|
||
|
||
except ValueError as e:
|
||
raise HTTPException(status_code=400, detail=str(e))
|
||
except Exception as e:
|
||
logger.error(f"❌ Failed to sync field {field_name} for customer {customer_id}: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@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...")
|
||
|
||
# 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;"
|
||
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
|
||
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()
|
||
})
|
||
|
||
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 = {}
|
||
for acc_data in accounts_with_economic:
|
||
company_name = acc_data['accountname'].strip()
|
||
economic_number = acc_data['economic_number']
|
||
|
||
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")
|
||
|
||
# Build set af eksisterende kunde-navne (normalized)
|
||
existing_customer_names = {c['name'].strip().lower() for c in hub_customers}
|
||
|
||
# Find customers der mangler i Hub
|
||
missing_customers = []
|
||
for name_key, data in name_to_economic.items():
|
||
if name_key not in existing_customer_names:
|
||
missing_customers.append(data)
|
||
|
||
logger.info(f"📊 Found {len(missing_customers)} customers missing in Hub")
|
||
|
||
# Opret manglende customers
|
||
for missing in missing_customers:
|
||
try:
|
||
execute_query(
|
||
"""INSERT INTO customers (name, economic_customer_number, created_at, updated_at)
|
||
VALUES (%s, %s, NOW(), NOW())""",
|
||
(missing['original_name'], missing['economic_customer_number'])
|
||
)
|
||
logger.info(f"➕ Created customer: {missing['original_name']} → e-conomic #{missing['economic_customer_number']}")
|
||
stats["hub_customers_created"] = stats.get("hub_customers_created", 0) + 1
|
||
except Exception as create_error:
|
||
logger.error(f"❌ Failed to create customer {missing['original_name']}: {create_error}")
|
||
stats["errors"] += 1
|
||
|
||
# Match og opdater eksisterende
|
||
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']} existing customers and created {stats.get('hub_customers_created', 0)} new customers 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))
|
||
|
||
|
||
@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}")
|
||
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}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@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
|
||
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 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))
|
||
|
||
|
||
@router.get("/customers/{customer_id}/contacts")
|
||
async def get_customer_contacts(customer_id: int):
|
||
"""Get all contacts for a specific customer"""
|
||
rows = execute_query("""
|
||
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",
|
||
(customer_id,))
|
||
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}")
|
||
|
||
# Log activity
|
||
contact_name = f"{contact.first_name} {contact.last_name}".strip()
|
||
CustomerActivityLogger.log_contact_added(customer_id, contact_name)
|
||
|
||
# Fetch and return created contact
|
||
created = execute_query_single(
|
||
"SELECT * FROM contacts WHERE id = %s",
|
||
(contact_id,))
|
||
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
|
||
|
||
|
||
@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
|
||
customer = execute_query_single(
|
||
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
|
||
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)
|
||
|
||
# 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)
|
||
logger.info(f"🔍 Simply-CRM raw query returned {len(all_simplycrm_orders or [])} orders for account {simplycrm_account_id}")
|
||
|
||
# Group line items by order ID
|
||
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
|
||
orders_dict = {}
|
||
filtered_closed = 0
|
||
filtered_no_freq = 0
|
||
for row in (all_simplycrm_orders or []):
|
||
status = row.get('sostatus', '').lower()
|
||
if status in ['closed', 'cancelled']:
|
||
filtered_closed += 1
|
||
logger.debug(f" ⏭️ Skipping closed order: {row.get('subject', 'N/A')} ({status})")
|
||
continue
|
||
|
||
# MUST have recurring_frequency to be a subscription
|
||
recurring_frequency = row.get('recurring_frequency', '').strip()
|
||
if not recurring_frequency:
|
||
filtered_no_freq += 1
|
||
logger.debug(f" ⏭️ Skipping order without frequency: {row.get('subject', 'N/A')}")
|
||
continue
|
||
|
||
logger.info(f" ✅ Including order: {row.get('subject', 'N/A')} - {recurring_frequency} ({status})")
|
||
|
||
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())
|
||
logger.info(f"📥 Found {len(simplycrm_sales_orders)} unique recurring orders in Simply-CRM (filtered out: {filtered_closed} closed, {filtered_no_freq} without frequency)")
|
||
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}")
|
||
|
||
# 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 []
|
||
|
||
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")
|
||
|
||
return {
|
||
"status": "success",
|
||
"customer_id": customer_id,
|
||
"customer_name": customer['name'],
|
||
"vtiger_id": vtiger_id,
|
||
"recurring_orders": recurring_orders,
|
||
"sales_orders": simplycrm_sales_orders, # Open sales orders from Simply-CRM
|
||
"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)}")
|
||
|
||
|
||
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",
|
||
(customer_id,))
|
||
|
||
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
|
||
)
|
||
|
||
# Log activity
|
||
subscription_name = subscription.subject or 'Nyt abonnement'
|
||
CustomerActivityLogger.log_subscription_created(customer_id, subscription_name)
|
||
|
||
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
|
||
)
|
||
|
||
# Log activity if we can find customer_id
|
||
if result and result.get('account_id'):
|
||
customer = execute_query_single(
|
||
"SELECT id, name FROM customers WHERE vtiger_id = %s",
|
||
(result['account_id'],))
|
||
if customer:
|
||
subscription_name = result.get('subject', subscription_id)
|
||
CustomerActivityLogger.log_subscription_updated(customer['id'], subscription_name)
|
||
|
||
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:
|
||
customer = execute_query_single(
|
||
"SELECT subscriptions_locked FROM customers WHERE id = %s",
|
||
(customer_id,))
|
||
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
|
||
)
|
||
|
||
# Log activity
|
||
if customer_id:
|
||
subscription_name = result.get('subject', subscription_id) if result else subscription_id
|
||
CustomerActivityLogger.log_subscription_deleted(customer_id, subscription_name)
|
||
|
||
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))
|
||
|
||
|
||
# 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))
|