bmc_hub/app/customers/backend/router.py
Christian 361f2fad5d feat: Implement vTiger integration for subscriptions and sales orders
- Added a new VTigerService class for handling API interactions with vTiger CRM.
- Implemented methods to fetch customer subscriptions and sales orders.
- Created a new database migration for BMC Office subscriptions, including table structure and view for totals.
- Enhanced customer detail frontend to display subscriptions and sales orders with improved UI/UX.
- Added JavaScript functions for loading and displaying subscription data dynamically.
- Created tests for vTiger API queries and field inspections to ensure data integrity and functionality.
2025-12-11 23:14:20 +01:00

463 lines
14 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_insert, execute_update
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
"""
# Build query
query = """
SELECT
c.*,
COUNT(DISTINCT cc.contact_id) as contact_count
FROM customers c
LEFT JOIN contact_companies cc ON cc.customer_id = c.id
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
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(count_query, tuple(count_params), fetchone=True)
total = count_result['total'] if count_result else 0
return {
"customers": rows or [],
"total": total,
"limit": limit,
"offset": offset
}
@router.get("/customers/{customer_id}")
async def get_customer(customer_id: int):
"""Get single customer by ID with contact count"""
# Get customer
customer = execute_query(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
# Get contact count
contact_count_result = execute_query(
"SELECT COUNT(*) as count FROM contact_companies WHERE customer_id = %s",
(customer_id,),
fetchone=True
)
contact_count = contact_count_result['count'] if contact_count_result else 0
return {
**customer,
'contact_count': contact_count
}
@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
created = execute_query(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
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(
"SELECT id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
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
updated = execute_query(
"SELECT * FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
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}/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,),
fetchone=True
)
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
created = execute_query(
"SELECT * FROM contacts WHERE id = %s",
(contact_id,),
fetchone=True
)
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(
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
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 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)} total open 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": all_open_orders, # Show ALL open sales orders
"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)}")