2025-12-06 02:22:01 +01:00
|
|
|
|
"""
|
|
|
|
|
|
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):
|
2025-12-13 12:06:28 +01:00
|
|
|
|
"""Get single customer by ID with contact count and vTiger BMC Låst status"""
|
2025-12-06 02:22:01 +01:00
|
|
|
|
# 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
|
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
|
# 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}")
|
|
|
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
|
return {
|
|
|
|
|
|
**customer,
|
2025-12-13 12:06:28 +01:00
|
|
|
|
'contact_count': contact_count,
|
|
|
|
|
|
'bmc_locked': bmc_locked
|
2025-12-06 02:22:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
|
@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(
|
|
|
|
|
|
"SELECT id, name FROM customers WHERE id = %s",
|
|
|
|
|
|
(customer_id,),
|
|
|
|
|
|
fetchone=True
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-06 02:22:01 +01:00
|
|
|
|
@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
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
|
|
|
|
|
# Group line items by order ID
|
|
|
|
|
|
# Filter: Only include orders with recurring_frequency (otherwise not subscription)
|
|
|
|
|
|
orders_dict = {}
|
|
|
|
|
|
for row in (all_simplycrm_orders or []):
|
|
|
|
|
|
status = row.get('sostatus', '').lower()
|
|
|
|
|
|
if status in ['closed', 'cancelled']:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# MUST have recurring_frequency to be a subscription
|
|
|
|
|
|
recurring_frequency = row.get('recurring_frequency', '').strip()
|
|
|
|
|
|
if not recurring_frequency:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
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 open sales orders in Simply-CRM")
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
2025-12-11 23:14:20 +01:00
|
|
|
|
# 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 []
|
|
|
|
|
|
|
2025-12-13 12:06:28 +01:00
|
|
|
|
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")
|
2025-12-11 23:14:20 +01:00
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
"status": "success",
|
|
|
|
|
|
"customer_id": customer_id,
|
|
|
|
|
|
"customer_name": customer['name'],
|
|
|
|
|
|
"vtiger_id": vtiger_id,
|
|
|
|
|
|
"recurring_orders": recurring_orders,
|
2025-12-13 12:06:28 +01:00
|
|
|
|
"sales_orders": simplycrm_sales_orders, # Open sales orders from Simply-CRM
|
2025-12-11 23:14:20 +01:00
|
|
|
|
"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)}")
|
2025-12-13 12:06:28 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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,),
|
|
|
|
|
|
fetchone=True
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
|
"SELECT subscriptions_locked FROM customers WHERE id = %s",
|
|
|
|
|
|
(customer_id,),
|
|
|
|
|
|
fetchone=True
|
|
|
|
|
|
)
|
|
|
|
|
|
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))
|