feat: Implement vTiger integration for subscriptions and sales orders

- Added a new VTigerService class for handling API interactions with vTiger CRM.
- Implemented methods to fetch customer subscriptions and sales orders.
- Created a new database migration for BMC Office subscriptions, including table structure and view for totals.
- Enhanced customer detail frontend to display subscriptions and sales orders with improved UI/UX.
- Added JavaScript functions for loading and displaying subscription data dynamically.
- Created tests for vTiger API queries and field inspections to ensure data integrity and functionality.
This commit is contained in:
Christian 2025-12-11 23:14:20 +01:00
parent c4c9b8a04a
commit 361f2fad5d
8 changed files with 1173 additions and 6 deletions

View File

@ -355,3 +355,108 @@ async def lookup_cvr(cvr_number: str):
raise HTTPException(status_code=404, detail="CVR number not found") raise HTTPException(status_code=404, detail="CVR number not found")
return result return result
@router.get("/customers/{customer_id}/subscriptions")
async def get_customer_subscriptions(customer_id: int):
"""
Get subscriptions and sales orders for a customer
Returns data from vTiger:
1. Recurring Sales Orders (enable_recurring = 1)
2. Sales Orders with recurring_frequency (open status)
3. Recent Invoices for context
"""
from app.services.vtiger_service import get_vtiger_service
# Get customer with vTiger ID
customer = execute_query(
"SELECT id, name, vtiger_id FROM customers WHERE id = %s",
(customer_id,),
fetchone=True
)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
vtiger_id = customer.get('vtiger_id')
if not vtiger_id:
logger.warning(f"⚠️ Customer {customer_id} has no vTiger ID")
return {
"status": "no_vtiger_link",
"message": "Kunde er ikke synkroniseret med vTiger",
"recurring_orders": [],
"sales_orders": [],
"invoices": []
}
try:
vtiger = get_vtiger_service()
# Fetch all sales orders
logger.info(f"🔍 Fetching sales orders for vTiger account {vtiger_id}")
all_orders = await vtiger.get_customer_sales_orders(vtiger_id)
# Fetch subscriptions from vTiger
logger.info(f"🔍 Fetching subscriptions for vTiger account {vtiger_id}")
subscriptions = await vtiger.get_customer_subscriptions(vtiger_id)
# Filter sales orders into categories
recurring_orders = []
frequency_orders = []
all_open_orders = []
for order in all_orders:
# Skip closed/cancelled orders
status = order.get('sostatus', '').lower()
if status in ['closed', 'cancelled']:
continue
all_open_orders.append(order)
# Check if recurring is enabled
enable_recurring = order.get('enable_recurring')
recurring_frequency = order.get('recurring_frequency', '').strip()
if enable_recurring == '1' or enable_recurring == 1:
recurring_orders.append(order)
elif recurring_frequency:
frequency_orders.append(order)
# Filter subscriptions by status
active_subscriptions = []
expired_subscriptions = []
for sub in subscriptions:
status = sub.get('sub_status', '').lower()
if status in ['cancelled', 'expired']:
expired_subscriptions.append(sub)
else:
active_subscriptions.append(sub)
# Fetch BMC Office subscriptions from local database
bmc_office_query = """
SELECT * FROM bmc_office_subscription_totals
WHERE customer_id = %s AND active = true
ORDER BY start_date DESC
"""
bmc_office_subs = execute_query(bmc_office_query, (customer_id,)) or []
logger.info(f"✅ Found {len(recurring_orders)} recurring orders, {len(frequency_orders)} frequency orders, {len(all_open_orders)} total open orders, {len(active_subscriptions)} active subscriptions, {len(bmc_office_subs)} BMC Office subscriptions")
return {
"status": "success",
"customer_id": customer_id,
"customer_name": customer['name'],
"vtiger_id": vtiger_id,
"recurring_orders": recurring_orders,
"sales_orders": all_open_orders, # Show ALL open sales orders
"subscriptions": active_subscriptions, # Active subscriptions from vTiger Subscriptions module
"expired_subscriptions": expired_subscriptions,
"bmc_office_subscriptions": bmc_office_subs, # Local BMC Office subscriptions
"last_updated": "real-time"
}
except Exception as e:
logger.error(f"❌ Error fetching subscriptions: {e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch subscriptions: {str(e)}")

View File

@ -105,6 +105,53 @@
position: relative; position: relative;
} }
.subscription-item {
transition: all 0.3s ease;
cursor: pointer;
}
.subscription-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12) !important;
}
.subscription-item.expanded {
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15) !important;
border-color: var(--accent) !important;
}
.subscription-column {
min-height: 200px;
}
.column-header {
position: sticky;
top: 0;
z-index: 10;
background: white;
padding: 1rem;
margin: -1rem -1rem 1rem -1rem;
border-radius: 8px 8px 0 0;
}
.line-item-details {
background: rgba(0, 0, 0, 0.02);
border-radius: 6px;
padding: 0.5rem;
}
.expandable-item {
transition: all 0.2s;
}
.expandable-item:hover {
background: rgba(0, 0, 0, 0.02);
}
.chevron-icon {
transition: transform 0.2s;
}
.activity-item::before { .activity-item::before {
content: ''; content: '';
position: absolute; position: absolute;
@ -165,6 +212,11 @@
<i class="bi bi-receipt"></i>Fakturaer <i class="bi bi-receipt"></i>Fakturaer
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#subscriptions">
<i class="bi bi-arrow-repeat"></i>Abonnementer
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#hardware"> <a class="nav-link" data-bs-toggle="tab" href="#hardware">
<i class="bi bi-hdd"></i>Hardware <i class="bi bi-hdd"></i>Hardware
@ -297,6 +349,23 @@
</div> </div>
</div> </div>
<!-- Subscriptions Tab -->
<div class="tab-pane fade" id="subscriptions">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Abonnementer & Salgsordre</h5>
<button class="btn btn-primary btn-sm" onclick="loadSubscriptions()">
<i class="bi bi-arrow-repeat me-2"></i>Opdater fra vTiger
</button>
</div>
<div id="subscriptionsContainer">
<div class="text-center py-5">
<div class="spinner-border text-primary"></div>
<p class="text-muted mt-3">Henter data fra vTiger...</p>
</div>
</div>
</div>
<!-- Hardware Tab --> <!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware"> <div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5> <h5 class="fw-bold mb-4">Hardware</h5>
@ -324,18 +393,41 @@
const customerId = parseInt(window.location.pathname.split('/').pop()); const customerId = parseInt(window.location.pathname.split('/').pop());
let customerData = null; let customerData = null;
let eventListenersAdded = false;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
if (eventListenersAdded) {
console.log('Event listeners already added, skipping...');
return;
}
loadCustomer(); loadCustomer();
// Load contacts when tab is shown // Load contacts when tab is shown
document.querySelector('a[href="#contacts"]').addEventListener('shown.bs.tab', () => { const contactsTab = document.querySelector('a[href="#contacts"]');
loadContacts(); if (contactsTab) {
}); contactsTab.addEventListener('shown.bs.tab', () => {
loadContacts();
}, { once: false });
}
// Load subscriptions when tab is shown
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
loadSubscriptions();
}, { once: false });
}
// Load activity when tab is shown // Load activity when tab is shown
document.querySelector('a[href="#activity"]').addEventListener('shown.bs.tab', () => { const activityTab = document.querySelector('a[href="#activity"]');
loadActivity(); if (activityTab) {
}); activityTab.addEventListener('shown.bs.tab', () => {
loadActivity();
}, { once: false });
}
eventListenersAdded = true;
}); });
async function loadCustomer() { async function loadCustomer() {
@ -458,6 +550,487 @@ async function loadContacts() {
} }
} }
let subscriptionsLoaded = false;
async function loadSubscriptions() {
const container = document.getElementById('subscriptionsContainer');
// Prevent duplicate loads
if (subscriptionsLoaded && container.innerHTML.includes('row g-4')) {
console.log('Subscriptions already loaded, skipping...');
return;
}
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div><p class="text-muted mt-3">Henter data fra vTiger...</p></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions`);
const data = await response.json();
console.log('Loaded subscriptions:', data);
if (!response.ok) {
throw new Error(data.detail || 'Failed to load subscriptions');
}
if (data.status === 'no_vtiger_link') {
container.innerHTML = `
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
${data.message}
</div>
`;
return;
}
displaySubscriptions(data);
subscriptionsLoaded = true;
} catch (error) {
console.error('Failed to load subscriptions:', error);
container.innerHTML = '<div class="alert alert-danger">Kunne ikke indlæse abonnementer</div>';
}
}
function displaySubscriptions(data) {
const container = document.getElementById('subscriptionsContainer');
const { recurring_orders, sales_orders, subscriptions, expired_subscriptions, bmc_office_subscriptions } = data;
const totalItems = (sales_orders?.length || 0) + (subscriptions?.length || 0) + (bmc_office_subscriptions?.length || 0);
if (totalItems === 0) {
container.innerHTML = '<div class="text-center text-muted py-5">Ingen abonnementer eller salgsordre fundet</div>';
return;
}
// Create 3-column layout
let html = '<div class="row g-3">';
// Column 1: vTiger Subscriptions
html += `
<div class="col-lg-4">
<div class="subscription-column">
<div class="column-header bg-primary bg-opacity-10 border-start border-primary border-4 p-3 mb-3 rounded">
<h5 class="fw-bold mb-1">
<i class="bi bi-arrow-repeat text-primary me-2"></i>
vTiger Abonnementer
</h5>
<small class="text-muted">Fra Simply-CRM</small>
</div>
${renderSubscriptionsList(subscriptions || [])}
</div>
</div>
`;
// Column 2: Sales Orders
html += `
<div class="col-lg-4">
<div class="subscription-column">
<div class="column-header bg-success bg-opacity-10 border-start border-success border-4 p-3 mb-3 rounded">
<h5 class="fw-bold mb-1">
<i class="bi bi-cart-check text-success me-2"></i>
Åbne Salgsordre
</h5>
<small class="text-muted">Fra Simply-CRM</small>
</div>
${renderSalesOrdersList(sales_orders || [])}
</div>
</div>
`;
// Column 3: BMC Office Subscriptions
html += `
<div class="col-lg-4">
<div class="subscription-column">
<div class="column-header bg-info bg-opacity-10 border-start border-info border-4 p-3 mb-3 rounded">
<h5 class="fw-bold mb-1">
<i class="bi bi-database text-info me-2"></i>
BMC Office Abonnementer
</h5>
<small class="text-muted">Fra lokalt system</small>
</div>
${renderBmcOfficeSubscriptionsList(bmc_office_subscriptions || [])}
</div>
</div>
`;
html += '</div>';
// Add comparison stats at bottom
const subTotal = subscriptions?.reduce((sum, sub) => sum + parseFloat(sub.hdnGrandTotal || 0), 0) || 0;
const orderTotal = sales_orders?.reduce((sum, order) => sum + parseFloat(order.hdnGrandTotal || 0), 0) || 0;
const bmcOfficeTotal = bmc_office_subscriptions?.reduce((sum, sub) => sum + parseFloat(sub.total_inkl_moms || 0), 0) || 0;
if (totalItems > 0) {
html += `
<div class="row g-4 mt-2">
<div class="col-12">
<div class="info-card bg-light">
<h6 class="fw-bold mb-3">Sammenligning</h6>
<div class="row text-center">
<div class="col-md-3">
<div class="stat-item">
<div class="text-muted small">vTiger Abonnementer</div>
<div class="fs-4 fw-bold text-primary">${subscriptions?.length || 0}</div>
<div class="text-muted small">${subTotal.toFixed(2)} DKK</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="text-muted small">Åbne Salgsordre</div>
<div class="fs-4 fw-bold text-success">${sales_orders?.length || 0}</div>
<div class="text-muted small">${orderTotal.toFixed(2)} DKK</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="text-muted small">BMC Office Abonnementer</div>
<div class="fs-4 fw-bold text-info">${bmc_office_subscriptions?.length || 0}</div>
<div class="text-muted small">${bmcOfficeTotal.toFixed(2)} DKK</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-item">
<div class="text-muted small">Total Værdi</div>
<div class="fs-4 fw-bold text-dark">${(subTotal + orderTotal + bmcOfficeTotal).toFixed(2)} DKK</div>
<div class="text-muted small">Samlet omsætning</div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
}
container.innerHTML = html;
}
function renderRecurringOrdersList(orders) {
if (!orders || orders.length === 0) {
return '<p class="text-muted small">Ingen tilbagevendende ordrer</p>';
}
return orders.map((order, idx) => {
const itemId = `recurring-${idx}`;
const lineItems = order.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
return `
<div class="border-bottom pb-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="fw-bold">
<i class="bi bi-chevron-right me-1" id="${itemId}-icon"></i>
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
</div>
<span class="badge bg-${getStatusColor(order.sostatus)}">${escapeHtml(order.sostatus || 'Open')}</span>
</div>
${order.description ? `<p class="text-muted small mb-2">${escapeHtml(order.description).substring(0, 100)}...</p>` : ''}
<div class="d-flex gap-3 small text-muted flex-wrap">
${order.recurring_frequency ? `<span><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
${order.start_period ? `<span><i class="bi bi-calendar-event me-1"></i>${formatDate(order.start_period)}</span>` : ''}
${order.end_period ? `<span><i class="bi bi-calendar-x me-1"></i>${formatDate(order.end_period)}</span>` : ''}
${order.hdnGrandTotal ? `<span><i class="bi bi-currency-dollar me-1"></i>${parseFloat(order.hdnGrandTotal).toFixed(2)} DKK</span>` : ''}
</div>
${hasLineItems ? `
<div id="${itemId}-lines" class="mt-3 ps-3" style="display: none;">
<div class="small">
<strong>Produktlinjer:</strong>
${lineItems.map(line => `
<div class="border-start border-2 border-primary ps-2 py-1 mt-2">
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
<div class="text-muted">
Antal: ${line.quantity || 0} × ${parseFloat(line.listprice || 0).toFixed(2)} DKK =
<strong>${parseFloat(line.netprice || 0).toFixed(2)} DKK</strong>
</div>
${line.comment ? `<div class="text-muted small">${escapeHtml(line.comment)}</div>` : ''}
</div>
`).join('')}
</div>
</div>
` : ''}
</div>
`;
}).join('');
}
function renderSalesOrdersList(orders) {
if (!orders || orders.length === 0) {
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen salgsordre</div>';
}
return orders.map((order, idx) => {
const itemId = `salesorder-${idx}`;
const lineItems = order.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
const total = parseFloat(order.hdnGrandTotal || 0);
return `
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="flex-grow-1">
<div class="fw-bold d-flex align-items-center">
<i class="bi bi-chevron-right me-2 text-success" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
${escapeHtml(order.subject || order.salesorder_no || 'Unnamed')}
</div>
<div class="small text-muted mt-1">
${order.salesorder_no ? `#${escapeHtml(order.salesorder_no)}` : ''}
</div>
</div>
<div class="text-end ms-3">
<div class="badge bg-${getStatusColor(order.sostatus)} mb-1">${escapeHtml(order.sostatus || 'Open')}</div>
<div class="fw-bold text-success">${total.toFixed(2)} DKK</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
${order.recurring_frequency ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(order.recurring_frequency)}</span>` : ''}
</div>
${hasLineItems ? `
<div id="${itemId}-lines" class="mt-3 pt-3 border-top" style="display: none;">
<div class="small">
${lineItems.map(line => `
<div class="d-flex justify-content-between align-items-start py-2 border-bottom">
<div class="flex-grow-1">
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
<div class="text-muted small">
${line.quantity || 0} stk × ${parseFloat(line.listprice || 0).toFixed(2)} DKK
</div>
</div>
<div class="text-end fw-bold">
${parseFloat(line.netprice || 0).toFixed(2)} DKK
</div>
</div>
`).join('')}
<div class="d-flex justify-content-between mt-3 pt-2">
<span class="text-muted">Subtotal:</span>
<strong>${parseFloat(order.hdnSubTotal || 0).toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-success fw-bold fs-5">
<span>Total inkl. moms:</span>
<strong>${total.toFixed(2)} DKK</strong>
</div>
</div>
</div>
` : ''}
</div>
`;
}).join('');
}
function renderBmcOfficeSubscriptionsList(subscriptions) {
if (!subscriptions || subscriptions.length === 0) {
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen BMC Office abonnementer</div>';
}
return subscriptions.map((sub, idx) => {
const itemId = `bmcoffice-${idx}`;
const total = parseFloat(sub.total_inkl_moms || 0);
const subtotal = parseFloat(sub.subtotal || 0);
const rabat = parseFloat(sub.rabat || 0);
return `
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-2">
<div class="flex-grow-1">
<div class="fw-bold d-flex align-items-center">
<i class="bi bi-box-seam text-info me-2" style="font-size: 0.8rem;"></i>
${escapeHtml(sub.text || 'Unnamed')}
</div>
<div class="small text-muted mt-1">
${sub.firma_name ? `${escapeHtml(sub.firma_name)}` : ''}
</div>
</div>
<div class="text-end ms-3">
<div class="badge bg-${sub.active ? 'success' : 'secondary'} mb-1">${sub.active ? 'Aktiv' : 'Inaktiv'}</div>
<div class="fw-bold text-info">${total.toFixed(2)} DKK</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
${sub.start_date ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(sub.start_date)}</span>` : ''}
${sub.antal ? `<span class="badge bg-light text-dark"><i class="bi bi-stack me-1"></i>Antal: ${sub.antal}</span>` : ''}
${sub.faktura_firma_name && sub.faktura_firma_name !== sub.firma_name ? `<span class="badge bg-light text-dark"><i class="bi bi-receipt me-1"></i>${escapeHtml(sub.faktura_firma_name)}</span>` : ''}
</div>
<div class="mt-3 pt-3 border-top">
<div class="small">
<div class="d-flex justify-content-between py-1">
<span class="text-muted">Antal:</span>
<strong>${sub.antal}</strong>
</div>
<div class="d-flex justify-content-between py-1">
<span class="text-muted">Pris pr. stk:</span>
<strong>${parseFloat(sub.pris || 0).toFixed(2)} DKK</strong>
</div>
${rabat > 0 ? `
<div class="d-flex justify-content-between py-1 text-danger">
<span>Rabat:</span>
<strong>-${rabat.toFixed(2)} DKK</strong>
</div>
` : ''}
${sub.beskrivelse ? `
<div class="py-1 text-muted small">
<em>${escapeHtml(sub.beskrivelse)}</em>
</div>
` : ''}
<div class="d-flex justify-content-between mt-2 pt-2 border-top">
<span class="text-muted">Subtotal:</span>
<strong>${subtotal.toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-info fw-bold fs-5">
<span>Total inkl. moms:</span>
<strong>${total.toFixed(2)} DKK</strong>
</div>
</div>
</div>
</div>
`;
}).join('');
}
function renderSubscriptionsList(subscriptions) {
if (!subscriptions || subscriptions.length === 0) {
return '<div class="text-center text-muted py-4"><i class="bi bi-inbox fs-1 d-block mb-2"></i>Ingen abonnementer</div>';
}
return subscriptions.map((sub, idx) => {
const itemId = `subscription-${idx}`;
const lineItems = sub.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
const total = parseFloat(sub.hdnGrandTotal || 0);
return `
<div class="subscription-item border rounded p-3 mb-3 bg-white shadow-sm">
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="flex-grow-1">
<div class="fw-bold d-flex align-items-center">
<i class="bi bi-chevron-right me-2 text-primary" id="${itemId}-icon" style="font-size: 0.8rem;"></i>
${escapeHtml(sub.subject || sub.subscription_no || 'Unnamed')}
</div>
<div class="small text-muted mt-1">
${sub.subscription_no ? `#${escapeHtml(sub.subscription_no)}` : ''}
</div>
</div>
<div class="text-end ms-3">
<div class="badge bg-${getStatusColor(sub.subscriptionstatus)} mb-1">${escapeHtml(sub.subscriptionstatus || 'Active')}</div>
<div class="fw-bold text-primary">${total.toFixed(2)} DKK</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap small text-muted mt-2">
${sub.generateinvoiceevery ? `<span class="badge bg-light text-dark"><i class="bi bi-arrow-repeat me-1"></i>${escapeHtml(sub.generateinvoiceevery)}</span>` : ''}
${sub.startdate && sub.enddate ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-range me-1"></i>${formatDate(sub.startdate)} - ${formatDate(sub.enddate)}</span>` : ''}
${sub.startdate ? `<span class="badge bg-light text-dark"><i class="bi bi-calendar-check me-1"></i>Start: ${formatDate(sub.startdate)}</span>` : ''}
</div>
${hasLineItems ? `
<div id="${itemId}-lines" class="mt-3 pt-3 border-top" style="display: none;">
<div class="small">
${lineItems.map(line => `
<div class="d-flex justify-content-between align-items-start py-2 border-bottom">
<div class="flex-grow-1">
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
<div class="text-muted small">
${line.quantity || 0} stk × ${parseFloat(line.listprice || 0).toFixed(2)} DKK
</div>
</div>
<div class="text-end fw-bold">
${parseFloat(line.netprice || 0).toFixed(2)} DKK
</div>
</div>
`).join('')}
<div class="d-flex justify-content-between mt-3 pt-2">
<span class="text-muted">Subtotal:</span>
<strong>${parseFloat(sub.hdnSubTotal || 0).toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-primary fw-bold fs-5">
<span>Total inkl. moms:</span>
<strong>${total.toFixed(2)} DKK</strong>
</div>
</div>
</div>
` : ''}
</div>
`;
}).join('');
}
function renderInvoicesList(invoices) {
if (!invoices || invoices.length === 0) {
return '<p class="text-muted small">Ingen fakturaer</p>';
}
return invoices.map((inv, idx) => {
const itemId = `regular-invoice-${idx}`;
const lineItems = inv.lineItems || [];
const hasLineItems = Array.isArray(lineItems) && lineItems.length > 0;
return `
<div class="border-bottom pb-3 mb-3">
<div class="d-flex justify-content-between align-items-start mb-2" style="cursor: pointer;" onclick="toggleLineItems('${itemId}')">
<div class="fw-bold">
<i class="bi bi-chevron-right me-1" id="${itemId}-icon"></i>
${escapeHtml(inv.subject || inv.invoice_no || 'Unnamed')}
</div>
<span class="badge bg-${getStatusColor(inv.invoicestatus)}">${escapeHtml(inv.invoicestatus || 'Draft')}</span>
</div>
<div class="d-flex gap-3 small text-muted flex-wrap">
${inv.invoicedate ? `<span><i class="bi bi-calendar me-1"></i>${formatDate(inv.invoicedate)}</span>` : ''}
${inv.invoice_no ? `<span><i class="bi bi-hash me-1"></i>${escapeHtml(inv.invoice_no)}</span>` : ''}
${inv.hdnGrandTotal ? `<span><i class="bi bi-currency-dollar me-1"></i>${parseFloat(inv.hdnGrandTotal).toFixed(2)} DKK</span>` : ''}
</div>
${hasLineItems ? `
<div id="${itemId}-lines" class="mt-3 ps-3" style="display: none;">
<div class="small">
<strong>Produktlinjer:</strong>
${lineItems.map(line => `
<div class="border-start border-2 border-secondary ps-2 py-1 mt-2">
<div class="fw-bold">${escapeHtml(line.product_name || line.productid)}</div>
<div class="text-muted">
Antal: ${line.quantity || 0} × ${parseFloat(line.listprice || 0).toFixed(2)} DKK =
<strong>${parseFloat(line.netprice || 0).toFixed(2)} DKK</strong>
</div>
${line.comment ? `<div class="text-muted small">${escapeHtml(line.comment)}</div>` : ''}
</div>
`).join('')}
<div class="border-top mt-2 pt-2">
<div class="d-flex justify-content-between">
<span>Subtotal:</span>
<strong>${parseFloat(inv.hdnSubTotal || 0).toFixed(2)} DKK</strong>
</div>
<div class="d-flex justify-content-between text-info fw-bold">
<span>Total inkl. moms:</span>
<strong>${parseFloat(inv.hdnGrandTotal || 0).toFixed(2)} DKK</strong>
</div>
</div>
</div>
</div>
` : ''}
</div>
`;
}).join('');
}
function getStatusColor(status) {
if (!status) return 'secondary';
const s = status.toLowerCase();
if (s.includes('active') || s.includes('approved')) return 'success';
if (s.includes('pending')) return 'warning';
if (s.includes('cancelled') || s.includes('expired')) return 'danger';
return 'info';
}
function formatDate(dateStr) {
if (!dateStr) return '';
try {
const date = new Date(dateStr);
return date.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return dateStr;
}
}
async function loadActivity() { async function loadActivity() {
const container = document.getElementById('activityContainer'); const container = document.getElementById('activityContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>'; container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
@ -468,6 +1041,28 @@ async function loadActivity() {
}, 500); }, 500);
} }
function toggleLineItems(itemId) {
const linesDiv = document.getElementById(`${itemId}-lines`);
const icon = document.getElementById(`${itemId}-icon`);
if (!linesDiv) return;
const item = linesDiv.closest('.subscription-item');
if (linesDiv.style.display === 'none') {
linesDiv.style.display = 'block';
if (icon) icon.className = 'bi bi-chevron-down me-2 text-primary';
if (item) item.classList.add('expanded');
} else {
linesDiv.style.display = 'none';
if (icon) {
const isSubscription = itemId.includes('subscription');
icon.className = `bi bi-chevron-right me-2 ${isSubscription ? 'text-primary' : 'text-success'}`;
}
if (item) item.classList.remove('expanded');
}
}
function editCustomer() { function editCustomer() {
// TODO: Open edit modal with pre-filled data // TODO: Open edit modal with pre-filled data
console.log('Edit customer:', customerId); console.log('Edit customer:', customerId);

View File

@ -0,0 +1,190 @@
"""
vTiger Cloud CRM Integration Service
Handles subscription and sales order data retrieval
"""
import logging
import aiohttp
from typing import List, Dict, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
class VTigerService:
"""Service for integrating with vTiger Cloud CRM via REST API"""
def __init__(self):
self.base_url = getattr(settings, 'VTIGER_URL', None)
self.username = getattr(settings, 'VTIGER_USERNAME', None)
self.api_key = getattr(settings, 'VTIGER_API_KEY', None)
# REST API endpoint
if self.base_url:
self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default"
else:
self.rest_endpoint = None
if not all([self.base_url, self.username, self.api_key]):
logger.warning("⚠️ vTiger credentials not fully configured")
def _get_auth(self):
"""Get HTTP Basic Auth credentials"""
if not self.api_key:
raise ValueError("VTIGER_API_KEY not configured")
return aiohttp.BasicAuth(self.username, self.api_key)
async def query(self, query_string: str) -> List[Dict]:
"""
Execute a query on vTiger REST API
Args:
query_string: SQL-like query (e.g., "SELECT * FROM Accounts;")
Returns:
List of records
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
auth = self._get_auth()
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/query",
params={"query": query_string},
auth=auth
) as response:
text = await response.text()
if response.status == 200:
# vTiger returns text/json instead of application/json
import json
try:
data = json.loads(text)
except json.JSONDecodeError as e:
logger.error(f"❌ Invalid JSON in query response: {text[:200]}")
return []
if data.get('success'):
result = data.get('result', [])
logger.info(f"✅ Query returned {len(result)} records")
return result
else:
logger.error(f"❌ vTiger query failed: {data.get('error')}")
return []
else:
logger.error(f"❌ vTiger query HTTP error {response.status}")
logger.error(f"Query: {query_string}")
logger.error(f"Response: {text[:500]}")
return []
except Exception as e:
logger.error(f"❌ vTiger query error: {e}")
return []
async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]:
"""
Fetch sales orders for a customer from vTiger
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
List of sales order records
"""
if not vtiger_account_id:
logger.warning("⚠️ No vTiger account ID provided")
return []
try:
# Query for sales orders linked to this account
query = f"SELECT * FROM SalesOrder WHERE account_id='{vtiger_account_id}';"
logger.info(f"🔍 Fetching sales orders for vTiger account {vtiger_account_id}")
orders = await self.query(query)
logger.info(f"✅ Found {len(orders)} sales orders")
return orders
except Exception as e:
logger.error(f"❌ Error fetching sales orders: {e}")
return []
async def get_customer_subscriptions(self, vtiger_account_id: str) -> List[Dict]:
"""
Fetch subscriptions for a customer from vTiger
Args:
vtiger_account_id: vTiger account ID (e.g., "3x760")
Returns:
List of subscription records
"""
if not vtiger_account_id:
logger.warning("⚠️ No vTiger account ID provided")
return []
try:
# Query for subscriptions linked to this account (note: module name is singular "Subscription")
query = f"SELECT * FROM Subscription WHERE account_id='{vtiger_account_id}';"
logger.info(f"🔍 Fetching subscriptions for vTiger account {vtiger_account_id}")
subscriptions = await self.query(query)
logger.info(f"✅ Found {len(subscriptions)} subscriptions")
return subscriptions
except Exception as e:
logger.error(f"❌ Error fetching subscriptions: {e}")
return []
async def test_connection(self) -> bool:
"""
Test vTiger connection using /me endpoint
Returns:
True if connection successful
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured in .env")
try:
auth = self._get_auth()
logger.info(f"🔑 Testing vTiger connection...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/me",
auth=auth
) as response:
if response.status == 200:
# vTiger returns text/json instead of application/json
text = await response.text()
import json
data = json.loads(text)
if data.get('success'):
user_name = data['result'].get('user_name')
logger.info(f"✅ vTiger connection successful (user: {user_name})")
return True
else:
logger.error(f"❌ vTiger API returned success=false: {data}")
return False
else:
error_text = await response.text()
logger.error(f"❌ vTiger connection failed: HTTP {response.status}: {error_text}")
return False
except Exception as e:
logger.error(f"❌ vTiger connection error: {e}")
return False
# Singleton instance
_vtiger_service = None
def get_vtiger_service() -> VTigerService:
"""Get or create vTiger service singleton"""
global _vtiger_service
if _vtiger_service is None:
_vtiger_service = VTigerService()
return _vtiger_service

View File

@ -0,0 +1,59 @@
-- BMC Office Subscriptions
-- Gemmer abonnementsdata importeret fra BMC Office system
CREATE TABLE IF NOT EXISTS bmc_office_subscriptions (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
-- BMC Office data
firma_id VARCHAR(50), -- FirmaID fra BMC Office
firma_name VARCHAR(255), -- Firma navn
start_date DATE, -- Startdate
text VARCHAR(500), -- Produkt/service beskrivelse
antal DECIMAL(10,2) DEFAULT 1, -- Antal
pris DECIMAL(10,2), -- Pris per enhed
rabat DECIMAL(10,2) DEFAULT 0, -- Rabat i DKK
beskrivelse TEXT, -- Ekstra beskrivelse/noter
-- Faktura info
faktura_firma_id VARCHAR(50), -- FakturaFirmaID
faktura_firma_name VARCHAR(255), -- Fakturafirma navn
-- Status
active BOOLEAN DEFAULT TRUE,
-- Metadata
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_bmc_office_subs_customer ON bmc_office_subscriptions(customer_id);
CREATE INDEX IF NOT EXISTS idx_bmc_office_subs_firma_id ON bmc_office_subscriptions(firma_id);
CREATE INDEX IF NOT EXISTS idx_bmc_office_subs_faktura_firma_id ON bmc_office_subscriptions(faktura_firma_id);
CREATE INDEX IF NOT EXISTS idx_bmc_office_subs_active ON bmc_office_subscriptions(active);
-- View for calculating totals
CREATE OR REPLACE VIEW bmc_office_subscription_totals AS
SELECT
id,
customer_id,
firma_id,
firma_name,
text,
antal,
pris,
rabat,
(antal * pris) - rabat AS subtotal,
((antal * pris) - rabat) * 1.25 AS total_inkl_moms,
start_date,
beskrivelse,
faktura_firma_name,
active
FROM bmc_office_subscriptions
WHERE deleted_at IS NULL;
COMMENT ON TABLE bmc_office_subscriptions IS 'Abonnementer importeret fra BMC Office legacy system';
COMMENT ON VIEW bmc_office_subscription_totals IS 'Beregner subtotal og total inkl. moms for BMC Office abonnementer';

View File

@ -0,0 +1,34 @@
import asyncio
import aiohttp
import json
import os
from dotenv import load_dotenv
load_dotenv()
async def test_vtiger():
base_url = os.getenv('VTIGER_URL')
username = os.getenv('VTIGER_USERNAME')
api_key = os.getenv('VTIGER_API_KEY')
auth = aiohttp.BasicAuth(username, api_key)
# Test query with singular "Subscription"
vtiger_id = '3x760'
query = f"SELECT * FROM Subscription WHERE account_id='{vtiger_id}';"
print(f"🔍 Testing query: {query}")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{base_url}/restapi/v1/vtiger/default/query",
params={"query": query},
auth=auth
) as response:
text = await response.text()
print(f"Status: {response.status}")
print(f"Response: {text[:500]}")
if response.status == 200:
data = json.loads(text)
print(json.dumps(data, indent=2))
asyncio.run(test_vtiger())

66
test_subscriptions.py Normal file
View File

@ -0,0 +1,66 @@
import asyncio
import aiohttp
import json
import os
from dotenv import load_dotenv
load_dotenv()
async def test_vtiger():
base_url = os.getenv('VTIGER_URL')
username = os.getenv('VTIGER_USERNAME')
api_key = os.getenv('VTIGER_API_KEY')
print(f"🔑 Testing vTiger connection...")
print(f"URL: {base_url}")
print(f"Username: {username}")
auth = aiohttp.BasicAuth(username, api_key)
# Test 1: Connection
async with aiohttp.ClientSession() as session:
async with session.get(f"{base_url}/restapi/v1/vtiger/default/me", auth=auth) as response:
text = await response.text()
print(f"\n✅ Connection test: {response.status}")
data = json.loads(text)
print(json.dumps(data, indent=2))
# Test 2: List all modules
async with session.get(f"{base_url}/restapi/v1/vtiger/default/listtypes", auth=auth) as response:
text = await response.text()
data = json.loads(text)
if data.get('success'):
modules = data.get('result', {}).get('types', [])
print(f"\n📋 Available modules ({len(modules)}):")
for mod in sorted(modules):
if 'sub' in mod.lower() or 'invoice' in mod.lower() or 'order' in mod.lower():
print(f" - {mod}")
# Test 3: Query Subscriptions
vtiger_id = '3x760'
query = f"SELECT * FROM Subscriptions WHERE account_id='{vtiger_id}';"
print(f"\n🔍 Testing query: {query}")
async with session.get(
f"{base_url}/restapi/v1/vtiger/default/query",
params={"query": query},
auth=auth
) as response:
text = await response.text()
print(f"Status: {response.status}")
data = json.loads(text)
print(json.dumps(data, indent=2))
# Test 4: Query Invoice
query2 = f"SELECT * FROM Invoice WHERE account_id='{vtiger_id}';"
print(f"\n🔍 Testing Invoice query: {query2}")
async with session.get(
f"{base_url}/restapi/v1/vtiger/default/query",
params={"query": query2},
auth=auth
) as response:
text = await response.text()
print(f"Status: {response.status}")
data = json.loads(text)
print(json.dumps(data, indent=2))
asyncio.run(test_vtiger())

60
test_vtiger_fields.py Normal file
View File

@ -0,0 +1,60 @@
"""
Detailed vTiger field inspection for SalesOrder
"""
import asyncio
import sys
import json
sys.path.insert(0, '/app')
from app.services.vtiger_service import get_vtiger_service
async def inspect_fields():
vtiger = get_vtiger_service()
print("="*60)
print("Inspecting SalesOrder for Arbodania (3x760)")
print("="*60)
query = "SELECT * FROM SalesOrder WHERE account_id='3x760';"
results = await vtiger.query(query)
if results:
print(f"\n✅ Found {len(results)} sales orders\n")
for i, order in enumerate(results, 1):
print(f"\n{'='*60}")
print(f"Sales Order #{i}")
print(f"{'='*60}")
for key, value in sorted(order.items()):
if value and str(value).strip(): # Only show non-empty values
print(f"{key:30s} = {value}")
print("\n" + "="*60)
print("Inspecting ALL SalesOrders (first 5)")
print("="*60)
query2 = "SELECT * FROM SalesOrder LIMIT 5;"
all_orders = await vtiger.query(query2)
if all_orders:
print(f"\n✅ Found {len(all_orders)} sales orders total\n")
# Collect all unique field names
all_fields = set()
for order in all_orders:
all_fields.update(order.keys())
print(f"Total unique fields: {len(all_fields)}")
print("\nField names related to frequency/recurring:")
freq_fields = [f for f in sorted(all_fields) if any(x in f.lower() for x in ['freq', 'recur', 'billing', 'period', 'subscr'])]
if freq_fields:
for f in freq_fields:
print(f" - {f}")
else:
print(" ⚠️ No frequency-related fields found")
print("\nAll field names:")
for f in sorted(all_fields):
print(f" - {f}")
if __name__ == "__main__":
asyncio.run(inspect_fields())

58
test_vtiger_modules.py Normal file
View File

@ -0,0 +1,58 @@
"""
Test vTiger modules and queries
"""
import asyncio
import sys
sys.path.insert(0, '/app')
from app.services.vtiger_service import get_vtiger_service
async def test_vtiger():
vtiger = get_vtiger_service()
# Test connection
print("🔑 Testing vTiger connection...")
connected = await vtiger.test_connection()
if not connected:
print("❌ Connection failed!")
return
print("\n" + "="*60)
print("Testing different module queries for account 3x760")
print("="*60)
# Test various queries
queries = [
# Try different module names
("Services", "SELECT * FROM Services WHERE account_id='3x760' LIMIT 5;"),
("Products", "SELECT * FROM Products WHERE account_id='3x760' LIMIT 5;"),
("SalesOrder", "SELECT * FROM SalesOrder WHERE account_id='3x760' LIMIT 5;"),
("Invoice", "SELECT * FROM Invoice WHERE account_id='3x760' LIMIT 5;"),
("Quotes", "SELECT * FROM Quotes WHERE account_id='3x760' LIMIT 5;"),
("Contacts", "SELECT * FROM Contacts WHERE account_id='3x760' LIMIT 5;"),
# Try without account filter to see structure
("SalesOrder (all)", "SELECT * FROM SalesOrder LIMIT 2;"),
("Invoice (all)", "SELECT * FROM Invoice LIMIT 2;"),
]
for name, query in queries:
print(f"\n📋 Testing: {name}")
print(f"Query: {query}")
try:
results = await vtiger.query(query)
if results:
print(f"✅ Found {len(results)} records")
if len(results) > 0:
print(f"Sample keys: {list(results[0].keys())[:10]}")
# Show first record
print("\nFirst record:")
for key, value in list(results[0].items())[:15]:
print(f" {key}: {value}")
else:
print("⚠️ No results")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
asyncio.run(test_vtiger())