- Added EconomicService.search_customer_by_name() method
- Added GET /api/v1/customers/{id}/search-economic endpoint
- Returns matching e-conomic customers by name (partial match)
- Helps find economic customer number for customers without CVR
- Shows customerNumber, name, CVR, email, city in results
883 lines
33 KiB
Python
883 lines
33 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
|
||
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 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/{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}")
|
||
|
||
# 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 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)
|
||
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_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.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_single("""
|
||
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}")
|
||
|
||
# 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
|
||
)
|
||
|
||
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:
|
||
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
|
||
)
|
||
|
||
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))
|