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:
parent
c4c9b8a04a
commit
361f2fad5d
@ -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)}")
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
190
app/services/vtiger_service.py
Normal file
190
app/services/vtiger_service.py
Normal 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
|
||||||
59
migrations/015_bmc_office_subscriptions.sql
Normal file
59
migrations/015_bmc_office_subscriptions.sql
Normal 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';
|
||||||
34
test_subscription_singular.py
Normal file
34
test_subscription_singular.py
Normal 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
66
test_subscriptions.py
Normal 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
60
test_vtiger_fields.py
Normal 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
58
test_vtiger_modules.py
Normal 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())
|
||||||
Loading…
Reference in New Issue
Block a user