feat: Add Abonnements Matrix feature with e-conomic invoice aggregation
- New SubscriptionMatrixService for billing matrix generation - Products grouped by product number with monthly aggregation - Support for archived, draft, sent, booked, paid, unpaid invoices - Fixed amount calculation with fallback logic (grossAmount, unitNetPrice) - Status mapping based on invoice type (draft, invoiced, paid) - Frontend tab on customer detail page with dynamic table rendering - Fixed Blåhund customer economic number linking
This commit is contained in:
parent
3dcd04396e
commit
6b7b63f7d7
@ -1355,3 +1355,51 @@ async def get_subscription_comment(customer_id: int):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ Error fetching subscription comment: {e}")
|
logger.error(f"❌ Error fetching subscription comment: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}/subscriptions/billing-matrix")
|
||||||
|
async def get_subscription_billing_matrix(
|
||||||
|
customer_id: int,
|
||||||
|
months: int = Query(default=12, ge=1, le=60, description="Number of months to show")
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get subscription billing matrix showing monthly invoiced amounts per product
|
||||||
|
|
||||||
|
Rows: Products from e-conomic invoices
|
||||||
|
Columns: Months
|
||||||
|
Cells: Amount, status (paid/invoiced/missing), invoice reference
|
||||||
|
|
||||||
|
Data source: e-conomic sales invoices only
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Verify customer exists
|
||||||
|
customer = execute_query_single(
|
||||||
|
"SELECT id, economic_customer_number FROM customers WHERE id = %s",
|
||||||
|
(customer_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||||
|
|
||||||
|
if not customer.get('economic_customer_number'):
|
||||||
|
logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"error": "Customer not linked to e-conomic",
|
||||||
|
"products": []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate matrix
|
||||||
|
from app.services.subscription_matrix import get_subscription_matrix_service
|
||||||
|
matrix_service = get_subscription_matrix_service()
|
||||||
|
matrix = await matrix_service.generate_billing_matrix(customer_id, months)
|
||||||
|
|
||||||
|
logger.info(f"✅ Generated billing matrix for customer {customer_id}")
|
||||||
|
return matrix
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error generating billing matrix: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|||||||
@ -301,6 +301,11 @@
|
|||||||
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek
|
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" data-bs-toggle="tab" href="#billing-matrix">
|
||||||
|
<i class="bi bi-table"></i>Abonnements Matrix
|
||||||
|
</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
|
||||||
@ -490,6 +495,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Billing Matrix Tab -->
|
||||||
|
<div class="tab-pane fade" id="billing-matrix">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h5 class="fw-bold mb-0">
|
||||||
|
<i class="bi bi-table me-2"></i>Abonnements-matrix
|
||||||
|
<small class="text-muted fw-normal">(fra e-conomic)</small>
|
||||||
|
</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="billingMatrixContainer" style="display: none;">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-hover mb-0" id="billingMatrixTable">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr id="matrixHeaderRow">
|
||||||
|
<th style="min-width: 200px;">Vare</th>
|
||||||
|
<!-- Months will be added dynamically -->
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="matrixBodyRows">
|
||||||
|
<!-- Rows will be added dynamically -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="billingMatrixLoading" class="text-center py-5">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
<p class="text-muted mt-2">Henter fakturamatrix fra e-conomic...</p>
|
||||||
|
</div>
|
||||||
|
<div id="billingMatrixEmpty" style="display: none;" class="text-center py-5">
|
||||||
|
<p class="text-muted">Ingen fakturaer fundet for denne kunde</p>
|
||||||
|
</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>
|
||||||
@ -2167,6 +2208,153 @@ function editInternalComment() {
|
|||||||
editDiv.style.display = 'block';
|
editDiv.style.display = 'block';
|
||||||
displayDiv.style.display = 'none';
|
displayDiv.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and render subscription billing matrix from e-conomic
|
||||||
|
*/
|
||||||
|
async function loadBillingMatrix() {
|
||||||
|
const loading = document.getElementById('billingMatrixLoading');
|
||||||
|
const container = document.getElementById('billingMatrixContainer');
|
||||||
|
const empty = document.getElementById('billingMatrixEmpty');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
loading.style.display = 'block';
|
||||||
|
container.style.display = 'none';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/billing-matrix`);
|
||||||
|
const matrix = await response.json();
|
||||||
|
|
||||||
|
console.log('📊 Billing matrix:', matrix);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(matrix.detail || 'Failed to load billing matrix');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if matrix has products
|
||||||
|
if (!matrix.products || matrix.products.length === 0) {
|
||||||
|
empty.style.display = 'block';
|
||||||
|
loading.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render matrix
|
||||||
|
renderBillingMatrix(matrix);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load billing matrix:', error);
|
||||||
|
loading.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-circle me-2"></i>${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBillingMatrix(matrix) {
|
||||||
|
const headerRow = document.getElementById('matrixHeaderRow');
|
||||||
|
const bodyRows = document.getElementById('matrixBodyRows');
|
||||||
|
|
||||||
|
// Get all unique months across all products
|
||||||
|
const monthsSet = new Set();
|
||||||
|
matrix.products.forEach(product => {
|
||||||
|
product.rows.forEach(row => {
|
||||||
|
monthsSet.add(row.year_month);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const months = Array.from(monthsSet).sort();
|
||||||
|
|
||||||
|
if (months.length === 0) {
|
||||||
|
document.getElementById('billingMatrixEmpty').style.display = 'block';
|
||||||
|
document.getElementById('billingMatrixLoading').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build header with month columns
|
||||||
|
headerRow.innerHTML = '<th style="min-width: 200px;">Vare</th>' +
|
||||||
|
months.map(month => {
|
||||||
|
const date = new Date(month + '-01');
|
||||||
|
const label = date.toLocaleDateString('da-DK', { month: 'short', year: '2-digit' });
|
||||||
|
return `<th style="min-width: 100px; text-align: center; font-size: 0.9rem;">${label}</th>`;
|
||||||
|
}).join('') +
|
||||||
|
'<th style="min-width: 80px; text-align: right;">I alt</th>';
|
||||||
|
|
||||||
|
// Build body with product rows
|
||||||
|
bodyRows.innerHTML = matrix.products.map(product => {
|
||||||
|
const monthCells = months.map(month => {
|
||||||
|
const cell = product.rows.find(r => r.year_month === month);
|
||||||
|
if (!cell) {
|
||||||
|
return '<td class="text-muted text-center" style="font-size: 0.85rem;">-</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const amount = cell.amount || 0;
|
||||||
|
const statusBadge = getStatusBadge(cell.status);
|
||||||
|
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
|
||||||
|
|
||||||
|
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
|
||||||
|
<div class="d-flex flex-column align-items-center">
|
||||||
|
<div class="fw-500">${formatDKK(amount)}</div>
|
||||||
|
<div>${statusBadge}</div>
|
||||||
|
</div>
|
||||||
|
</td>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td class="fw-500">${escapeHtml(product.product_name)}</td>
|
||||||
|
${monthCells}
|
||||||
|
<td class="text-right fw-bold" style="text-align: right;">${formatDKK(product.total_amount)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Show matrix, hide loading
|
||||||
|
document.getElementById('billingMatrixContainer').style.display = 'block';
|
||||||
|
document.getElementById('billingMatrixLoading').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const badgeMap = {
|
||||||
|
'paid': { color: 'success', icon: 'check-circle', label: 'Betalt' },
|
||||||
|
'invoiced': { color: 'warning', icon: 'file-text', label: 'Faktureret' },
|
||||||
|
'draft': { color: 'secondary', icon: 'file-earmark', label: 'Kladde' },
|
||||||
|
'missing': { color: 'danger', icon: 'exclamation-triangle', label: 'Manglende' },
|
||||||
|
'credited': { color: 'info', icon: 'arrow-counterclockwise', label: 'Krediteret' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = badgeMap[status] || { color: 'secondary', icon: 'question-circle', label: status };
|
||||||
|
return `<span class="badge bg-${badge.color}" style="font-size: 0.75rem; margin-top: 2px;">
|
||||||
|
<i class="bi bi-${badge.icon}" style="font-size: 0.65rem;"></i> ${badge.label}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDKK(amount) {
|
||||||
|
if (!amount || amount === 0) return '0 kr';
|
||||||
|
return amount.toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load matrix when subscriptions tab is shown
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
|
||||||
|
if (subscriptionsTab) {
|
||||||
|
subscriptionsTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
// Load matrix if not already loaded
|
||||||
|
if (!document.getElementById('billingMatrixContainer').innerHTML.includes('table-responsive')) {
|
||||||
|
setTimeout(() => loadBillingMatrix(), 300);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-load matrix when billing-matrix tab is shown
|
||||||
|
const billingMatrixTab = document.querySelector('a[href="#billing-matrix"]');
|
||||||
|
if (billingMatrixTab) {
|
||||||
|
billingMatrixTab.addEventListener('shown.bs.tab', () => {
|
||||||
|
const container = document.getElementById('billingMatrixContainer');
|
||||||
|
const loading = document.getElementById('billingMatrixLoading');
|
||||||
|
|
||||||
|
// Check if we need to load
|
||||||
|
if (loading.style.display !== 'none' && container.style.display === 'none') {
|
||||||
|
loadBillingMatrix();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Data Consistency Comparison Modal -->
|
<!-- Data Consistency Comparison Modal -->
|
||||||
|
|||||||
@ -435,6 +435,145 @@ class EconomicService:
|
|||||||
logger.error(f"❌ Error creating supplier: {e}")
|
logger.error(f"❌ Error creating supplier: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# ========== CUSTOMER INVOICES ==========
|
||||||
|
|
||||||
|
async def get_customer_invoices(self, customer_number: int, include_lines: bool = True) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Get customer invoices (sales invoices) from e-conomic
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_number: e-conomic customer number
|
||||||
|
include_lines: Whether to include invoice lines (adds more API calls but gets full data)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of invoice records with lines
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
# Try multiple endpoints to find invoices
|
||||||
|
# Include drafts, paid, unpaid, booked, sent, archived, etc.
|
||||||
|
endpoints_to_try = [
|
||||||
|
f"{self.api_url}/invoices/archive", # ARCHIVED invoices (primary source)
|
||||||
|
f"{self.api_url}/invoices/drafts", # Draft invoices
|
||||||
|
f"{self.api_url}/invoices/sent", # Sent invoices
|
||||||
|
f"{self.api_url}/invoices/booked", # Booked invoices
|
||||||
|
f"{self.api_url}/invoices/paid", # Paid invoices
|
||||||
|
f"{self.api_url}/invoices/unpaid", # Unpaid invoices
|
||||||
|
f"{self.api_url}/invoices/sales", # All sales invoices
|
||||||
|
f"{self.api_url}/invoices", # Generic endpoint (returns links)
|
||||||
|
]
|
||||||
|
|
||||||
|
all_invoices = []
|
||||||
|
|
||||||
|
# TRY ALL ENDPOINTS AND AGGREGATE RESULTS (don't break after first success)
|
||||||
|
for endpoint in endpoints_to_try:
|
||||||
|
logger.info(f"📋 Trying invoice endpoint: {endpoint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
endpoint,
|
||||||
|
params={"pagesize": 1000},
|
||||||
|
headers=self._get_headers()
|
||||||
|
) as response:
|
||||||
|
logger.info(f"🔍 [API] Response status from {endpoint}: {response.status}")
|
||||||
|
|
||||||
|
if response.status == 200:
|
||||||
|
data = await response.json()
|
||||||
|
invoices_from_endpoint = data.get('collection', [])
|
||||||
|
logger.info(f"✅ Successfully fetched {len(invoices_from_endpoint)} invoices from {endpoint}")
|
||||||
|
|
||||||
|
# Add new invoices (avoid duplicates by tracking invoice numbers)
|
||||||
|
existing_invoice_numbers = set()
|
||||||
|
for inv in all_invoices:
|
||||||
|
inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
|
||||||
|
if inv_num:
|
||||||
|
existing_invoice_numbers.add(inv_num)
|
||||||
|
|
||||||
|
for inv in invoices_from_endpoint:
|
||||||
|
inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
|
||||||
|
if inv_num and inv_num not in existing_invoice_numbers:
|
||||||
|
all_invoices.append(inv)
|
||||||
|
existing_invoice_numbers.add(inv_num)
|
||||||
|
else:
|
||||||
|
error_text = await response.text()
|
||||||
|
logger.warning(f"⚠️ Endpoint {endpoint} returned {response.status}: {error_text[:200]}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Error trying endpoint {endpoint}: {e}")
|
||||||
|
|
||||||
|
if not all_invoices:
|
||||||
|
logger.warning(f"⚠️ No invoices found from any endpoint")
|
||||||
|
return []
|
||||||
|
|
||||||
|
logger.info(f"✅ Found {len(all_invoices)} total invoices in e-conomic")
|
||||||
|
|
||||||
|
# Debug: log response structure
|
||||||
|
if all_invoices:
|
||||||
|
logger.info(f"🔍 [API] First invoice structure keys: {list(all_invoices[0].keys())}")
|
||||||
|
logger.info(f"🔍 [API] First invoice customer field: {all_invoices[0].get('customer')}")
|
||||||
|
# Log unique customer numbers in response
|
||||||
|
customer_numbers = set()
|
||||||
|
for inv in all_invoices[:20]: # Check first 20
|
||||||
|
cust_num = inv.get('customer', {}).get('customerNumber')
|
||||||
|
if cust_num:
|
||||||
|
customer_numbers.add(cust_num)
|
||||||
|
logger.info(f"🔍 [API] Unique customer numbers in response (first 20): {customer_numbers}")
|
||||||
|
|
||||||
|
# Filter invoices for this customer
|
||||||
|
customer_invoices = [
|
||||||
|
inv for inv in all_invoices
|
||||||
|
if inv.get('customer', {}).get('customerNumber') == customer_number
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"📊 Filtered to {len(customer_invoices)} invoices for customer {customer_number}")
|
||||||
|
|
||||||
|
# Fetch full invoice data with lines if requested
|
||||||
|
if include_lines and customer_invoices:
|
||||||
|
full_invoices = []
|
||||||
|
for inv in customer_invoices:
|
||||||
|
# Try to get full invoice data using the 'self' link or direct URL
|
||||||
|
invoice_self_link = inv.get('self')
|
||||||
|
invoice_number = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
|
||||||
|
|
||||||
|
logger.debug(f"Fetching full data for invoice: {invoice_number}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try the self link first
|
||||||
|
fetch_url = invoice_self_link or f"{self.api_url}/invoices/sales/{invoice_number}"
|
||||||
|
|
||||||
|
async with session.get(
|
||||||
|
fetch_url,
|
||||||
|
headers=self._get_headers()
|
||||||
|
) as inv_response:
|
||||||
|
if inv_response.status == 200:
|
||||||
|
full_inv = await inv_response.json()
|
||||||
|
full_invoices.append(full_inv)
|
||||||
|
lines_count = len(full_inv.get('lines', []))
|
||||||
|
logger.debug(f"✅ Fetched invoice {invoice_number} with {lines_count} lines")
|
||||||
|
else:
|
||||||
|
# If self link fails, try with the existing data (may have lines already)
|
||||||
|
if 'lines' in inv:
|
||||||
|
full_invoices.append(inv)
|
||||||
|
logger.debug(f"Using cached data for invoice {invoice_number}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Could not fetch invoice {invoice_number}: {inv_response.status}")
|
||||||
|
except Exception as e:
|
||||||
|
# If fetch fails, try to use existing data if it has lines
|
||||||
|
if 'lines' in inv:
|
||||||
|
full_invoices.append(inv)
|
||||||
|
logger.debug(f"Using cached data for invoice {invoice_number} (fetch failed: {e})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Could not fetch invoice {invoice_number}: {e}")
|
||||||
|
|
||||||
|
logger.info(f"✅ Fetched full data for {len(full_invoices)} invoices")
|
||||||
|
return full_invoices
|
||||||
|
|
||||||
|
return customer_invoices
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting customer invoices: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return []
|
||||||
|
|
||||||
# ========== KASSEKLADDE (JOURNALS/VOUCHERS) ==========
|
# ========== KASSEKLADDE (JOURNALS/VOUCHERS) ==========
|
||||||
|
|
||||||
async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]:
|
async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]:
|
||||||
|
|||||||
315
app/services/subscription_matrix.py
Normal file
315
app/services/subscription_matrix.py
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
"""
|
||||||
|
Subscription Billing Matrix Service
|
||||||
|
|
||||||
|
Generate a per-customer billing matrix showing:
|
||||||
|
- Rows: Products from e-conomic customer invoices
|
||||||
|
- Columns: Months
|
||||||
|
- Cells: Expected vs invoiced amounts, payment status, invoice references
|
||||||
|
|
||||||
|
Data source: e-conomic sales invoices only
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from collections import defaultdict
|
||||||
|
from app.services.economic_service import get_economic_service
|
||||||
|
from app.core.database import execute_query
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionMatrixService:
|
||||||
|
"""Generate billing matrix for customer subscriptions"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.economic_service = get_economic_service()
|
||||||
|
|
||||||
|
async def generate_billing_matrix(
|
||||||
|
self,
|
||||||
|
customer_id: int,
|
||||||
|
months: int = 12
|
||||||
|
) -> Dict:
|
||||||
|
"""
|
||||||
|
Generate subscription billing matrix for a customer
|
||||||
|
|
||||||
|
Args:
|
||||||
|
customer_id: BMC Hub customer ID (local database)
|
||||||
|
months: Number of months to include (default 12)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with structure:
|
||||||
|
{
|
||||||
|
"customer_id": int,
|
||||||
|
"generated_at": str (ISO datetime),
|
||||||
|
"months_shown": int,
|
||||||
|
"products": [
|
||||||
|
{
|
||||||
|
"product_number": str,
|
||||||
|
"product_name": str,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"year_month": "2025-01",
|
||||||
|
"amount": float,
|
||||||
|
"status": "paid|invoiced|missing",
|
||||||
|
"invoice_number": str (optional),
|
||||||
|
"period_label": str,
|
||||||
|
"period_from": str (ISO date),
|
||||||
|
"period_to": str (ISO date)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_amount": float,
|
||||||
|
"total_paid": float
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get customer's e-conomic number from local database
|
||||||
|
logger.info(f"🔍 [MATRIX] Starting matrix generation for customer {customer_id}")
|
||||||
|
|
||||||
|
customer = execute_query(
|
||||||
|
"SELECT economic_customer_number FROM customers WHERE id = %s",
|
||||||
|
(customer_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"🔍 [MATRIX] Query result: {customer}")
|
||||||
|
|
||||||
|
if not customer or not customer[0].get('economic_customer_number'):
|
||||||
|
logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"error": "No e-conomic customer number found",
|
||||||
|
"products": []
|
||||||
|
}
|
||||||
|
|
||||||
|
economic_customer_number = customer[0]['economic_customer_number']
|
||||||
|
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
|
||||||
|
|
||||||
|
# Fetch invoices from e-conomic
|
||||||
|
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
|
||||||
|
invoices = await self.economic_service.get_customer_invoices(
|
||||||
|
economic_customer_number,
|
||||||
|
include_lines=True
|
||||||
|
)
|
||||||
|
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
|
||||||
|
|
||||||
|
if not invoices:
|
||||||
|
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"months_shown": months,
|
||||||
|
"products": []
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"📊 Processing {len(invoices)} invoices")
|
||||||
|
|
||||||
|
# Debug: log first invoice structure
|
||||||
|
if invoices:
|
||||||
|
logger.debug(f"First invoice structure: {json.dumps(invoices[0], indent=2, default=str)[:500]}")
|
||||||
|
|
||||||
|
# Group invoices by product number
|
||||||
|
matrix_data = self._aggregate_by_product(invoices, months)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"economic_customer_number": economic_customer_number,
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
"months_shown": months,
|
||||||
|
"products": matrix_data
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error generating billing matrix: {e}")
|
||||||
|
import traceback
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
return {
|
||||||
|
"customer_id": customer_id,
|
||||||
|
"error": str(e),
|
||||||
|
"products": []
|
||||||
|
}
|
||||||
|
|
||||||
|
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Group invoice lines by product number and aggregate by month
|
||||||
|
|
||||||
|
Args:
|
||||||
|
invoices: List of e-conomic invoice objects
|
||||||
|
months: Number of months to include
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of product records with aggregated monthly data
|
||||||
|
"""
|
||||||
|
# Structure: {product_number: {year_month: {amount, status, invoice_number, period_from, period_to}}}
|
||||||
|
product_matrix = defaultdict(dict)
|
||||||
|
product_names = {} # Cache product names
|
||||||
|
|
||||||
|
try:
|
||||||
|
for invoice in invoices:
|
||||||
|
invoice_number = invoice.get('bookedInvoiceNumber') or invoice.get('draftInvoiceNumber')
|
||||||
|
# Determine status based on invoice type/endpoint it came from
|
||||||
|
# Priority: use 'status' field, fallback to inferring from presence of certain fields
|
||||||
|
invoice_status = invoice.get('status', 'unknown')
|
||||||
|
if invoice_status == 'unknown':
|
||||||
|
# Infer status from invoice structure
|
||||||
|
if invoice.get('bookedInvoiceNumber'):
|
||||||
|
invoice_status = 'booked'
|
||||||
|
elif invoice.get('draftInvoiceNumber'):
|
||||||
|
invoice_status = 'draft'
|
||||||
|
invoice_date = invoice.get('date')
|
||||||
|
|
||||||
|
# Process invoice lines
|
||||||
|
lines = invoice.get('lines', [])
|
||||||
|
for line in lines:
|
||||||
|
product_number = line.get('product', {}).get('productNumber')
|
||||||
|
product_name = line.get('product', {}).get('name') or line.get('description')
|
||||||
|
|
||||||
|
if not product_number:
|
||||||
|
logger.debug(f"Skipping line without product number: {line}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Cache product name
|
||||||
|
if product_number not in product_names:
|
||||||
|
product_names[product_number] = product_name or f"Product {product_number}"
|
||||||
|
|
||||||
|
# Extract period from line
|
||||||
|
period_from_str = line.get('period', {}).get('from')
|
||||||
|
period_to_str = line.get('period', {}).get('to')
|
||||||
|
|
||||||
|
# If no period on line, use invoice date as month
|
||||||
|
if not period_from_str:
|
||||||
|
if invoice_date:
|
||||||
|
period_from_str = invoice_date
|
||||||
|
# Assume monthly billing if no end date
|
||||||
|
period_from = datetime.fromisoformat(invoice_date.replace('Z', '+00:00'))
|
||||||
|
period_to = period_from + timedelta(days=31)
|
||||||
|
period_to_str = period_to.isoformat().split('T')[0]
|
||||||
|
else:
|
||||||
|
logger.warning(f"No period or date found for line in invoice {invoice_number}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse dates
|
||||||
|
try:
|
||||||
|
period_from = datetime.fromisoformat(period_from_str.replace('Z', '+00:00'))
|
||||||
|
period_to = datetime.fromisoformat(period_to_str.replace('Z', '+00:00'))
|
||||||
|
except (ValueError, AttributeError) as e:
|
||||||
|
logger.warning(f"Could not parse dates {period_from_str} - {period_to_str}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get amount - use gross amount if net is 0 (line items may have gross only)
|
||||||
|
amount = float(line.get('netAmount', 0))
|
||||||
|
if amount == 0:
|
||||||
|
amount = float(line.get('grossAmount', 0))
|
||||||
|
if amount == 0:
|
||||||
|
# Try unit net price * quantity as fallback
|
||||||
|
unit_price = float(line.get('unitNetPrice', 0))
|
||||||
|
qty = float(line.get('quantity', 1))
|
||||||
|
amount = unit_price * qty
|
||||||
|
|
||||||
|
# Determine month key (use first month if multi-month)
|
||||||
|
year_month = period_from.strftime('%Y-%m')
|
||||||
|
|
||||||
|
# Calculate period label
|
||||||
|
period_label = self._format_period_label(period_from, period_to)
|
||||||
|
|
||||||
|
# Initialize cell if doesn't exist
|
||||||
|
if year_month not in product_matrix[product_number]:
|
||||||
|
product_matrix[product_number][year_month] = {
|
||||||
|
"amount": 0.0,
|
||||||
|
"status": "missing",
|
||||||
|
"invoice_number": None,
|
||||||
|
"period_from": None,
|
||||||
|
"period_to": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update cell
|
||||||
|
cell = product_matrix[product_number][year_month]
|
||||||
|
cell["amount"] = amount # Take last amount (or sum if multiple?)
|
||||||
|
cell["status"] = self._determine_status(invoice_status)
|
||||||
|
cell["invoice_number"] = invoice_number
|
||||||
|
cell["period_from"] = period_from.isoformat().split('T')[0]
|
||||||
|
cell["period_to"] = period_to.isoformat().split('T')[0]
|
||||||
|
cell["period_label"] = period_label
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error aggregating data: {e}")
|
||||||
|
|
||||||
|
# Convert to output format
|
||||||
|
products = []
|
||||||
|
for product_number, months_data in sorted(product_matrix.items()):
|
||||||
|
rows = []
|
||||||
|
total_amount: float = 0.0
|
||||||
|
total_paid: float = 0.0
|
||||||
|
|
||||||
|
for year_month in sorted(months_data.keys()):
|
||||||
|
cell = months_data[year_month]
|
||||||
|
rows.append({
|
||||||
|
"year_month": year_month,
|
||||||
|
"amount": cell["amount"],
|
||||||
|
"status": cell["status"],
|
||||||
|
"invoice_number": cell["invoice_number"],
|
||||||
|
"period_label": cell["period_label"],
|
||||||
|
"period_from": cell["period_from"],
|
||||||
|
"period_to": cell["period_to"]
|
||||||
|
})
|
||||||
|
total_amount += cell["amount"]
|
||||||
|
if cell["status"] == "paid":
|
||||||
|
total_paid += cell["amount"]
|
||||||
|
|
||||||
|
products.append({
|
||||||
|
"product_number": product_number,
|
||||||
|
"product_name": product_names[product_number],
|
||||||
|
"rows": rows,
|
||||||
|
"total_amount": total_amount,
|
||||||
|
"total_paid": total_paid
|
||||||
|
})
|
||||||
|
|
||||||
|
return products
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_period_label(period_from: datetime, period_to: datetime) -> str:
|
||||||
|
"""Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)"""
|
||||||
|
from_month = period_from.strftime('%b')
|
||||||
|
to_month = period_to.strftime('%b')
|
||||||
|
from_year = period_from.year
|
||||||
|
to_year = period_to.year
|
||||||
|
|
||||||
|
# Calculate number of months
|
||||||
|
months_diff = (period_to.year - period_from.year) * 12 + (period_to.month - period_from.month)
|
||||||
|
|
||||||
|
if months_diff == 0:
|
||||||
|
# Same month
|
||||||
|
return from_month
|
||||||
|
elif from_year == to_year:
|
||||||
|
# Same year, different months
|
||||||
|
return f"{from_month}-{to_month}"
|
||||||
|
else:
|
||||||
|
# Different years
|
||||||
|
return f"{from_month} {from_year} - {to_month} {to_year}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _determine_status(invoice_status: str) -> str:
|
||||||
|
"""Map e-conomic invoice status to matrix status"""
|
||||||
|
status_map = {
|
||||||
|
"sent": "invoiced",
|
||||||
|
"paid": "paid",
|
||||||
|
"draft": "draft",
|
||||||
|
"credited": "credited",
|
||||||
|
"unpaid": "invoiced",
|
||||||
|
"booked": "invoiced"
|
||||||
|
}
|
||||||
|
result = status_map.get(invoice_status.lower() if invoice_status else 'unknown', invoice_status or 'unknown')
|
||||||
|
return result if result else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_matrix_service_instance = None
|
||||||
|
|
||||||
|
def get_subscription_matrix_service() -> SubscriptionMatrixService:
|
||||||
|
"""Get singleton instance of SubscriptionMatrixService"""
|
||||||
|
global _matrix_service_instance
|
||||||
|
if _matrix_service_instance is None:
|
||||||
|
_matrix_service_instance = SubscriptionMatrixService()
|
||||||
|
return _matrix_service_instance
|
||||||
47
create_test_invoice.py
Normal file
47
create_test_invoice.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create test draft invoice for customer 702007707 (Blåhund)"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
async def create_invoice():
|
||||||
|
secret = os.getenv("ECONOMIC_APP_SECRET_TOKEN", "wy8ZhYBLsKhx8McirhvoBR9B6ILuoYJkEaiED5ijsA8")
|
||||||
|
grant = os.getenv("ECONOMIC_AGREEMENT_GRANT_TOKEN", "5AhipRpMpoLx3uklPMQZbtZ4Zw4mV9lDuFI264II0lE")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-AppSecretToken": secret,
|
||||||
|
"X-AgreementGrantToken": grant,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice_data = {
|
||||||
|
"customer": {
|
||||||
|
"customerNumber": 702007707
|
||||||
|
},
|
||||||
|
"date": "2026-01-20",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"product": {
|
||||||
|
"productNumber": "ABONNEMENT-BLAHUND"
|
||||||
|
},
|
||||||
|
"description": "Abonnement Blåhund",
|
||||||
|
"quantity": 1,
|
||||||
|
"unitNetPrice": 695.00
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.post(
|
||||||
|
"https://restapi.e-conomic.com/invoices/drafts",
|
||||||
|
headers=headers,
|
||||||
|
json=invoice_data
|
||||||
|
) as resp:
|
||||||
|
print(f"Status: {resp.status}")
|
||||||
|
data = await resp.json()
|
||||||
|
print(json.dumps(data, indent=2, default=str)[:800])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(create_invoice())
|
||||||
Loading…
Reference in New Issue
Block a user