""" 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}/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))