358 lines
10 KiB
Python
358 lines
10 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
|