Add comprehensive tests for internet connections module, invoice parsing, and subscription provisioning

- Implement tests for the internet connections module, covering routes, IP range creation, and connection validation.
- Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices.
- Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations.
- Include validation checks for subtotal mismatches and ensure error handling for missing IP selections.
This commit is contained in:
Christian 2026-07-09 23:44:30 +02:00
parent 8e99453dea
commit 3311b8e590
42 changed files with 13050 additions and 219 deletions

File diff suppressed because it is too large Load Diff

View File

@ -99,6 +99,51 @@
.status-processing { background-color: #6c757d; color: #fff; } .status-processing { background-color: #6c757d; color: #fff; }
.status-failed { background-color: var(--danger); color: #fff; } .status-failed { background-color: var(--danger); color: #fff; }
.status-completed { background-color: var(--success); color: #fff; } .status-completed { background-color: var(--success); color: #fff; }
.sync-report-card {
border: 1px solid rgba(13, 110, 253, 0.14);
border-radius: 14px;
background: linear-gradient(180deg, rgba(13, 110, 253, 0.04), rgba(13, 110, 253, 0.015));
}
.sync-report-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
}
.sync-report-metric {
border: 1px solid rgba(13, 110, 253, 0.12);
border-radius: 12px;
padding: 0.75rem;
background: rgba(255,255,255,0.7);
}
.sync-report-metric .label {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
margin-bottom: 0.2rem;
}
.sync-report-metric .value {
font-weight: 700;
font-size: 1.1rem;
}
.sync-line-list {
display: grid;
gap: 0.5rem;
}
.sync-line-item {
border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px;
padding: 0.7rem 0.85rem;
background: #fff;
}
</style> </style>
{% endblock %} {% endblock %}
@ -2036,6 +2081,114 @@ function getFileStatusBadge(status) {
return badges[status] || `<span class="badge bg-secondary">${status}</span>`; return badges[status] || `<span class="badge bg-secondary">${status}</span>`;
} }
function renderSyncVerificationBadge(verification) {
if (!verification) return '';
if (verification.requires_manual_review) {
return '<span class="badge bg-danger">Kræver manuel kontrol</span>';
}
if (verification.fully_synced) {
return '<span class="badge bg-success">Alt er dækket</span>';
}
return '<span class="badge bg-secondary">Ingen internet-sync</span>';
}
function renderSyncReport(syncReport) {
if (!syncReport || syncReport.skipped) return '';
const verification = syncReport.verification || {};
const skippedItems = Array.isArray(syncReport.skipped_items) ? syncReport.skipped_items : [];
const lineAudit = Array.isArray(syncReport.line_audit) ? syncReport.line_audit : [];
const syncedLines = lineAudit.filter((item) => item.status === 'synced');
const ignoredLines = lineAudit.filter((item) => item.status === 'ignored');
const renderLine = (item, tone) => `
<div class="sync-line-item">
<div class="d-flex justify-content-between align-items-start gap-2">
<div>
<div class="fw-semibold">Linje ${item.line_number || '-'} · ${escapeHtml(item.description || '-')}</div>
<div class="small text-muted mt-1">
${item.provider_reference ? `Ref: ${escapeHtml(item.provider_reference)} · ` : ''}
${item.ip_address ? `IP/CIDR: ${escapeHtml(item.ip_address)} · ` : ''}
${item.service_address ? `Adresse: ${escapeHtml(item.service_address)}` : ''}
</div>
${item.reason ? `<div class="small mt-1 text-${tone}">${escapeHtml(item.reason)}</div>` : ''}
</div>
<span class="badge bg-${tone}">${item.result ? escapeHtml(item.result) : escapeHtml(item.status)}</span>
</div>
</div>
`;
return `
<div class="sync-report-card p-3 mt-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h5 class="mb-1">Internet-importkontrol</h5>
<div class="small text-muted">Alle relevante linjer er gennemgået enkeltvis. Alt der springes over vises her med årsag.</div>
</div>
<div>${renderSyncVerificationBadge(verification)}</div>
</div>
<div class="sync-report-grid mb-3">
<div class="sync-report-metric">
<span class="label">Linjer i alt</span>
<div class="value">${verification.total_lines || 0}</div>
</div>
<div class="sync-report-metric">
<span class="label">Relevante linjer</span>
<div class="value">${verification.actionable_lines || 0}</div>
</div>
<div class="sync-report-metric">
<span class="label">Synkroniseret</span>
<div class="value text-success">${verification.synced_actionable_lines || 0}</div>
</div>
<div class="sync-report-metric">
<span class="label">Sprunget over</span>
<div class="value text-danger">${verification.skipped_actionable_lines || 0}</div>
</div>
<div class="sync-report-metric">
<span class="label">Forbindelser</span>
<div class="value">${syncReport.connections_created || 0} ny · ${syncReport.connections_updated || 0} opdat.</div>
</div>
<div class="sync-report-metric">
<span class="label">IP-ranges</span>
<div class="value">${syncReport.ip_ranges_synced || 0}</div>
</div>
</div>
${skippedItems.length ? `
<div class="alert alert-danger mb-3">
<strong>${skippedItems.length} linjer blev sprunget over.</strong> De skal gennemgås manuelt før du kan være sikker på, at alt er oprettet.
</div>
<div class="sync-line-list mb-3">
${skippedItems.map((item) => renderLine(item, 'danger')).join('')}
</div>
` : `
<div class="alert alert-success mb-3">
Ingen relevante linjer blev sprunget over.
</div>
`}
${syncedLines.length ? `
<details class="mb-3">
<summary class="fw-semibold">Vis synkroniserede linjer (${syncedLines.length})</summary>
<div class="sync-line-list mt-2">
${syncedLines.map((item) => renderLine(item, 'success')).join('')}
</div>
</details>
` : ''}
${ignoredLines.length ? `
<details>
<summary class="fw-semibold">Vis linjer uden internet-handling (${ignoredLines.length})</summary>
<div class="sync-line-list mt-2">
${ignoredLines.map((item) => renderLine(item, 'secondary')).join('')}
</div>
</details>
` : ''}
</div>
`;
}
// NEW: Batch analyze all files // NEW: Batch analyze all files
async function batchAnalyzeAllFiles() { async function batchAnalyzeAllFiles() {
if (!confirm('Kør automatisk analyse på alle ubehandlede filer?\n\nDette kan tage flere minutter afhængigt af antal filer.\nSiden opdateres automatisk undervejs.')) { if (!confirm('Kør automatisk analyse på alle ubehandlede filer?\n\nDette kan tage flere minutter afhængigt af antal filer.\nSiden opdateres automatisk undervejs.')) {
@ -2757,6 +2910,7 @@ async function reviewExtractedData(fileId) {
const ext = data.extraction; const ext = data.extraction;
const lines = data.extraction_lines || []; const lines = data.extraction_lines || [];
const syncPreview = data.internet_sync_preview || null;
// Parse JSON if llm_response_json exists // Parse JSON if llm_response_json exists
let aiData = null; let aiData = null;
@ -2840,6 +2994,8 @@ async function reviewExtractedData(fileId) {
<pre class="mb-0 text-body" style="font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; line-height: 1.3;">${escapeHtml(data.pdf_text_preview)}</pre> <pre class="mb-0 text-body" style="font-size: 0.85rem; white-space: pre-wrap; word-wrap: break-word; font-family: monospace; line-height: 1.3;">${escapeHtml(data.pdf_text_preview)}</pre>
</div> </div>
` : '<div class="alert alert-warning mt-3"><i class="bi bi-exclamation-triangle me-2"></i>PDF tekst ikke tilgængelig - prøv at genbehandle filen</div>'} ` : '<div class="alert alert-warning mt-3"><i class="bi bi-exclamation-triangle me-2"></i>PDF tekst ikke tilgængelig - prøv at genbehandle filen</div>'}
${renderSyncReport(syncPreview)}
`; `;
document.getElementById('reviewModalContent').innerHTML = modalContent; document.getElementById('reviewModalContent').innerHTML = modalContent;
@ -3188,7 +3344,11 @@ async function createInvoiceFromExtraction() {
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}`); const syncReport = result.internet_sync || null;
const syncWarning = syncReport?.verification?.requires_manual_review
? `\n\nADVARSEL: ${syncReport.verification.skipped_actionable_lines} internet-linjer blev sprunget over. Åbn review igen og kontroller dem.`
: '';
alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}${syncWarning}`);
// Close modal and refresh // Close modal and refresh
const modalInstance = bootstrap.Modal.getInstance(modal); const modalInstance = bootstrap.Modal.getInstance(modal);

View File

@ -80,6 +80,112 @@
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
.internet-stat-card {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 14px;
padding: 1rem;
height: 100%;
}
.internet-stat-label {
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.35rem;
}
.internet-stat-value {
color: var(--text-primary);
font-size: 1.4rem;
font-weight: 700;
}
.internet-shell {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.05), rgba(255, 255, 255, 0));
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 18px;
padding: 1.25rem;
}
.internet-connection-card {
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 16px;
background: var(--bg-card);
padding: 1rem;
box-shadow: 0 10px 24px rgba(15, 76, 117, 0.06);
}
.internet-connection-card + .internet-connection-card {
margin-top: 0.9rem;
}
.internet-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
}
.internet-meta-item {
background: rgba(15, 76, 117, 0.04);
border-radius: 12px;
padding: 0.75rem;
}
.internet-meta-item .label {
display: block;
color: var(--text-secondary);
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.25rem;
}
.internet-meta-item .value {
color: var(--text-primary);
font-weight: 600;
}
.internet-status-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.35rem 0.7rem;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.internet-status-active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.internet-status-inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
}
.internet-status-pending, .internet-status-planned {
background: rgba(255, 193, 7, 0.18);
color: #997404;
}
.internet-status-terminated, .internet-status-cancelled {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
}
.internet-create-panel {
border: 1px dashed rgba(15, 76, 117, 0.22);
border-radius: 16px;
background: rgba(15, 76, 117, 0.03);
padding: 1rem;
}
.info-row { .info-row {
display: flex; display: flex;
@ -566,6 +672,11 @@
<i class="bi bi-geo-alt"></i>Lokationer <i class="bi bi-geo-alt"></i>Lokationer
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#internet-connections">
<i class="bi bi-hdd-network"></i>Internet
</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
@ -1191,6 +1302,112 @@
</div> </div>
</div> </div>
<div class="tab-pane fade" id="internet-connections">
<div class="internet-shell">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
<div>
<h5 class="fw-bold mb-1">
<i class="bi bi-hdd-network me-2"></i>Internetforbindelser
</h5>
<div class="text-muted">Fiber, WAN, kredsløb, IP-ranges og økonomi samlet på kunden.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a class="btn btn-sm btn-outline-secondary" href="/economy/internet-connections">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn modul
</a>
<button class="btn btn-sm btn-primary" type="button" onclick="toggleCustomerInternetCreate()">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="internet-stat-card">
<div class="internet-stat-label">Forbindelser</div>
<div class="internet-stat-value" id="customerInternetCount">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-stat-card">
<div class="internet-stat-label">Månedlig kost</div>
<div class="internet-stat-value" id="customerInternetCost">0 kr.</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-stat-card">
<div class="internet-stat-label">Månedligt salg</div>
<div class="internet-stat-value" id="customerInternetSales">0 kr.</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-stat-card">
<div class="internet-stat-label">Aktive IP'er</div>
<div class="internet-stat-value" id="customerInternetIps">0</div>
</div>
</div>
</div>
<div class="internet-create-panel mb-4 d-none" id="customerInternetCreatePanel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="fw-semibold">Opret forbindelse på kunden</div>
<div class="small text-muted">Knytter automatisk forbindelsen til denne kunde.</div>
</div>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="toggleCustomerInternetCreate(false)">Luk</button>
</div>
<div class="row g-2">
<div class="col-md-4">
<input type="text" class="form-control" id="customerInternetName" placeholder="Navn på forbindelse">
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="customerInternetProvider" placeholder="Leverandør">
</div>
<div class="col-md-2">
<input type="text" class="form-control" id="customerInternetCircuit" placeholder="Kredsløb">
</div>
<div class="col-md-3">
<input type="text" class="form-control" id="customerInternetTechnology" placeholder="Teknologi">
</div>
<div class="col-md-4">
<input type="text" class="form-control" id="customerInternetAddress" placeholder="Installationsadresse">
</div>
<div class="col-md-2">
<input type="number" class="form-control" id="customerInternetDownload" placeholder="Download">
</div>
<div class="col-md-2">
<input type="number" class="form-control" id="customerInternetUpload" placeholder="Upload">
</div>
<div class="col-md-2">
<input type="number" class="form-control" id="customerInternetCostInput" placeholder="Kost">
</div>
<div class="col-md-2">
<input type="number" class="form-control" id="customerInternetSalesInput" placeholder="Salg">
</div>
<div class="col-md-12">
<textarea class="form-control" id="customerInternetNotes" rows="2" placeholder="Noter, SLA, overvågning, aftaler..."></textarea>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary" type="button" onclick="createCustomerInternetConnection()">Gem forbindelse</button>
<div class="small text-muted align-self-center" id="customerInternetCreateFeedback"></div>
</div>
</div>
<div id="customerInternetLoading" class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary"></div>
<div class="text-muted mt-2">Henter internetforbindelser...</div>
</div>
<div id="customerInternetEmpty" class="text-center py-5 d-none">
<div class="fw-semibold mb-1">Ingen internetforbindelser på kunden endnu</div>
<div class="text-muted">Opret den første forbindelse direkte her eller i internetmodulet.</div>
</div>
<div id="customerInternetList" class="d-none"></div>
</div>
</div>
<!-- Hardware Tab --> <!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware"> <div class="tab-pane fade" id="hardware">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
@ -1878,6 +2095,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
const internetConnectionsTab = document.querySelector('a[href="#internet-connections"]');
if (internetConnectionsTab) {
internetConnectionsTab.addEventListener('shown.bs.tab', () => {
loadCustomerInternetConnections();
}, { once: false });
}
// Load hardware when tab is shown // Load hardware when tab is shown
const hardwareTab = document.querySelector('a[href="#hardware"]'); const hardwareTab = document.querySelector('a[href="#hardware"]');
if (hardwareTab) { if (hardwareTab) {
@ -5642,6 +5866,204 @@ function formatShortDate(value) {
return date.toLocaleDateString('da-DK'); return date.toLocaleDateString('da-DK');
} }
async function loadCustomerInternetConnections() {
const loading = document.getElementById('customerInternetLoading');
const empty = document.getElementById('customerInternetEmpty');
const list = document.getElementById('customerInternetList');
if (!loading || !empty || !list) return;
loading.classList.remove('d-none');
empty.classList.add('d-none');
list.classList.add('d-none');
try {
const response = await fetch(`/api/v1/internet-connections?customer_id=${customerId}`);
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.detail || 'Kunne ikke hente internetforbindelser');
}
const connections = Array.isArray(payload) ? payload : [];
window.__customerInternetConnections = connections;
renderCustomerInternetConnections(connections);
} catch (error) {
loading.innerHTML = `<div class="alert alert-danger mb-0"><i class="bi bi-exclamation-circle me-2"></i>${escapeHtml(error.message || 'Ukendt fejl')}</div>`;
}
}
function renderCustomerInternetConnections(connections) {
const loading = document.getElementById('customerInternetLoading');
const empty = document.getElementById('customerInternetEmpty');
const list = document.getElementById('customerInternetList');
const items = Array.isArray(connections) ? connections : [];
const totalCost = items.reduce((sum, item) => sum + Number(item.monthly_cost || 0), 0);
const totalSales = items.reduce((sum, item) => sum + Number(item.sales_price || 0), 0);
const totalInUseIps = items.reduce((sum, item) => sum + Number(item.in_use_ip_addresses || 0), 0);
document.getElementById('customerInternetCount').textContent = String(items.length);
document.getElementById('customerInternetCost').textContent = formatDKK(totalCost);
document.getElementById('customerInternetSales').textContent = formatDKK(totalSales);
document.getElementById('customerInternetIps').textContent = String(totalInUseIps);
loading.classList.add('d-none');
if (!items.length) {
empty.classList.remove('d-none');
list.classList.add('d-none');
list.innerHTML = '';
return;
}
empty.classList.add('d-none');
list.classList.remove('d-none');
list.innerHTML = items.map((item) => {
const status = String(item.status || 'active').toLowerCase();
const statusClass = {
active: 'internet-status-active',
inactive: 'internet-status-inactive',
planned: 'internet-status-planned',
pending: 'internet-status-pending',
terminated: 'internet-status-terminated',
cancelled: 'internet-status-cancelled',
}[status] || 'internet-status-inactive';
const parentLabel = item.parent_name
? `<div class="small text-muted"><i class="bi bi-diagram-2 me-1"></i>Ligger under ${escapeHtml(item.parent_name)}</div>`
: '';
const monitoring = item.monitoring_url
? `<a href="${escapeHtml(item.monitoring_url)}" target="_blank" class="btn btn-sm btn-outline-secondary"><i class="bi bi-activity me-1"></i>Overvågning</a>`
: '';
return `
<div class="internet-connection-card">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-start gap-3 mb-3">
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h6 class="mb-0">${escapeHtml(item.name || '-')}</h6>
<span class="internet-status-pill ${statusClass}">${escapeHtml(item.status || '-')}</span>
</div>
<div class="text-muted mt-1">${escapeHtml(item.provider || 'Ingen leverandør')} · ${escapeHtml(item.connection_type || item.technology || 'Forbindelse')}</div>
${parentLabel}
</div>
<div class="d-flex gap-2 flex-wrap">
${monitoring}
<a href="/economy/internet-connections/${item.id}" class="btn btn-sm btn-primary">
<i class="bi bi-eye me-1"></i>Åbn
</a>
</div>
</div>
<div class="internet-meta-grid">
<div class="internet-meta-item">
<span class="label">Adresse</span>
<span class="value">${escapeHtml(item.address || '-')}</span>
</div>
<div class="internet-meta-item">
<span class="label">Kredsløb</span>
<span class="value">${escapeHtml(item.circuit_number || '-')}</span>
</div>
<div class="internet-meta-item">
<span class="label">Hastighed</span>
<span class="value">${formatConnectionSpeed(item)}</span>
</div>
<div class="internet-meta-item">
<span class="label">Kost / salg</span>
<span class="value">${formatDKK(Number(item.monthly_cost || 0))} / ${formatDKK(Number(item.sales_price || 0))}</span>
</div>
<div class="internet-meta-item">
<span class="label">DB</span>
<span class="value">${formatDKK(Number(item.margin_amount || 0))}</span>
</div>
<div class="internet-meta-item">
<span class="label">IP i brug</span>
<span class="value">${Number(item.in_use_ip_addresses || 0)} af ${Number(item.total_ip_addresses || 0)}</span>
</div>
</div>
</div>
`;
}).join('');
}
function formatConnectionSpeed(item) {
const download = Number(item.download_mbps || 0);
const upload = Number(item.upload_mbps || 0);
const speed = Number(item.speed_mbps || 0);
if (download || upload) {
return `${download || 0}/${upload || 0} Mbps`;
}
if (speed) {
return `${speed} Mbps`;
}
return '-';
}
function toggleCustomerInternetCreate(forceState) {
const panel = document.getElementById('customerInternetCreatePanel');
if (!panel) return;
const shouldShow = typeof forceState === 'boolean' ? forceState : panel.classList.contains('d-none');
panel.classList.toggle('d-none', !shouldShow);
}
async function createCustomerInternetConnection() {
const feedback = document.getElementById('customerInternetCreateFeedback');
const payload = {
customer_id: customerId,
name: document.getElementById('customerInternetName').value.trim(),
provider: document.getElementById('customerInternetProvider').value.trim() || null,
circuit_number: document.getElementById('customerInternetCircuit').value.trim() || null,
technology: document.getElementById('customerInternetTechnology').value.trim() || null,
address: document.getElementById('customerInternetAddress').value.trim() || null,
download_mbps: Number(document.getElementById('customerInternetDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('customerInternetUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('customerInternetCostInput').value || 0),
sales_price: Number(document.getElementById('customerInternetSalesInput').value || 0),
notes: document.getElementById('customerInternetNotes').value.trim() || null,
status: 'active',
connection_type: 'fiber',
};
if (!payload.name) {
feedback.textContent = 'Navn er påkrævet.';
return;
}
feedback.textContent = 'Gemmer...';
try {
const response = await fetch('/api/v1/internet-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.detail || 'Kunne ikke oprette forbindelse');
}
[
'customerInternetName',
'customerInternetProvider',
'customerInternetCircuit',
'customerInternetTechnology',
'customerInternetAddress',
'customerInternetDownload',
'customerInternetUpload',
'customerInternetCostInput',
'customerInternetSalesInput',
'customerInternetNotes',
].forEach((id) => {
const field = document.getElementById(id);
if (field) field.value = '';
});
feedback.textContent = 'Forbindelse oprettet.';
await loadCustomerInternetConnections();
toggleCustomerInternetCreate(false);
} catch (error) {
feedback.textContent = error.message || 'Kunne ikke oprette forbindelse';
}
}
function editInternalComment() { function editInternalComment() {
const commentText = document.getElementById('commentText').textContent; const commentText = document.getElementById('commentText').textContent;
const commentInput = document.getElementById('internalCommentInput'); const commentInput = document.getElementById('internalCommentInput');

View File

@ -0,0 +1 @@
"""Internet connections module package."""

View File

@ -0,0 +1,143 @@
import json
import re
from typing import Any, Dict, List, Optional
NETWORK_KINDS = {"internet_access", "ip_allocation"}
def parse_product_attributes(raw: Any) -> Dict[str, Any]:
if raw is None:
return {}
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
text = raw.strip()
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
return {}
def _parse_speed_from_text(text: str) -> Dict[str, Optional[int]]:
match = re.search(r"(\d+)\s*/\s*(\d+)\s*(?:mbit|mbps|gbit|gbps)?", text, re.IGNORECASE)
if not match:
return {"speed_mbps": None, "download_mbps": None, "upload_mbps": None}
download = int(match.group(1))
upload = int(match.group(2))
if re.search(r"(gbit|gbps)", text, re.IGNORECASE):
download *= 1000
upload *= 1000
return {
"speed_mbps": max(download, upload),
"download_mbps": download,
"upload_mbps": upload,
}
def _parse_prefix_from_text(text: str) -> Optional[int]:
match = re.search(r"/(\d{1,2})", text)
if not match:
return None
try:
prefix = int(match.group(1))
except ValueError:
return None
return prefix if 0 <= prefix <= 32 else None
def build_network_product_profile(product: Dict[str, Any], fallback_text: Optional[str] = None) -> Dict[str, Any]:
attributes = parse_product_attributes(product.get("attributes_json"))
network = attributes.get("network") if isinstance(attributes.get("network"), dict) else {}
text = " ".join(
part for part in [
str(product.get("name") or "").strip(),
str(product.get("product_name") or "").strip(),
str(product.get("description") or "").strip(),
str(fallback_text or "").strip(),
]
if part
)
lowered = text.lower()
kind = (
network.get("kind")
or attributes.get("network_kind")
or product.get("network_kind")
or product.get("type")
)
if kind not in NETWORK_KINDS:
if "/3" in lowered and "ip" in lowered:
kind = "ip_allocation"
elif re.search(r"/\d{1,2}", lowered) and "ip" in lowered:
kind = "ip_allocation"
elif "bmcnet" in lowered or "internet" in lowered or "fiber" in lowered:
kind = "internet_access"
else:
kind = None
speeds = _parse_speed_from_text(text)
speed_mbps = network.get("speed_mbps") or attributes.get("speed_mbps") or speeds["speed_mbps"]
download_mbps = network.get("download_mbps") or attributes.get("download_mbps") or speeds["download_mbps"]
upload_mbps = network.get("upload_mbps") or attributes.get("upload_mbps") or speeds["upload_mbps"]
ip_prefix_length = network.get("ip_prefix_length") or attributes.get("ip_prefix_length") or _parse_prefix_from_text(text)
connection_type = network.get("connection_type") or attributes.get("connection_type")
return {
"kind": kind,
"is_network_product": kind in NETWORK_KINDS,
"requires_provisioning": kind in NETWORK_KINDS,
"speed_mbps": int(speed_mbps) if speed_mbps is not None else None,
"download_mbps": int(download_mbps) if download_mbps is not None else None,
"upload_mbps": int(upload_mbps) if upload_mbps is not None else None,
"ip_prefix_length": int(ip_prefix_length) if ip_prefix_length is not None else None,
"connection_type": connection_type,
"attributes": attributes,
}
def summarize_subscription_network_requirements(line_items: List[Dict[str, Any]]) -> Dict[str, Any]:
internet_items: List[Dict[str, Any]] = []
ip_items: List[Dict[str, Any]] = []
for item in line_items or []:
profile = build_network_product_profile(item, fallback_text=item.get("description"))
if not profile["requires_provisioning"]:
continue
entry = {
"subscription_item_id": item.get("id"),
"line_no": item.get("line_no"),
"product_id": item.get("product_id"),
"product_name": item.get("product_name") or item.get("description"),
"description": item.get("description"),
"quantity": item.get("quantity"),
"unit_price": item.get("unit_price"),
"line_total": item.get("line_total"),
"network_kind": profile["kind"],
"speed_mbps": profile["speed_mbps"],
"download_mbps": profile["download_mbps"],
"upload_mbps": profile["upload_mbps"],
"ip_prefix_length": profile["ip_prefix_length"],
"connection_type": profile["connection_type"],
}
if profile["kind"] == "internet_access":
internet_items.append(entry)
elif profile["kind"] == "ip_allocation":
ip_items.append(entry)
primary_internet_item = internet_items[0] if internet_items else None
return {
"requires_provisioning": bool(internet_items or ip_items),
"internet_items": internet_items,
"ip_items": ip_items,
"primary_internet_item": primary_internet_item,
"required_ip_prefixes": [item["ip_prefix_length"] for item in ip_items if item.get("ip_prefix_length") is not None],
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
logger = logging.getLogger(__name__)
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/economy/internet-connections", response_class=HTMLResponse)
async def internet_connections_index(request: Request):
return templates.TemplateResponse(
"modules/internet_connections/templates/index.html",
{"request": request, "title": "Internetforbindelser"},
)
@router.get("/economy/internet-connections/{connection_id}", response_class=HTMLResponse)
async def internet_connection_detail(request: Request, connection_id: int):
return templates.TemplateResponse(
"modules/internet_connections/templates/detail.html",
{"request": request, "title": "Forbindelsesdetaljer", "connection_id": connection_id},
)
@router.get("/data-migration/internet-wizard-v2", response_class=HTMLResponse)
async def internet_connection_migration_wizard_v2(request: Request):
return templates.TemplateResponse(
"modules/internet_connections/templates/migration_wizard_v2.html",
{"request": request, "title": "Internet Wizard v2"},
)

View File

@ -0,0 +1,11 @@
{
"name": "internet_connections",
"version": "0.1.0",
"description": "Modul til administration af internetforbindelser, IP-adresser og priser",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "internet_connections_",
"api_prefix": "/api/v1/internet-connections",
"tags": ["Internetforbindelser"]
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,633 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Internetforbindelser{% endblock %}
{% block extra_css %}
<style>
.internet-hero {
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.18), transparent 34%),
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(15, 76, 117, 0.14);
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
}
.internet-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.internet-kpi {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
height: 100%;
}
.internet-kpi-label {
color: var(--text-secondary);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.internet-kpi-value {
color: var(--text-primary);
font-size: 1.55rem;
font-weight: 700;
}
.internet-toolbar {
display: grid;
grid-template-columns: 1.4fr 1fr 0.8fr 0.9fr auto auto;
gap: 0.75rem;
}
.internet-row {
cursor: pointer;
}
.internet-row td {
vertical-align: middle;
}
.internet-tabs {
display: inline-flex;
padding: 0.35rem;
border-radius: 999px;
background: rgba(15, 76, 117, 0.07);
gap: 0.35rem;
}
.internet-tab {
border: 0;
background: transparent;
border-radius: 999px;
padding: 0.6rem 1rem;
font-weight: 700;
color: var(--text-secondary);
}
.internet-tab.active {
background: #0f4c75;
color: white;
}
.internet-tag {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border-radius: 999px;
padding: 0.18rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
margin-right: 0.35rem;
}
.internet-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.7rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.internet-status.active {
background: rgba(25, 135, 84, 0.12);
color: #146c43;
}
.internet-status.inactive {
background: rgba(108, 117, 125, 0.14);
color: #495057;
}
.internet-status.pending,
.internet-status.planned {
background: rgba(255, 193, 7, 0.18);
color: #997404;
}
.internet-status.terminated,
.internet-status.cancelled {
background: rgba(220, 53, 69, 0.12);
color: #b02a37;
}
.internet-mini {
color: var(--text-secondary);
font-size: 0.83rem;
}
.internet-quick-form {
border: 1px dashed rgba(15, 76, 117, 0.2);
border-radius: 18px;
background: rgba(15, 76, 117, 0.03);
padding: 1rem;
}
@media (max-width: 991px) {
.internet-toolbar {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="internet-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Økonomi / Drift / Support</div>
<h2 class="h3 mb-1">Internetforbindelser</h2>
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</button>
</div>
</div>
<div class="internet-tabs mt-3">
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div>
<div class="internet-kpi-value" id="metricTotal">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Aktive</div>
<div class="internet-kpi-value" id="metricActive">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label" id="metricSharedLabel">Delte hoveder</div>
<div class="internet-kpi-value" id="metricShared">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Dækningsbidrag</div>
<div class="internet-kpi-value" id="metricMargin">0 kr.</div>
</div>
</div>
</div>
<div class="internet-panel p-4 mb-4 collapse" id="createConnectionBlock">
<div class="internet-quick-form">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="fw-semibold">Opret ny forbindelse</div>
<div class="internet-mini">Bruges til fysiske og interne BMC-forbindelser.</div>
</div>
<div class="small text-muted" id="createConnectionFeedback" role="status"></div>
</div>
<div class="row g-2">
<div class="col-lg-3">
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionCircuitInput" placeholder="Kredsløb" />
</div>
<div class="col-lg-3">
<input type="text" class="form-control" id="connectionAddressInput" placeholder="Installationsadresse" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionAllocationInput">
<option value="dedicated">Dedikeret</option>
<option value="shared">Delt</option>
</select>
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionValueTypeInput" onchange="toggleCreateValueFields()">
<option value="other">Anden værdi</option>
<option value="subscription">Abonnement</option>
<option value="bmc_networks">BMC Networks</option>
<option value="delefiber">Delefiber</option>
</select>
</div>
<div class="col-lg-4" id="createValueLabelWrap">
<input type="text" class="form-control" id="connectionValueLabelInput" placeholder="Værdi / klassifikation" value="Mangler klassifikation" />
</div>
<div class="col-lg-4 d-none" id="createSubscriptionWrap">
<input type="text" class="form-control" id="connectionSubscriptionInput" list="subscriptionLookupList" placeholder="Abonnement">
<input type="hidden" id="connectionSubscriptionIdInput" />
</div>
<div class="col-lg-2">
<input type="text" class="form-control" id="connectionTechnologyInput" placeholder="Teknologi" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionDownloadInput" placeholder="Download" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionUploadInput" placeholder="Upload" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionPurchaseInput" placeholder="Kost" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionSalesInput" placeholder="Salg" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionStatusInput">
<option value="active">Aktiv</option>
<option value="planned">Planlagt</option>
<option value="inactive">Inaktiv</option>
</select>
</div>
<div class="col-12">
<textarea class="form-control" id="connectionNotesInput" rows="2" placeholder="Noter, SLA, overvågning, intern struktur..."></textarea>
</div>
</div>
<div class="mt-3">
<button class="btn btn-primary" type="button" onclick="submitConnectionForm()">Gem forbindelse</button>
</div>
</div>
</div>
<div class="internet-panel p-4 mb-4">
<div class="internet-toolbar mb-3">
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter">
<option value="">Alle statusser</option>
<option value="active">Aktive</option>
<option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option>
</select>
<select class="form-select" id="valueTypeFilter">
<option value="">Alle værdier</option>
<option value="subscription">Abonnement</option>
<option value="bmc_networks">BMC Networks</option>
<option value="delefiber">Delefiber</option>
<option value="other">Anden</option>
</select>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">Anvend</button>
<button class="btn btn-light border" type="button" onclick="resetFilters()">Nulstil</button>
</div>
<div class="row g-3 mb-3">
<div class="col-xl-6">
<div class="internet-mini" id="pageSummaryText">Indlæser forbindelser...</div>
</div>
<div class="col-xl-6 text-xl-end">
<div class="internet-mini">Klik på en række for at åbne forbindelsesdetaljer.</div>
</div>
</div>
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead class="table-light">
<tr>
<th>Forbindelse</th>
<th>Kunde</th>
<th>Leverandør / kredsløb</th>
<th>Hastighed / IP</th>
<th>Økonomi</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody id="connectionsTableBody">
<tr>
<td colspan="7" class="text-muted py-4">Indlæser...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<script>
let allConnections = [];
let activeTab = 'all';
let subscriptionOptions = [];
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
}
function formatSpeed(item) {
const down = Number(item.download_mbps || 0);
const up = Number(item.upload_mbps || 0);
const speed = Number(item.speed_mbps || 0);
if (down || up) return `${down || 0}/${up || 0} Mbps`;
if (speed) return `${speed} Mbps`;
return '-';
}
function statusBadge(status) {
const value = String(status || 'inactive').toLowerCase();
const labelMap = {
active: 'Aktiv',
planned: 'Planlagt',
pending: 'Afventer',
inactive: 'Inaktiv',
terminated: 'Opsagt',
cancelled: 'Annulleret',
};
return `<span class="internet-status ${value}">${labelMap[value] || value}</span>`;
}
function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
return `<span class="internet-tag">${label}</span>`;
}
function valueBadge(item) {
const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${label}</span>`;
}
function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet';
return '';
}
async function safeJson(response, fallback) {
if (!response.ok) return fallback;
try {
return await response.json();
} catch {
return fallback;
}
}
async function extractErrorMessage(response, fallback) {
try {
const payload = await response.clone().json();
if (typeof payload?.detail === 'string' && payload.detail.trim()) return payload.detail.trim();
if (Array.isArray(payload?.detail)) {
const validation = payload.detail
.map((item) => item?.msg || item?.message || '')
.filter(Boolean)
.join(', ');
if (validation) return validation;
}
} catch (_) {}
try {
const text = (await response.text()).trim();
if (text) return text;
} catch (_) {}
return fallback;
}
async function loadInternetPage() {
const search = document.getElementById('searchInput').value.trim();
const provider = document.getElementById('providerFilter').value.trim();
const status = document.getElementById('statusFilter').value;
const valueType = document.getElementById('valueTypeFilter').value;
const params = new URLSearchParams();
if (search) params.set('q', search);
if (provider) params.set('provider', provider);
if (status) params.set('status', status);
if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : [];
const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length;
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = String(active);
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections);
} catch (error) {
document.getElementById('connectionsTableBody').innerHTML = '<tr><td colspan="7" class="text-danger py-4">Kunne ikke indlæse internetforbindelser.</td></tr>';
document.getElementById('pageSummaryText').textContent = 'Indlæsning fejlede.';
}
}
function renderConnections(connections) {
const body = document.getElementById('connectionsTableBody');
document.getElementById('pageSummaryText').textContent = `${connections.length} forbindelser vist${currentTabDescription()}`;
if (!connections.length) {
body.innerHTML = '<tr><td colspan="7" class="text-muted py-4">Ingen forbindelser matcher filtrene.</td></tr>';
return;
}
body.innerHTML = connections.map((item) => `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
<div class="fw-semibold">${item.name || '-'}</div>
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${item.address || '-'}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''}
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td>
<td>
<div>${item.customer_name || '-'}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
</td>
<td>
<div>${item.provider || '-'}</div>
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')}
</td>
<td>
<div>${formatSpeed(item)}</div>
<div class="internet-mini">Ranges ${Number(item.ip_range_count || 0)} · IP i brug: ${Number(item.in_use_ip_addresses || 0)} / ${Number(item.total_ip_addresses || 0)}</div>
${item.is_shared_head ? `<div class="internet-mini">BMCnet-IP i brug: ${Number(item.bmcnet_child_ip_count || 0)}</div>` : ''}
</td>
<td>
<div>${formatDKK(item.sales_price || 0)}</div>
<div class="internet-mini">Kost ${formatDKK(item.monthly_cost || 0)} · DB ${formatDKK(item.margin_amount || 0)}</div>
</td>
<td>${statusBadge(item.status)}</td>
<td class="text-end">
<a href="/economy/internet-connections/${item.id}" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
Åbn
</a>
</td>
</tr>
`).join('');
}
async function submitConnectionForm() {
const feedback = document.getElementById('createConnectionFeedback');
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
provider: document.getElementById('connectionProviderInput').value.trim() || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
technology: document.getElementById('connectionTechnologyInput').value.trim() || null,
download_mbps: Number(document.getElementById('connectionDownloadInput').value || 0) || null,
upload_mbps: Number(document.getElementById('connectionUploadInput').value || 0) || null,
monthly_cost: Number(document.getElementById('connectionPurchaseInput').value || 0),
sales_price: Number(document.getElementById('connectionSalesInput').value || 0),
status: document.getElementById('connectionStatusInput').value || 'active',
notes: document.getElementById('connectionNotesInput').value.trim() || null,
connection_type: 'fiber',
allocation_model: document.getElementById('connectionAllocationInput').value || 'dedicated',
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
};
if (!payload.name) {
feedback.textContent = 'Navn er påkrævet.';
return;
}
if (!payload.address) {
feedback.textContent = 'Adresse er påkrævet.';
return;
}
if (payload.value_type === 'subscription' && !payload.subscription_id) {
feedback.textContent = 'Vælg et gyldigt abonnement.';
return;
}
feedback.textContent = 'Gemmer...';
if (saveButton) saveButton.disabled = true;
try {
const response = await fetch('/api/v1/internet-connections', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
feedback.textContent = await extractErrorMessage(response, 'Kunne ikke gemme forbindelsen.');
return;
}
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
'connectionProviderInput',
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
'connectionTechnologyInput',
'connectionDownloadInput',
'connectionUploadInput',
'connectionPurchaseInput',
'connectionSalesInput',
'connectionNotesInput',
].forEach((id) => {
const field = document.getElementById(id);
if (field) field.value = '';
});
document.getElementById('connectionStatusInput').value = 'active';
document.getElementById('connectionAllocationInput').value = 'dedicated';
document.getElementById('connectionValueTypeInput').value = 'other';
document.getElementById('connectionValueLabelInput').value = 'Mangler klassifikation';
document.getElementById('connectionSubscriptionInput').value = '';
document.getElementById('connectionSubscriptionIdInput').value = '';
toggleCreateValueFields();
await loadInternetPage();
} catch (error) {
feedback.textContent = error?.message || 'Netværksfejl under gem.';
} finally {
if (saveButton) saveButton.disabled = false;
}
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('providerFilter').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('valueTypeFilter').value = '';
loadInternetPage();
}
function setActiveTab(tab) {
activeTab = tab;
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
loadInternetPage();
}
function toggleCreateValueFields() {
const valueType = document.getElementById('connectionValueTypeInput').value;
document.getElementById('createValueLabelWrap').classList.toggle('d-none', valueType !== 'other');
document.getElementById('createSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
function buildSubscriptionLabel(item) {
return `${item.id} · ${item.subscription_number || '-'} · ${item.product_name || '-'} · ${item.customer_name || '-'}`;
}
function resolveSubscriptionId(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const idMatch = raw.match(/^(\d+)\b/);
if (idMatch) return Number(idMatch[1]);
const matched = subscriptionOptions.find((item) => buildSubscriptionLabel(item).toLowerCase() === raw.toLowerCase());
return matched ? Number(matched.id) : null;
}
async function loadSubscriptionOptions() {
try {
const response = await fetch('/api/v1/internet-connections/subscription-options');
subscriptionOptions = response.ok ? await response.json() : [];
} catch (error) {
subscriptionOptions = [];
}
document.getElementById('subscriptionLookupList').innerHTML = subscriptionOptions
.map((item) => `<option value="${buildSubscriptionLabel(item)}"></option>`)
.join('');
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
});
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
toggleCreateValueFields();
await loadSubscriptionOptions();
await loadInternetPage();
});
</script>
<datalist id="subscriptionLookupList"></datalist>
{% endblock %}

View File

@ -0,0 +1,557 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Internet Wizard v2{% endblock %}
{% block extra_css %}
<style>
.wiz-hero {
background:
radial-gradient(circle at top right, rgba(15, 76, 117, 0.16), transparent 36%),
linear-gradient(135deg, rgba(15, 76, 117, 0.1), rgba(255, 255, 255, 0.02));
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 14px 34px rgba(15, 76, 117, 0.06);
}
.wiz-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 30px rgba(15, 76, 117, 0.05);
}
.wiz-kpi {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
}
.wiz-kpi-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary);
font-weight: 700;
}
.wiz-kpi-value {
font-size: 1.4rem;
font-weight: 700;
color: var(--text-primary);
}
.wiz-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 1rem;
}
.wiz-card {
border: 1px solid rgba(15, 76, 117, 0.1);
border-radius: 16px;
padding: 1rem;
background: rgba(15, 76, 117, 0.03);
}
.wiz-card h3 {
font-size: 1rem;
margin-bottom: 0.35rem;
}
.wiz-meta {
color: var(--text-secondary);
font-size: 0.85rem;
}
.wiz-snippet {
border-left: 3px solid rgba(15, 76, 117, 0.25);
padding-left: 0.8rem;
margin-top: 0.75rem;
color: var(--text-primary);
}
.wiz-upload-box {
border: 1px dashed rgba(15, 76, 117, 0.24);
border-radius: 16px;
padding: 1rem;
background: rgba(15, 76, 117, 0.025);
}
.wiz-empty {
color: var(--text-secondary);
font-style: italic;
}
.wiz-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border-radius: 999px;
padding: 0.35rem 0.7rem;
background: rgba(15, 76, 117, 0.08);
color: #0f4c75;
font-size: 0.78rem;
font-weight: 700;
}
.wiz-list {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.wiz-summary {
min-height: 110px;
white-space: pre-wrap;
}
@media (max-width: 991px) {
.wiz-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="wiz-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-center gap-3">
<div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Data migration / Internet</div>
<h2 class="h3 mb-1">Internet Wizard v2</h2>
<div class="text-muted">Midlertidig research-wizard til gamle kundetekster, internetnoter og leverandørfakturaer fra de sidste 18 måneder.</div>
</div>
<div class="wiz-pill">
<i class="bi bi-magic"></i>
Temp version
</div>
</div>
</div>
<div class="wiz-panel p-4 mb-4">
<div class="row g-3 align-items-end">
<div class="col-lg-5">
<label class="form-label fw-semibold">Kunde</label>
<input type="text" class="form-control" id="customerSearchInput" list="customerOptions" placeholder="Søg kunde og vælg fra listen">
<datalist id="customerOptions"></datalist>
<input type="hidden" id="selectedCustomerId">
<div class="wiz-meta mt-2" id="selectedCustomerMeta">Ingen kunde valgt endnu.</div>
</div>
<div class="col-lg-5">
<label class="form-label fw-semibold">Søgning / fokus</label>
<input type="text" class="form-control" id="queryInput" placeholder="fx fiber, MPLS, Lejrvej, public IP, gammel aftale">
</div>
<div class="col-lg-2 d-grid">
<button class="btn btn-primary" type="button" onclick="loadWizardContext()">
<i class="bi bi-search me-1"></i>Vis info
</button>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="wiz-kpi">
<div class="wiz-kpi-label">Kundefiler</div>
<div class="wiz-kpi-value" id="metricDocuments">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="wiz-kpi">
<div class="wiz-kpi-label">Blokfund</div>
<div class="wiz-kpi-value" id="metricSnippets">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="wiz-kpi">
<div class="wiz-kpi-label">Internetfakturaer</div>
<div class="wiz-kpi-value" id="metricInvoices">0</div>
</div>
</div>
<div class="col-6 col-xl-3">
<div class="wiz-kpi">
<div class="wiz-kpi-label">Status</div>
<div class="wiz-kpi-value fs-6" id="wizardStatusText">Venter</div>
</div>
</div>
</div>
<div class="wiz-grid mb-4">
<div class="wiz-panel p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-1">AI-overblik</h3>
<div class="wiz-meta">Kort opsummering fra kundefiler og relevante internetfakturaer.</div>
</div>
</div>
<div class="wiz-card wiz-summary" id="aiSummaryBox">Vælg en kunde for at hente kontekst.</div>
</div>
<div class="wiz-panel p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-1">Upload tekstfiler</h3>
<div class="wiz-meta">Upload delte batch-filer med flere kunder. Systemet splitter dem i søgbare blokke med fx IP, CIDR, stiknr og referencer.</div>
</div>
</div>
<div class="wiz-upload-box">
<div class="mb-2">
<input type="file" class="form-control" id="customerFileInput" accept=".txt,.csv,.log,.md">
</div>
<div class="mb-2">
<textarea class="form-control" id="customerFileNotes" rows="3" placeholder="Noter om filen eller kontekst"></textarea>
</div>
<div class="d-grid">
<button class="btn btn-outline-primary" type="button" onclick="uploadCustomerFile()">
<i class="bi bi-upload me-1"></i>Upload fil
</button>
</div>
<div class="wiz-meta mt-2" id="uploadStatus">Ingen upload kørt endnu.</div>
</div>
</div>
</div>
<div class="row g-4">
<div class="col-xl-6">
<div class="wiz-panel p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-1">Kundetekster</h3>
<div class="wiz-meta">Uploadede filer og de bedste fund fra dem.</div>
</div>
</div>
<div class="wiz-list" id="documentList">
<div class="wiz-empty">Ingen dokumenter endnu.</div>
</div>
</div>
</div>
<div class="col-xl-6">
<div class="wiz-panel p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-1">Præcise blokfund</h3>
<div class="wiz-meta">Her finder du den konkrete tekstblok med fx IP, stiknr eller reference.</div>
</div>
</div>
<div class="wiz-list" id="segmentList">
<div class="wiz-empty">Ingen blokfund endnu.</div>
</div>
</div>
</div>
<div class="col-xl-12">
<div class="wiz-panel p-4 h-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h3 class="mb-1">Relaterede fakturaer</h3>
<div class="wiz-meta">De sidste 18 måneder med internet/bredbånd-relateret tekst.</div>
</div>
</div>
<div class="wiz-list" id="invoiceList">
<div class="wiz-empty">Ingen fakturafund endnu.</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let customerSearchTimer = null;
let customerOptions = [];
let localUploadedDocuments = [];
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDate(value) {
if (!value) return '-';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleDateString('da-DK');
}
function formatBytes(value) {
const size = Number(value || 0);
if (!size) return '0 B';
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function formatMoney(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', {
style: 'currency',
currency: currency || 'DKK',
minimumFractionDigits: 2,
}).format(Number(value || 0));
}
function setWizardStatus(text) {
document.getElementById('wizardStatusText').textContent = text;
}
function renderLocalDocumentsOnly(documents = []) {
document.getElementById('metricDocuments').textContent = String(documents.length);
document.getElementById('metricSnippets').textContent = '0';
document.getElementById('metricInvoices').textContent = '0';
document.getElementById('aiSummaryBox').textContent = 'Viser uploadede filer. Vælg kunde eller genindlæs siden efter backend-genstart for fuld kontekst.';
const documentList = document.getElementById('documentList');
if (!documents.length) {
documentList.innerHTML = '<div class="wiz-empty">Ingen dokumenter i det delte arkiv endnu.</div>';
} else {
documentList.innerHTML = documents.map(item => `
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.original_filename || item.filename || 'Uploadet fil')}</h3>
<div class="wiz-meta">${item.created_at ? formatDate(item.created_at) + ' · ' : ''}${item.file_size ? formatBytes(item.file_size) + ' · ' : ''}${escapeHtml(item.mime_type || '-')}</div>
</div>
<span class="wiz-pill">${item.segment_count || 0} blokke</span>
</div>
${(item.notes ? `<div class="wiz-meta mt-2">${escapeHtml(item.notes)}</div>` : '')}
</div>
`).join('');
}
document.getElementById('segmentList').innerHTML = '<div class="wiz-empty">Vælg kunde eller søg på IP/stiknr/reference for konkrete blokfund.</div>';
document.getElementById('invoiceList').innerHTML = '<div class="wiz-empty">Vælg kunde for at se relaterede internetfakturaer.</div>';
}
function renderCustomerOptions(items) {
customerOptions = items || [];
const list = document.getElementById('customerOptions');
list.innerHTML = customerOptions.map(item => (
`<option value="${escapeHtml(item.name)}" data-id="${item.id}"></option>`
)).join('');
}
async function searchCustomers(query) {
const response = await fetch(`/api/v1/customers?search=${encodeURIComponent(query)}&limit=15&is_active=true`);
if (!response.ok) return [];
const payload = await response.json();
return Array.isArray(payload) ? payload : (payload.customers || payload.items || []);
}
async function onCustomerInputChanged() {
const input = document.getElementById('customerSearchInput');
const query = input.value.trim();
document.getElementById('selectedCustomerId').value = '';
document.getElementById('selectedCustomerMeta').textContent = 'Vælg kunde fra listen.';
const exact = customerOptions.find(item => item.name === query);
if (exact) {
document.getElementById('selectedCustomerId').value = exact.id;
document.getElementById('selectedCustomerMeta').textContent = `Kunde-ID ${exact.id} · ${exact.name}`;
return;
}
if (query.length < 2) return;
const items = await searchCustomers(query);
renderCustomerOptions(items);
}
async function loadWizardContext() {
const customerId = document.getElementById('selectedCustomerId').value;
const query = document.getElementById('queryInput').value.trim();
setWizardStatus('Henter');
if (!customerId && !query) {
try {
const docRes = await fetch('/api/v1/internet-connections/customer-documents');
if (docRes.ok) {
const docPayload = await docRes.json();
const docs = docPayload.documents || [];
renderLocalDocumentsOnly(docs);
setWizardStatus('Klar');
return;
}
} catch (error) {
console.warn('Could not load shared document list', error);
}
if (localUploadedDocuments.length) {
renderLocalDocumentsOnly(localUploadedDocuments);
setWizardStatus('Klar');
return;
}
}
const params = new URLSearchParams();
if (customerId) {
params.set('customer_id', customerId);
}
if (query) {
params.set('query', query);
}
const response = await fetch(`/api/v1/internet-connections/migration-wizard-v2/context?${params.toString()}`);
if (!response.ok) {
let payload = {};
try {
payload = await response.json();
} catch (error) {
payload = {};
}
const detail = JSON.stringify(payload.detail || '');
if (!customerId && detail.includes('customer_id')) {
if (localUploadedDocuments.length) {
renderLocalDocumentsOnly(localUploadedDocuments);
setWizardStatus('Klar');
return;
}
setWizardStatus('Venter');
return;
}
setWizardStatus('Fejl');
const errorText = payload.detail ? JSON.stringify(payload.detail) : await response.text();
alert(`Kunne ikke hente kontekst: ${errorText}`);
return;
}
const payload = await response.json();
renderWizardContext(payload);
setWizardStatus('Klar');
}
function renderWizardContext(payload) {
const documents = payload.documents || [];
const invoiceHits = payload.invoice_hits || [];
const segmentHits = payload.segment_hits || [];
const customer = payload.customer || null;
document.getElementById('metricDocuments').textContent = String(documents.length);
document.getElementById('metricSnippets').textContent = String(segmentHits.length);
document.getElementById('metricInvoices').textContent = String(invoiceHits.length);
document.getElementById('aiSummaryBox').textContent = payload.ai_summary || (customer ? 'Ingen AI-opsummering endnu.' : 'Viser delt arkiv. Vælg kunde eller søg på IP/stiknr/reference for mere præcise fund.');
document.getElementById('selectedCustomerMeta').textContent = customer
? `Kunde-ID ${customer.id} · ${customer.name}`
: 'Delt arkiv uden valgt kunde.';
const documentList = document.getElementById('documentList');
if (!documents.length) {
documentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen kundefiler fundet for denne kunde.' : 'Ingen dokumenter i det delte arkiv endnu.'}</div>`;
} else {
documentList.innerHTML = documents.map(item => `
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.original_filename)}</h3>
<div class="wiz-meta">${formatDate(item.created_at)} · ${formatBytes(item.file_size)} · ${escapeHtml(item.mime_type || '-')}</div>
</div>
<span class="wiz-pill">${item.snippet_count || 0} fund</span>
</div>
${(item.notes ? `<div class="wiz-meta mt-2">${escapeHtml(item.notes)}</div>` : '')}
${(item.snippets || []).map(snippet => `<div class="wiz-snippet">${escapeHtml(snippet)}</div>`).join('')}
</div>
`).join('');
}
const segmentList = document.getElementById('segmentList');
if (!segmentHits.length) {
segmentList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen blokke matcher kunden og søgningen endnu.' : 'Ingen blokfund i arkivet endnu.'}</div>`;
} else {
segmentList.innerHTML = segmentHits.map(item => `
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.title || 'Blok')}</h3>
<div class="wiz-meta">Dokument #${item.document_id} · score ${item.score}</div>
</div>
<span class="wiz-pill">blok ${Number(item.block_index || 0) + 1}</span>
</div>
<div class="wiz-snippet">${escapeHtml(item.snippet || '-')}</div>
<div class="wiz-meta mt-2">
${(item.ip_addresses || []).length ? `IP: ${escapeHtml(item.ip_addresses.join(', '))}` : ''}
${(item.cidr_blocks || []).length ? `${(item.ip_addresses || []).length ? ' · ' : ''}CIDR: ${escapeHtml(item.cidr_blocks.join(', '))}` : ''}
${(item.references || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length) ? ' · ' : ''}Ref: ${escapeHtml(item.references.join(', '))}` : ''}
${(item.socket_numbers || []).length ? `${((item.ip_addresses || []).length || (item.cidr_blocks || []).length || (item.references || []).length) ? ' · ' : ''}Stik: ${escapeHtml(item.socket_numbers.join(', '))}` : ''}
</div>
</div>
`).join('');
}
const invoiceList = document.getElementById('invoiceList');
if (!invoiceHits.length) {
invoiceList.innerHTML = `<div class="wiz-empty">${customer ? 'Ingen internetrelaterede fakturafund de sidste 18 måneder.' : 'Vælg kunde for at se relaterede internetfakturaer.'}</div>`;
} else {
invoiceList.innerHTML = invoiceHits.map(item => `
<div class="wiz-card">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<h3>${escapeHtml(item.vendor_name)} · ${escapeHtml(item.invoice_number || '-')}</h3>
<div class="wiz-meta">${formatDate(item.invoice_date)} · ${formatMoney(item.total_amount, item.currency)}${item.directly_linked ? ' · koblet til kunde' : ''}</div>
</div>
<span class="wiz-pill">score ${item.score}</span>
</div>
${(item.source_filename ? `<div class="wiz-meta mt-2">Fil: ${escapeHtml(item.source_filename)}</div>` : '')}
${(item.snippets || []).map(snippet => `<div class="wiz-snippet">${escapeHtml(snippet)}</div>`).join('')}
</div>
`).join('');
}
}
async function uploadCustomerFile() {
const fileInput = document.getElementById('customerFileInput');
const notes = document.getElementById('customerFileNotes').value.trim();
if (!fileInput.files.length) {
alert('Vælg en fil først.');
return;
}
const formData = new FormData();
const customerId = document.getElementById('selectedCustomerId').value;
if (customerId) {
formData.append('customer_id', customerId);
}
formData.append('notes', notes);
formData.append('file', fileInput.files[0]);
document.getElementById('uploadStatus').textContent = 'Uploader...';
const response = await fetch('/api/v1/internet-connections/customer-documents/upload', {
method: 'POST',
body: formData,
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
document.getElementById('uploadStatus').textContent = 'Upload fejlede.';
alert(payload.detail || 'Upload fejlede');
return;
}
document.getElementById('uploadStatus').textContent = payload.message || `Uploadet: ${payload.filename || fileInput.files[0].name} · ${payload.segment_count || 0} blokke indekseret`;
localUploadedDocuments.unshift({
document_id: payload.document_id,
original_filename: payload.filename || fileInput.files[0].name,
mime_type: fileInput.files[0].type || 'text/plain',
file_size: fileInput.files[0].size || 0,
notes,
segment_count: payload.segment_count || 0,
created_at: new Date().toISOString(),
});
fileInput.value = '';
document.getElementById('customerFileNotes').value = '';
await loadWizardContext();
}
document.getElementById('customerSearchInput').addEventListener('input', () => {
clearTimeout(customerSearchTimer);
customerSearchTimer = setTimeout(onCustomerInputChanged, 250);
});
document.getElementById('customerSearchInput').addEventListener('change', onCustomerInputChanged);
document.getElementById('queryInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
loadWizardContext();
}
});
loadWizardContext();
</script>
{% endblock %}

View File

@ -7544,6 +7544,7 @@
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div> <div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
</div> </div>
<div id="subscriptionProvisioningStatus" class="alert alert-light border d-none mb-3"></div>
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div> <div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
</div> </div>
@ -7613,8 +7614,9 @@
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea> <textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
</div> </div>
<div class="col-12"> <div class="col-12">
<button type="button" class="btn btn-primary" onclick="createSubscription()"> <div id="subscriptionCreateHint" class="alert alert-info d-none mb-3"></div>
<i class="bi bi-plus-circle me-1"></i>Opret abonnement <button type="button" class="btn btn-primary" id="subscriptionCreateButton" onclick="createSubscription()">
<i class="bi bi-plus-circle me-1"></i><span id="subscriptionCreateButtonLabel">Opret abonnement</span>
</button> </button>
</div> </div>
</form> </form>
@ -7670,6 +7672,26 @@
<label class="form-label">Kort beskrivelse</label> <label class="form-label">Kort beskrivelse</label>
<input type="text" class="form-control" id="subscriptionProductDescription"> <input type="text" class="form-control" id="subscriptionProductDescription">
</div> </div>
<div class="col-12">
<label class="form-label">Netværksprodukt</label>
<select class="form-select" id="subscriptionProductNetworkKind">
<option value="">Ingen provisioning</option>
<option value="internet_access">Internet adgang</option>
<option value="ip_allocation">IP-allokering</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Download Mbps</label>
<input type="number" class="form-control" id="subscriptionProductDownloadMbps" min="1" step="1">
</div>
<div class="col-6">
<label class="form-label">Upload Mbps</label>
<input type="number" class="form-control" id="subscriptionProductUploadMbps" min="1" step="1">
</div>
<div class="col-12">
<label class="form-label">IP prefix</label>
<input type="number" class="form-control" id="subscriptionProductIpPrefix" min="1" max="32" placeholder="fx 30 for /30">
</div>
</div> </div>
</form> </form>
</div> </div>
@ -7683,6 +7705,43 @@
</div> </div>
</div> </div>
<div class="modal fade" id="subscriptionProvisioningModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-hdd-network me-2"></i>Netværksprovisionering</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="subscriptionProvisioningAlert" class="alert alert-info d-none mb-3"></div>
<div id="subscriptionProvisioningCurrent" class="alert alert-secondary d-none mb-3"></div>
<div class="mb-3">
<label class="form-label">BMC hovedforbindelse *</label>
<select class="form-select" id="subscriptionProvisioningHeadSelect"></select>
<div class="form-text" id="subscriptionProvisioningHeadHint">Vælg eksisterende BMC-adresse/hovedforbindelse.</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Internetprodukt</label>
<select class="form-select" id="subscriptionProvisioningInternetItem"></select>
</div>
<div class="col-md-6">
<label class="form-label">Adresse</label>
<div class="form-control bg-light" id="subscriptionProvisioningAddress">-</div>
</div>
</div>
<div id="subscriptionProvisioningIpRanges"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveSubscriptionProvisioning()">
<i class="bi bi-check2-circle me-1"></i>Gem provisioning
</button>
</div>
</div>
</div>
</div>
<!-- Reminders Tab --> <!-- Reminders Tab -->
<div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown" style="display:none;"> <div class="tab-pane fade" id="reminders" role="tabpanel" tabindex="0" data-module="reminders" data-has-content="unknown" style="display:none;">
<div class="row g-3"> <div class="row g-3">
@ -14242,6 +14301,8 @@
let currentSubscription = null; let currentSubscription = null;
let subscriptionProducts = []; let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null; let lastCreatedSubscriptionProductId = null;
let subscriptionProvisioningData = null;
let subscriptionProvisioningAutoPrompted = false;
function formatSubscriptionInterval(interval) { function formatSubscriptionInterval(interval) {
const map = { const map = {
@ -14323,6 +14384,7 @@
} }
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function populateSubscriptionProductSelects() { function populateSubscriptionProductSelects() {
@ -14336,6 +14398,7 @@
option.textContent = product.name; option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? ''; option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? ''; option.dataset.description = product.short_description ?? '';
option.dataset.attributes = JSON.stringify(product.attributes_json ?? {});
select.appendChild(option); select.appendChild(option);
}); });
if (currentValue) { if (currentValue) {
@ -14347,6 +14410,41 @@
lastCreatedSubscriptionProductId = null; lastCreatedSubscriptionProductId = null;
} }
function selectedSubscriptionNeedsProvisioning() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return false;
return Array.from(body.querySelectorAll('.subscriptionProductSelect')).some(select => {
if (!select.value) return false;
const option = select.options[select.selectedIndex];
if (!option?.dataset?.attributes) return false;
try {
const attrs = JSON.parse(option.dataset.attributes);
const kind = attrs?.network?.kind;
return kind === 'internet_access' || kind === 'ip_allocation';
} catch (e) {
return false;
}
});
}
function refreshSubscriptionCreateState() {
const hint = document.getElementById('subscriptionCreateHint');
const label = document.getElementById('subscriptionCreateButtonLabel');
const needsProvisioning = selectedSubscriptionNeedsProvisioning();
if (hint) {
if (needsProvisioning) {
hint.classList.remove('d-none');
hint.textContent = "Dette abonnement kræver netværksprovisionering. Efter oprettelse åbnes valg af BMC-adresse og IP-range.";
} else {
hint.classList.add('d-none');
hint.textContent = '';
}
}
if (label) {
label.textContent = needsProvisioning ? "Opret abonnement og vælg IP'er" : 'Opret abonnement';
}
}
function applySubscriptionProduct(select) { function applySubscriptionProduct(select) {
const row = select.closest('tr'); const row = select.closest('tr');
if (!row) return; if (!row) return;
@ -14365,6 +14463,7 @@
unitPriceInput.value = salesPrice; unitPriceInput.value = salesPrice;
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function addSubscriptionLine() { function addSubscriptionLine() {
@ -14388,6 +14487,7 @@
body.appendChild(row); body.appendChild(row);
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function removeSubscriptionLine(button) { function removeSubscriptionLine(button) {
@ -14402,6 +14502,7 @@
row.remove(); row.remove();
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function updateSubscriptionLineTotals() { function updateSubscriptionLineTotals() {
@ -14475,13 +14576,28 @@
} }
async function createSubscriptionProduct() { async function createSubscriptionProduct() {
const networkKind = document.getElementById('subscriptionProductNetworkKind').value || null;
const downloadMbps = parseInt(document.getElementById('subscriptionProductDownloadMbps').value || '', 10);
const uploadMbps = parseInt(document.getElementById('subscriptionProductUploadMbps').value || '', 10);
const ipPrefix = parseInt(document.getElementById('subscriptionProductIpPrefix').value || '', 10);
const attributes = {};
if (networkKind) {
attributes.network = {
kind: networkKind,
download_mbps: Number.isFinite(downloadMbps) ? downloadMbps : null,
upload_mbps: Number.isFinite(uploadMbps) ? uploadMbps : null,
speed_mbps: Number.isFinite(downloadMbps) && Number.isFinite(uploadMbps) ? Math.max(downloadMbps, uploadMbps) : null,
ip_prefix_length: Number.isFinite(ipPrefix) ? ipPrefix : null
};
}
const payload = { const payload = {
name: document.getElementById('subscriptionProductName').value.trim(), name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null, type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value, status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null, sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null, billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null short_description: document.getElementById('subscriptionProductDescription').value.trim() || null,
attributes_json: networkKind ? attributes : null
}; };
if (!payload.name) { if (!payload.name) {
@ -14508,6 +14624,200 @@
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
} }
function renderSubscriptionProvisioningStatus(subscription) {
const statusEl = document.getElementById('subscriptionProvisioningStatus');
if (!statusEl) return;
const provisioning = subscription?.network_provisioning;
if (!provisioning?.requires_provisioning) {
subscriptionProvisioningAutoPrompted = false;
statusEl.classList.add('d-none');
statusEl.innerHTML = '';
return;
}
statusEl.classList.remove('d-none');
const internetCount = provisioning.internet_items?.length || 0;
const ipCount = provisioning.ip_items?.length || 0;
const stateText = provisioning.is_provisioned
? `Provisioneret paa forbindelse #${provisioning.existing_connection_id}`
: 'Mangler provisioning';
statusEl.innerHTML = `
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-center">
<div>
<strong>Netværksflow</strong><br>
<span class="text-muted">${stateText}. Internetlinjer: ${internetCount}. IP-produkter: ${ipCount}.</span>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="openSubscriptionProvisioningModal()">
<i class="bi bi-hdd-network me-1"></i>${provisioning.is_provisioned ? 'Opdater provisioning' : 'Provisioner nu'}
</button>
</div>
`;
if (!provisioning.is_provisioned && !subscriptionProvisioningAutoPrompted) {
subscriptionProvisioningAutoPrompted = true;
setTimeout(() => openSubscriptionProvisioningModal(), 250);
}
}
function renderSubscriptionProvisioningModal() {
const headSelect = document.getElementById('subscriptionProvisioningHeadSelect');
const internetSelect = document.getElementById('subscriptionProvisioningInternetItem');
const ipRangesWrap = document.getElementById('subscriptionProvisioningIpRanges');
const addressEl = document.getElementById('subscriptionProvisioningAddress');
const alertEl = document.getElementById('subscriptionProvisioningAlert');
const currentEl = document.getElementById('subscriptionProvisioningCurrent');
if (!headSelect || !internetSelect || !ipRangesWrap || !addressEl || !alertEl || !currentEl) return;
const data = subscriptionProvisioningData;
const provisioning = data?.network_provisioning;
const heads = data?.shared_heads || [];
const currentConnection = data?.existing_connection;
alertEl.classList.remove('d-none', 'alert-danger');
alertEl.classList.add('alert-info');
alertEl.innerHTML = provisioning?.requires_provisioning
? 'Vaelg delt BMC-hovedforbindelse og reserver de noedvendige IP-ranges paa adressen.'
: 'Dette abonnement kraever ikke netvaerksprovisionering.';
if (currentConnection) {
const currentRanges = (data.current_allocated_ranges || []).map(range => range.cidr).join(', ') || 'Ingen IP-ranges';
currentEl.classList.remove('d-none');
currentEl.innerHTML = `<strong>Eksisterende forbindelse:</strong> #${currentConnection.id} · ${currentConnection.name || '-'}<br><span class="text-muted">${currentConnection.address || '-'} · ${currentRanges}</span>`;
} else {
currentEl.classList.add('d-none');
currentEl.innerHTML = '';
}
headSelect.innerHTML = heads.map(head => `
<option value="${head.id}">
${head.name || 'Hovedforbindelse'} · ${head.address || 'Ingen adresse'}${head.available_matching_range_count ? ` · ${head.available_matching_range_count} ledige ranges` : ''}
</option>
`).join('');
if (!heads.length) {
alertEl.classList.remove('alert-info');
alertEl.classList.add('alert-danger');
alertEl.innerHTML = 'Ingen delte BMC-hovedforbindelser med ledig kapacitet matcher abonnementet endnu.';
headSelect.innerHTML = '<option value="">Ingen ledige hovedforbindelser</option>';
addressEl.textContent = '-';
ipRangesWrap.innerHTML = '<div class="alert alert-light border mb-0">Tilfoej eller frigiv kapacitet paa en delt hovedforbindelse foerst.</div>';
return;
}
const internetItems = provisioning?.internet_items || [];
const selectedInternetId = provisioning?.primary_internet_item?.subscription_item_id || internetItems[0]?.subscription_item_id || '';
if (internetItems.length) {
internetSelect.innerHTML = internetItems.map(item => `
<option value="${item.subscription_item_id}" ${String(item.subscription_item_id) === String(selectedInternetId) ? 'selected' : ''}>
${item.description || item.product_name}${item.download_mbps || item.upload_mbps ? ` · ${item.download_mbps || '-'} / ${item.upload_mbps || '-'} Mbps` : ''}
</option>
`).join('');
internetSelect.disabled = internetItems.length <= 1;
} else {
internetSelect.innerHTML = '<option value="">Ingen dedikeret internetlinje</option>';
internetSelect.disabled = true;
}
const selectedHeadId = parseInt(headSelect.value || currentConnection?.parent_id || heads[0]?.id || '', 10);
if (Number.isFinite(selectedHeadId)) {
headSelect.value = String(selectedHeadId);
}
const selectedHead = heads.find(head => Number(head.id) === Number(headSelect.value));
addressEl.textContent = selectedHead?.address || '-';
const ipItems = provisioning?.ip_items || [];
if (!ipItems.length) {
ipRangesWrap.innerHTML = '<div class="alert alert-light border mb-0">Ingen IP-produktlinjer paa abonnementet.</div>';
return;
}
const ranges = selectedHead?.available_matching_ranges || [];
ipRangesWrap.innerHTML = ipItems.map(item => {
const matchingOptions = ranges
.filter(range => !item.ip_prefix_length || Number(range.prefix_length) === Number(item.ip_prefix_length));
return `
<div class="card border-0 bg-light mb-2">
<div class="card-body py-3">
<div class="fw-semibold mb-1">${item.description || item.product_name}</div>
<div class="small text-muted mb-2">Kraever /${item.ip_prefix_length || '?'} fra den valgte BMC-adresse.</div>
<select class="form-select subscriptionProvisioningRangeSelect" data-item-id="${item.subscription_item_id}">
<option value="">Vaelg ledigt range</option>
${matchingOptions
.map(range => `<option value="${range.id}">${range.cidr} · ${range.name || 'Range'} · ${range.available_addresses} ledige IP'er</option>`)
.join('')}
</select>
${matchingOptions.length ? '' : '<div class="small text-danger mt-2">Ingen ledige ranges i den størrelse på denne hovedforbindelse.</div>'}
</div>
</div>
`}).join('');
}
async function openSubscriptionProvisioningModal() {
if (!currentSubscription?.id) return;
try {
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provisioning`);
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke hente provisioning-data');
}
subscriptionProvisioningData = await res.json();
renderSubscriptionProvisioningModal();
const headSelect = document.getElementById('subscriptionProvisioningHeadSelect');
if (headSelect) {
headSelect.onchange = () => renderSubscriptionProvisioningModal();
}
new bootstrap.Modal(document.getElementById('subscriptionProvisioningModal')).show();
} catch (e) {
alert(e.message || e);
}
}
async function saveSubscriptionProvisioning() {
if (!currentSubscription?.id || !subscriptionProvisioningData) return;
const headSelect = document.getElementById('subscriptionProvisioningHeadSelect');
const internetSelect = document.getElementById('subscriptionProvisioningInternetItem');
const rangeSelects = Array.from(document.querySelectorAll('.subscriptionProvisioningRangeSelect'));
const sharedConnectionId = parseInt(headSelect?.value || '', 10);
if (!Number.isFinite(sharedConnectionId)) {
alert('Vaelg en BMC hovedforbindelse');
return;
}
const ipAllocations = [];
for (const select of rangeSelects) {
const subscriptionItemId = parseInt(select.dataset.itemId || '', 10);
const rangeId = parseInt(select.value || '', 10);
if (!Number.isFinite(subscriptionItemId) || !Number.isFinite(rangeId)) {
alert('Vaelg et ledigt range til alle IP-produktlinjer');
return;
}
ipAllocations.push({
subscription_item_id: subscriptionItemId,
range_id: rangeId
});
}
try {
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shared_connection_id: sharedConnectionId,
internet_item_id: internetSelect?.value ? parseInt(internetSelect.value, 10) : null,
ip_allocations: ipAllocations
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke gemme provisioning');
}
bootstrap.Modal.getInstance(document.getElementById('subscriptionProvisioningModal'))?.hide();
await loadSubscriptionForCase();
} catch (e) {
alert(e.message || e);
}
}
function renderSubscription(subscription) { function renderSubscription(subscription) {
currentSubscription = subscription; currentSubscription = subscription;
const empty = document.getElementById('subscriptionEmpty'); const empty = document.getElementById('subscriptionEmpty');
@ -14566,6 +14876,8 @@
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0); itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
} }
renderSubscriptionProvisioningStatus(subscription);
const actions = document.getElementById('subscriptionActions'); const actions = document.getElementById('subscriptionActions');
if (!actions) return; if (!actions) return;
@ -14579,6 +14891,9 @@
if (subscription.status !== 'cancelled') { if (subscription.status !== 'cancelled') {
buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`); buttons.push(`<button class="btn btn-sm btn-outline-danger" onclick="updateSubscriptionStatus('cancelled')"><i class="bi bi-x-circle me-1"></i>Opsig</button>`);
} }
if (subscription?.network_provisioning?.requires_provisioning) {
buttons.push(`<button class="btn btn-sm btn-outline-primary" onclick="openSubscriptionProvisioningModal()"><i class="bi bi-hdd-network me-1"></i>${subscription.network_provisioning.is_provisioned ? 'Opdater provisioning' : 'Provisioner forbindelse'}</button>`);
}
actions.innerHTML = buttons.join(' '); actions.innerHTML = buttons.join(' ');
} }
@ -14649,6 +14964,9 @@
const subscription = await res.json(); const subscription = await res.json();
renderSubscription(subscription); renderSubscription(subscription);
if (subscription?.requires_network_provisioning) {
await openSubscriptionProvisioningModal();
}
} catch (e) { } catch (e) {
alert(e.message || e); alert(e.message || e);
} }

View File

@ -8451,6 +8451,40 @@
<div class="d-flex justify-content-end mb-3"> <div class="d-flex justify-content-end mb-3">
<div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div> <div class="fw-semibold">Total: <span id="subscriptionItemsTotal">0,00 kr</span></div>
</div> </div>
<div id="subscriptionProvisioningStatus" class="alert alert-light border d-none mb-3"></div>
<div id="subscriptionProvisioningInline" class="card border-primary-subtle bg-light d-none mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div class="fw-semibold">Netværksprovisionering</div>
<div class="small text-muted">Vælg BMC-hovedforbindelse og reserver IP-range på kladden, før aktivering.</div>
</div>
</div>
<div id="subscriptionProvisioningInlineAlert" class="alert alert-info d-none mb-3"></div>
<div id="subscriptionProvisioningInlineCurrent" class="alert alert-secondary d-none mb-3"></div>
<div id="subscriptionProvisioningInlineCompact" class="d-none mb-2"></div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">BMC hovedforbindelse *</label>
<select class="form-select" id="subscriptionProvisioningInlineHeadSelect"></select>
</div>
<div class="col-md-6">
<label class="form-label">Internetprodukt</label>
<select class="form-select" id="subscriptionProvisioningInlineInternetItem"></select>
</div>
<div class="col-12">
<label class="form-label">Adresse</label>
<div class="form-control bg-white" id="subscriptionProvisioningInlineAddress">-</div>
</div>
</div>
<div id="subscriptionProvisioningInlineRanges"></div>
<div class="d-flex gap-2 mt-3">
<button type="button" class="btn btn-primary" id="subscriptionProvisioningInlineSaveBtn" onclick="saveSubscriptionProvisioningInline()">
<i class="bi bi-check2-circle me-1"></i>Gem provisioning
</button>
</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div> <div class="d-flex flex-wrap gap-2" id="subscriptionActions"></div>
</div> </div>
@ -8520,9 +8554,15 @@
<textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea> <textarea class="form-control" id="subscriptionNotesInput" rows="2"></textarea>
</div> </div>
<div class="col-12"> <div class="col-12">
<button type="button" class="btn btn-primary" onclick="createSubscription()"> <div id="subscriptionCreateHint" class="alert alert-info d-none mb-3"></div>
<i class="bi bi-plus-circle me-1"></i>Opret abonnement <div class="d-flex flex-wrap gap-2">
</button> <button type="button" class="btn btn-primary" id="subscriptionCreateButton" onclick="createSubscription()">
<i class="bi bi-plus-circle me-1"></i><span id="subscriptionCreateButtonLabel">Opret abonnement</span>
</button>
<button type="button" class="btn btn-outline-secondary d-none" id="subscriptionCancelEditButton" onclick="cancelSubscriptionEdit()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Annuller redigering
</button>
</div>
</div> </div>
</form> </form>
</div> </div>
@ -8577,6 +8617,26 @@
<label class="form-label">Kort beskrivelse</label> <label class="form-label">Kort beskrivelse</label>
<input type="text" class="form-control" id="subscriptionProductDescription"> <input type="text" class="form-control" id="subscriptionProductDescription">
</div> </div>
<div class="col-12">
<label class="form-label">Netværksprodukt</label>
<select class="form-select" id="subscriptionProductNetworkKind">
<option value="">Ingen provisioning</option>
<option value="internet_access">Internet adgang</option>
<option value="ip_allocation">IP-allokering</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Download Mbps</label>
<input type="number" class="form-control" id="subscriptionProductDownloadMbps" min="1" step="1">
</div>
<div class="col-6">
<label class="form-label">Upload Mbps</label>
<input type="number" class="form-control" id="subscriptionProductUploadMbps" min="1" step="1">
</div>
<div class="col-12">
<label class="form-label">IP prefix</label>
<input type="number" class="form-control" id="subscriptionProductIpPrefix" min="1" max="32" placeholder="fx 30 for /30">
</div>
</div> </div>
</form> </form>
</div> </div>
@ -16361,6 +16421,8 @@
let currentSubscription = null; let currentSubscription = null;
let subscriptionProducts = []; let subscriptionProducts = [];
let lastCreatedSubscriptionProductId = null; let lastCreatedSubscriptionProductId = null;
let subscriptionProvisioningData = null;
let subscriptionEditMode = false;
function formatSubscriptionInterval(interval) { function formatSubscriptionInterval(interval) {
const map = { const map = {
@ -16442,6 +16504,81 @@
} }
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
}
function renderSubscriptionEditForm(subscription) {
const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails');
if (empty) empty.classList.add('d-none');
if (form) form.classList.remove('d-none');
if (details) details.classList.add('d-none');
document.getElementById('subscriptionIntervalInput').value = subscription.billing_interval || 'monthly';
document.getElementById('subscriptionBillingDayInput').value = subscription.billing_day || 1;
document.getElementById('subscriptionStartDateInput').value = subscription.start_date || '';
document.getElementById('subscriptionNotesInput').value = subscription.notes || '';
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return;
const items = subscription.line_items || [];
body.innerHTML = items.map(item => `
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse" value="${(item.description || '').replace(/"/g, '&quot;')}"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="${item.quantity ?? 1}" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="${item.unit_price ?? 0}" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
`).join('') || `
<tr>
<td>
<select class="form-select form-select-sm subscriptionProductSelect" onchange="applySubscriptionProduct(this)">
<option value="">Vælg produkt</option>
</select>
</td>
<td><input type="text" class="form-control form-control-sm" placeholder="Beskrivelse"></td>
<td><input type="number" class="form-control form-control-sm" min="0.01" step="0.01" value="1" oninput="updateSubscriptionLineTotals()"></td>
<td><input type="number" class="form-control form-control-sm" min="0" step="0.01" value="0" oninput="updateSubscriptionLineTotals()"></td>
<td class="text-end"><span class="subscriptionLineTotal">0,00 kr</span></td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeSubscriptionLine(this)"><i class="bi bi-x"></i></button>
</td>
</tr>
`;
populateSubscriptionProductSelects();
Array.from(body.querySelectorAll('tr')).forEach((row, index) => {
const select = row.querySelector('.subscriptionProductSelect');
const item = items[index];
if (select && item?.product_id) {
select.value = String(item.product_id);
}
});
updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
}
function startSubscriptionEdit() {
if (!currentSubscription) return;
subscriptionEditMode = true;
renderSubscriptionEditForm(currentSubscription);
}
function cancelSubscriptionEdit() {
subscriptionEditMode = false;
if (currentSubscription?.id) {
renderSubscription(currentSubscription);
} else {
showSubscriptionCreateForm();
}
} }
function populateSubscriptionProductSelects() { function populateSubscriptionProductSelects() {
@ -16455,6 +16592,7 @@
option.textContent = product.name; option.textContent = product.name;
option.dataset.salesPrice = product.sales_price ?? ''; option.dataset.salesPrice = product.sales_price ?? '';
option.dataset.description = product.short_description ?? ''; option.dataset.description = product.short_description ?? '';
option.dataset.attributes = JSON.stringify(product.attributes_json ?? {});
select.appendChild(option); select.appendChild(option);
}); });
if (currentValue) { if (currentValue) {
@ -16466,6 +16604,51 @@
lastCreatedSubscriptionProductId = null; lastCreatedSubscriptionProductId = null;
} }
function selectedSubscriptionNeedsProvisioning() {
const body = document.getElementById('subscriptionLineItemsBody');
if (!body) return false;
return Array.from(body.querySelectorAll('.subscriptionProductSelect')).some(select => {
if (!select.value) return false;
const option = select.options[select.selectedIndex];
if (!option?.dataset?.attributes) return false;
try {
const attrs = JSON.parse(option.dataset.attributes);
const kind = attrs?.network?.kind;
return kind === 'internet_access' || kind === 'ip_allocation';
} catch (e) {
return false;
}
});
}
function refreshSubscriptionCreateState() {
const hint = document.getElementById('subscriptionCreateHint');
const label = document.getElementById('subscriptionCreateButtonLabel');
const cancelButton = document.getElementById('subscriptionCancelEditButton');
const needsProvisioning = selectedSubscriptionNeedsProvisioning();
if (hint) {
if (needsProvisioning) {
hint.classList.remove('d-none');
hint.textContent = subscriptionEditMode
? "Denne kladde kræver netværksprovisionering. Gem kladden og vælg BMC-adresse og IP-range i boksen under abonnementet."
: "Dette abonnement kræver netværksprovisionering. Efter oprettelse bruger du boksen under abonnementet til at vælge BMC-adresse og IP-range.";
} else {
hint.classList.add('d-none');
hint.textContent = '';
}
}
if (label) {
if (subscriptionEditMode) {
label.textContent = needsProvisioning ? "Gem kladde og vælg IP'er" : 'Gem kladde';
} else {
label.textContent = needsProvisioning ? "Opret abonnement og vælg IP'er" : 'Opret abonnement';
}
}
if (cancelButton) {
cancelButton.classList.toggle('d-none', !subscriptionEditMode);
}
}
function applySubscriptionProduct(select) { function applySubscriptionProduct(select) {
const row = select.closest('tr'); const row = select.closest('tr');
if (!row) return; if (!row) return;
@ -16484,6 +16667,7 @@
unitPriceInput.value = salesPrice; unitPriceInput.value = salesPrice;
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function addSubscriptionLine() { function addSubscriptionLine() {
@ -16507,6 +16691,7 @@
body.appendChild(row); body.appendChild(row);
populateSubscriptionProductSelects(); populateSubscriptionProductSelects();
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function removeSubscriptionLine(button) { function removeSubscriptionLine(button) {
@ -16521,6 +16706,7 @@
row.remove(); row.remove();
} }
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
refreshSubscriptionCreateState();
} }
function updateSubscriptionLineTotals() { function updateSubscriptionLineTotals() {
@ -16594,13 +16780,28 @@
} }
async function createSubscriptionProduct() { async function createSubscriptionProduct() {
const networkKind = document.getElementById('subscriptionProductNetworkKind').value || null;
const downloadMbps = parseInt(document.getElementById('subscriptionProductDownloadMbps').value || '', 10);
const uploadMbps = parseInt(document.getElementById('subscriptionProductUploadMbps').value || '', 10);
const ipPrefix = parseInt(document.getElementById('subscriptionProductIpPrefix').value || '', 10);
const attributes = {};
if (networkKind) {
attributes.network = {
kind: networkKind,
download_mbps: Number.isFinite(downloadMbps) ? downloadMbps : null,
upload_mbps: Number.isFinite(uploadMbps) ? uploadMbps : null,
speed_mbps: Number.isFinite(downloadMbps) && Number.isFinite(uploadMbps) ? Math.max(downloadMbps, uploadMbps) : null,
ip_prefix_length: Number.isFinite(ipPrefix) ? ipPrefix : null
};
}
const payload = { const payload = {
name: document.getElementById('subscriptionProductName').value.trim(), name: document.getElementById('subscriptionProductName').value.trim(),
type: document.getElementById('subscriptionProductType').value.trim() || null, type: document.getElementById('subscriptionProductType').value.trim() || null,
status: document.getElementById('subscriptionProductStatus').value, status: document.getElementById('subscriptionProductStatus').value,
sales_price: document.getElementById('subscriptionProductSalesPrice').value || null, sales_price: document.getElementById('subscriptionProductSalesPrice').value || null,
billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null, billing_period: document.getElementById('subscriptionProductBillingPeriod').value || null,
short_description: document.getElementById('subscriptionProductDescription').value.trim() || null short_description: document.getElementById('subscriptionProductDescription').value.trim() || null,
attributes_json: networkKind ? attributes : null
}; };
if (!payload.name) { if (!payload.name) {
@ -16627,8 +16828,398 @@
updateSubscriptionLineTotals(); updateSubscriptionLineTotals();
} }
function renderSubscriptionProvisioningStatus(subscription) {
const statusEl = document.getElementById('subscriptionProvisioningStatus');
if (!statusEl) return;
const provisioning = subscription?.network_provisioning;
if (!provisioning?.requires_provisioning) {
statusEl.classList.add('d-none');
statusEl.innerHTML = '';
return;
}
statusEl.classList.remove('d-none');
const internetCount = provisioning.internet_items?.length || 0;
const ipCount = provisioning.ip_items?.length || 0;
const stateText = provisioning.is_provisioned
? `Provisioneret paa forbindelse #${provisioning.existing_connection_id}`
: 'Mangler provisioning';
statusEl.innerHTML = `
<div class="d-flex flex-wrap justify-content-between gap-2 align-items-center">
<div>
<strong>Netværksflow</strong><br>
<span class="text-muted">${stateText}. Internetlinjer: ${internetCount}. IP-produkter: ${ipCount}.</span>
</div>
</div>
`;
}
function parseProvisioningRangeSelection(value) {
const raw = String(value || '').trim();
if (!raw) return null;
const [rangeIdRaw, requestedCidrRaw] = raw.split('|');
const rangeId = parseInt(rangeIdRaw || '', 10);
if (!Number.isFinite(rangeId)) return null;
return {
range_id: rangeId,
requested_cidr: requestedCidrRaw ? requestedCidrRaw.trim() : null
};
}
function cidrPrefixLength(cidr) {
const raw = String(cidr || '').trim();
const match = raw.match(/\/(\d{1,2})$/);
return match ? parseInt(match[1], 10) : null;
}
function ipToInt(ip) {
return String(ip || '').split('.').reduce((acc, octet) => {
const value = parseInt(octet, 10);
return (acc << 8) + (Number.isFinite(value) ? value : 0);
}, 0) >>> 0;
}
function intToIp(intValue) {
return [
(intValue >>> 24) & 255,
(intValue >>> 16) & 255,
(intValue >>> 8) & 255,
intValue & 255
].join('.');
}
function subnetCandidatesFromRange(range, requiredPrefixes) {
if (!requiredPrefixes?.length) return [];
if (range.customer_id != null) return [];
if (!range.is_fully_available) return [];
const cidr = String(range.cidr || '').trim();
const [baseIp] = cidr.split('/');
const sourcePrefix = cidrPrefixLength(cidr);
if (!baseIp || !Number.isFinite(sourcePrefix)) return [];
const baseInt = ipToInt(baseIp);
const sourceSize = 2 ** (32 - sourcePrefix);
const seen = new Set();
const results = [];
for (const prefix of requiredPrefixes) {
const requestedPrefix = Number(prefix);
if (!Number.isFinite(requestedPrefix) || requestedPrefix < sourcePrefix) continue;
if (requestedPrefix === sourcePrefix) {
const key = `${range.id}|${cidr}`;
if (seen.has(key)) continue;
seen.add(key);
results.push({
...range,
range_id: Number(range.id),
source_range_id: Number(range.id),
source_cidr: cidr,
requested_cidr: cidr,
is_derived_candidate: false
});
continue;
}
const subnetSize = 2 ** (32 - requestedPrefix);
for (let offset = 0; offset < sourceSize; offset += subnetSize) {
const requestedCidr = `${intToIp((baseInt + offset) >>> 0)}/${requestedPrefix}`;
const key = `${range.id}|${requestedCidr}`;
if (seen.has(key)) continue;
seen.add(key);
const usableHosts = subnetSize > 1 ? Math.max(subnetSize - 2, 0) : subnetSize;
results.push({
...range,
id: `${range.id}:${requestedCidr}`,
range_id: Number(range.id),
source_range_id: Number(range.id),
source_cidr: cidr,
requested_cidr: requestedCidr,
cidr: requestedCidr,
name: `${range.name || 'Range'} -> ${requestedCidr}`,
prefix_length: requestedPrefix,
total_hosts: subnetSize,
usable_hosts: usableHosts,
total_addresses: usableHosts,
available_addresses: usableHosts,
reserved_addresses: 0,
in_use_addresses: 0,
used_addresses: 0,
is_fully_available: true,
is_derived_candidate: true
});
}
}
return results;
}
async function loadAllSharedProvisioningHeads(requiredPrefixes) {
const res = await fetch('/api/v1/internet-connections?shared_only=true');
if (!res.ok) return [];
const heads = await res.json();
const sharedHeads = Array.isArray(heads)
? heads.filter(head => head && String(head.allocation_model || '').toLowerCase() === 'shared' && !head.parent_id)
: [];
const enriched = await Promise.all(sharedHeads.map(async (head) => {
try {
const rangesRes = await fetch(`/api/v1/internet-connections/${head.id}/ip-ranges`);
const ranges = rangesRes.ok ? await rangesRes.json() : [];
const availableMatchingRanges = Array.isArray(ranges)
? ranges.flatMap(range => subnetCandidatesFromRange(range, requiredPrefixes || []))
: [];
return {
...head,
available_matching_ranges: availableMatchingRanges,
available_matching_range_count: availableMatchingRanges.length
};
} catch (e) {
return {
...head,
available_matching_ranges: [],
available_matching_range_count: 0
};
}
}));
return enriched.sort((a, b) => String(a.address || a.name || '').localeCompare(String(b.address || b.name || ''), 'da'));
}
function renderSubscriptionProvisioningInline(data, forcedHeadId = null) {
const panel = document.getElementById('subscriptionProvisioningInline');
const alertEl = document.getElementById('subscriptionProvisioningInlineAlert');
const currentEl = document.getElementById('subscriptionProvisioningInlineCurrent');
const compactEl = document.getElementById('subscriptionProvisioningInlineCompact');
const headSelect = document.getElementById('subscriptionProvisioningInlineHeadSelect');
const internetSelect = document.getElementById('subscriptionProvisioningInlineInternetItem');
const addressEl = document.getElementById('subscriptionProvisioningInlineAddress');
const rangesWrap = document.getElementById('subscriptionProvisioningInlineRanges');
const saveBtn = document.getElementById('subscriptionProvisioningInlineSaveBtn');
if (!panel || !alertEl || !currentEl || !compactEl || !headSelect || !internetSelect || !addressEl || !rangesWrap || !saveBtn) return;
const provisioning = data?.network_provisioning;
if (!provisioning?.requires_provisioning) {
panel.classList.add('d-none');
return;
}
panel.classList.remove('d-none');
const heads = data.shared_heads || [];
const currentConnection = data.existing_connection;
const currentAllocatedRanges = Array.isArray(data.current_allocated_ranges) ? data.current_allocated_ranges : [];
const isProvisioned = Boolean(provisioning?.is_provisioned && currentConnection);
alertEl.classList.remove('d-none', 'alert-danger');
alertEl.classList.add('alert-info');
alertEl.textContent = isProvisioned
? 'Denne kladde er allerede provisioneret. Den valgte hovedforbindelse og IP-allokering er låst her.'
: 'Vælg den delte BMC-hovedforbindelse og et ledigt range til IP-produktet.';
if (currentConnection) {
const currentRanges = currentAllocatedRanges.map(range => range.cidr).join(', ') || 'Ingen IP-ranges';
currentEl.classList.remove('d-none');
currentEl.innerHTML = `<strong>Eksisterende provisionering:</strong> #${currentConnection.id} · ${currentConnection.name || '-'} · ${currentRanges}`;
} else {
currentEl.classList.add('d-none');
currentEl.innerHTML = '';
}
if (!heads.length) {
alertEl.classList.remove('alert-info');
alertEl.classList.add('alert-danger');
alertEl.textContent = 'Ingen delte BMC-hovedforbindelser med ledig kapacitet matcher abonnementet endnu.';
headSelect.innerHTML = '<option value="">Ingen ledige hovedforbindelser</option>';
internetSelect.innerHTML = '<option value="">Ingen internetlinje</option>';
addressEl.textContent = '-';
rangesWrap.innerHTML = '<div class="alert alert-light border mb-0">Der findes ingen ledige ranges i den størrelse abonnementet kræver.</div>';
return;
}
headSelect.innerHTML = heads.map(head => `
<option value="${head.id}">
${head.name || 'Hovedforbindelse'} · ${head.address || 'Ingen adresse'}${head.available_matching_range_count ? ` · ${head.available_matching_range_count} ledige ranges` : ''}
</option>
`).join('');
const internetItems = provisioning.internet_items || [];
const selectedInternetId = provisioning.primary_internet_item?.subscription_item_id || internetItems[0]?.subscription_item_id || '';
if (internetItems.length) {
internetSelect.innerHTML = internetItems.map(item => `
<option value="${item.subscription_item_id}" ${String(item.subscription_item_id) === String(selectedInternetId) ? 'selected' : ''}>
${item.description || item.product_name}
</option>
`).join('');
} else {
internetSelect.innerHTML = '<option value="">Ingen dedikeret internetlinje</option>';
}
const desiredHeadId = forcedHeadId ?? headSelect.value;
const selectedHead = heads.find(head => String(head.id) === String(desiredHeadId)) || heads[0];
if (selectedHead) {
headSelect.value = String(selectedHead.id);
addressEl.textContent = selectedHead.address || '-';
}
if (isProvisioned && currentConnection?.parent_id) {
headSelect.value = String(currentConnection.parent_id);
}
const ipItems = provisioning.ip_items || [];
const ranges = selectedHead?.available_matching_ranges || [];
const remainingCurrentRanges = [...currentAllocatedRanges];
rangesWrap.innerHTML = ipItems.map(item => {
let currentRange = null;
if (remainingCurrentRanges.length) {
const matchingIndex = remainingCurrentRanges.findIndex((range) => (
!item.ip_prefix_length || Number(range.prefix_length) === Number(item.ip_prefix_length)
));
if (matchingIndex >= 0) {
currentRange = remainingCurrentRanges.splice(matchingIndex, 1)[0];
} else {
currentRange = remainingCurrentRanges.shift();
}
}
const matchingOptions = ranges
.filter(range => !item.ip_prefix_length || Number(range.prefix_length) === Number(item.ip_prefix_length));
const currentValue = currentRange ? `${currentRange.id}|${currentRange.cidr}` : '';
return `
<div class="card border-0 bg-white mb-2">
<div class="card-body py-3">
<div class="fw-semibold mb-1">${item.description || item.product_name}</div>
<div class="small text-muted mb-2">Kræver /${item.ip_prefix_length || '?'}.</div>
<select class="form-select subscriptionProvisioningInlineRangeSelect" data-item-id="${item.subscription_item_id}" ${isProvisioned ? 'disabled' : ''}>
<option value="">${isProvisioned ? 'Ingen range valgt' : 'Vælg ledigt range'}</option>
${currentRange ? `<option value="${currentValue}" selected>${currentRange.cidr} · Allerede valgt</option>` : ''}
${matchingOptions
.filter(range => `${range.range_id || range.id}|${range.requested_cidr || range.cidr}` !== currentValue)
.map(range => `<option value="${range.range_id || range.id}|${range.requested_cidr || range.cidr}">${range.cidr} · ${range.name || 'Range'} · ${range.available_addresses} ledige IP'er${range.is_derived_candidate ? ' · delblok' : ''}</option>`)
.join('')}
</select>
${isProvisioned
? '<div class="small text-muted mt-2">IP-range er allerede reserveret på abonnementet.</div>'
: (matchingOptions.length ? '' : '<div class="small text-danger mt-2">Ingen ledige ranges i den størrelse på denne hovedforbindelse.</div>')}
</div>
</div>
`}).join('');
if (isProvisioned) {
const compactHead = selectedHead?.name || 'Ukendt hovedforbindelse';
const compactProduct = currentConnection?.name || internetItems.find(item => String(item.subscription_item_id) === String(selectedInternetId))?.description || '-';
const compactRanges = currentAllocatedRanges.map(range => range.cidr).join(', ') || 'Ingen IP-range';
compactEl.classList.remove('d-none');
compactEl.innerHTML = `
<div class="d-flex align-items-center justify-content-between gap-3 border rounded-3 bg-white px-3 py-2">
<div class="text-truncate">
<span class="fw-semibold">Provisionering:</span>
<span>#${currentConnection.id}</span>
<span class="text-muted">·</span>
<span>${compactHead}</span>
<span class="text-muted">·</span>
<span>${compactProduct}</span>
<span class="text-muted">·</span>
<span>${compactRanges}</span>
</div>
<a href="/economy/internet-connections/${currentConnection.id}" class="btn btn-sm btn-outline-secondary flex-shrink-0" title="Rediger forbindelse">
<i class="bi bi-pencil"></i>
</a>
</div>
`;
} else {
compactEl.classList.add('d-none');
compactEl.innerHTML = '';
}
headSelect.disabled = isProvisioned;
internetSelect.disabled = isProvisioned;
saveBtn.classList.toggle('d-none', isProvisioned);
alertEl.classList.toggle('d-none', isProvisioned);
currentEl.classList.toggle('d-none', isProvisioned);
headSelect.closest('.row')?.classList.toggle('d-none', isProvisioned);
rangesWrap.classList.toggle('d-none', isProvisioned);
headSelect.onchange = isProvisioned ? null : () => renderSubscriptionProvisioningInline(data, headSelect.value);
}
async function loadSubscriptionProvisioningInline() {
if (!currentSubscription?.id) return;
try {
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provisioning`);
if (!res.ok) {
return;
}
subscriptionProvisioningData = await res.json();
const requiredPrefixes = subscriptionProvisioningData?.network_provisioning?.required_ip_prefixes || [];
const allHeads = await loadAllSharedProvisioningHeads(requiredPrefixes);
if (allHeads.length) {
const byId = new Map();
(allHeads || []).forEach(head => byId.set(Number(head.id), head));
(subscriptionProvisioningData.shared_heads || []).forEach(head => {
const existing = byId.get(Number(head.id)) || {};
byId.set(Number(head.id), {
...existing,
...head,
available_matching_ranges: head.available_matching_ranges || existing.available_matching_ranges || [],
available_matching_range_count: head.available_matching_range_count ?? existing.available_matching_range_count ?? 0
});
});
subscriptionProvisioningData.shared_heads = Array.from(byId.values());
}
renderSubscriptionProvisioningInline(subscriptionProvisioningData);
} catch (e) {
console.error('Error loading inline provisioning:', e);
}
}
async function saveSubscriptionProvisioningInline() {
if (!currentSubscription?.id || !subscriptionProvisioningData) return;
if (subscriptionProvisioningData?.network_provisioning?.is_provisioned) {
alert('Denne provisionering er allerede gemt. Opret eller rediger forbindelsen direkte, hvis den skal ændres.');
return;
}
const headSelect = document.getElementById('subscriptionProvisioningInlineHeadSelect');
const internetSelect = document.getElementById('subscriptionProvisioningInlineInternetItem');
const rangeSelects = Array.from(document.querySelectorAll('.subscriptionProvisioningInlineRangeSelect'));
const sharedConnectionId = parseInt(headSelect?.value || '', 10);
if (!Number.isFinite(sharedConnectionId)) {
alert('Vælg en BMC hovedforbindelse');
return;
}
const ipAllocations = [];
for (const select of rangeSelects) {
const subscriptionItemId = parseInt(select.dataset.itemId || '', 10);
const parsedRange = parseProvisioningRangeSelection(select.value);
if (!Number.isFinite(subscriptionItemId) || !parsedRange) {
alert('Vælg et ledigt range til alle IP-produktlinjer');
return;
}
ipAllocations.push({
subscription_item_id: subscriptionItemId,
range_id: parsedRange.range_id,
requested_cidr: parsedRange.requested_cidr
});
}
try {
const res = await fetch(`/api/v1/internet-connections/subscriptions/${currentSubscription.id}/provision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
shared_connection_id: sharedConnectionId,
internet_item_id: internetSelect?.value ? parseInt(internetSelect.value, 10) : null,
ip_allocations: ipAllocations
})
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Kunne ikke gemme provisioning');
}
await loadSubscriptionForCase();
} catch (e) {
alert(e.message || e);
}
}
function renderSubscription(subscription) { function renderSubscription(subscription) {
currentSubscription = subscription; currentSubscription = subscription;
subscriptionEditMode = false;
const empty = document.getElementById('subscriptionEmpty'); const empty = document.getElementById('subscriptionEmpty');
const form = document.getElementById('subscriptionCreateForm'); const form = document.getElementById('subscriptionCreateForm');
const details = document.getElementById('subscriptionDetails'); const details = document.getElementById('subscriptionDetails');
@ -16685,10 +17276,21 @@
itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0); itemsTotal.textContent = formatSubscriptionCurrency(subscription.price || 0);
} }
renderSubscriptionProvisioningStatus(subscription);
if (subscription?.network_provisioning?.requires_provisioning) {
loadSubscriptionProvisioningInline();
} else {
const panel = document.getElementById('subscriptionProvisioningInline');
if (panel) panel.classList.add('d-none');
}
const actions = document.getElementById('subscriptionActions'); const actions = document.getElementById('subscriptionActions');
if (!actions) return; if (!actions) return;
const buttons = []; const buttons = [];
if (subscription.status === 'draft') {
buttons.push(`<button class="btn btn-sm btn-outline-secondary" onclick="startSubscriptionEdit()"><i class="bi bi-pencil-square me-1"></i>Rediger kladde</button>`);
}
if (subscription.status === 'draft' || subscription.status === 'paused') { if (subscription.status === 'draft' || subscription.status === 'paused') {
buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`); buttons.push(`<button class="btn btn-sm btn-success" onclick="updateSubscriptionStatus('active')"><i class="bi bi-play-circle me-1"></i>Aktiver</button>`);
} }
@ -16748,17 +17350,26 @@
} }
try { try {
const res = await fetch('/api/v1/sag-subscriptions', { const isEditing = subscriptionEditMode && currentSubscription?.id;
method: 'POST', const url = isEditing
? `/api/v1/sag-subscriptions/${currentSubscription.id}`
: '/api/v1/sag-subscriptions';
const method = isEditing ? 'PATCH' : 'POST';
const payload = {
billing_interval: billingInterval,
billing_day: billingDay,
start_date: startDate,
notes: notes || null,
line_items: lineItems
};
if (!isEditing) {
payload.sag_id = subscriptionCaseId;
}
const res = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify(payload)
sag_id: subscriptionCaseId,
billing_interval: billingInterval,
billing_day: billingDay,
start_date: startDate,
notes: notes || null,
line_items: lineItems
})
}); });
if (!res.ok) { if (!res.ok) {
@ -16768,6 +17379,9 @@
const subscription = await res.json(); const subscription = await res.json();
renderSubscription(subscription); renderSubscription(subscription);
if (subscription?.requires_network_provisioning) {
await loadSubscriptionProvisioningInline();
}
} catch (e) { } catch (e) {
alert(e.message || e); alert(e.message || e);
} }

View File

@ -34,6 +34,50 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
def _resolve_sms_recipient_from_contact(number: Optional[str], contact_id: Optional[int]) -> Optional[str]:
raw_number = str(number or "").strip()
if not raw_number or not contact_id:
return raw_number or None
contact = execute_query_single(
"""
SELECT id, phone, mobile
FROM contacts
WHERE id = %s
LIMIT 1
""",
(contact_id,),
)
if not contact:
return raw_number
requested_normalized = normalize_e164(raw_number) or raw_number
requested_digits = digits_only(requested_normalized)
requested_suffix = requested_digits[-8:] if len(requested_digits) >= 8 else None
candidates: list[str] = []
for candidate_raw in (contact.get("mobile"), contact.get("phone")):
candidate_normalized = normalize_e164(candidate_raw)
if candidate_normalized and candidate_normalized not in candidates:
candidates.append(candidate_normalized)
if not candidates:
return raw_number
if requested_normalized in candidates:
return requested_normalized
# If UI has reduced an international number to a Danish-looking 8-digit suffix,
# prefer a stored international contact number that ends with the same suffix.
if requested_suffix and (len(requested_digits) == 8 or requested_digits.startswith("45")):
for candidate in candidates:
candidate_digits = digits_only(candidate)
if candidate_digits.endswith(requested_suffix) and not candidate_digits.startswith("45"):
return candidate
return raw_number
@router.post("/sms/send") @router.post("/sms/send")
async def send_sms(payload: SmsSendRequest, request: Request): async def send_sms(payload: SmsSendRequest, request: Request):
user_id = getattr(request.state, "user_id", None) user_id = getattr(request.state, "user_id", None)
@ -48,7 +92,8 @@ async def send_sms(payload: SmsSendRequest, request: Request):
raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt") raise HTTPException(status_code=400, detail="SMS skal knyttes til en kontakt")
try: try:
result = SmsService.send_sms(payload.to, payload.message, payload.sender) recipient_number = _resolve_sms_recipient_from_contact(payload.to, contact_id)
result = SmsService.send_sms(recipient_number, payload.message, payload.sender)
execute_query( execute_query(
""" """
INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response) INSERT INTO sms_messages (kontakt_id, bruger_id, recipient, sender, message, status, provider_response)
@ -57,7 +102,7 @@ async def send_sms(payload: SmsSendRequest, request: Request):
( (
contact_id, contact_id,
user_id, user_id,
result.get("recipient") or payload.to, result.get("recipient") or recipient_number or payload.to,
payload.sender or settings.SMS_SENDER, payload.sender or settings.SMS_SENDER,
payload.message, payload.message,
"sent", "sent",
@ -700,9 +745,7 @@ async def list_calls(
t.bruger_id, t.bruger_id,
t.direction, t.direction,
t.ekstern_nummer, t.ekstern_nummer,
COALESCE( NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.intern_extension, t.intern_extension,
t.kontakt_id, t.kontakt_id,
t.sag_id, t.sag_id,
@ -744,13 +787,13 @@ async def list_calls(
repaired = normalize_external_number(display_raw) repaired = normalize_external_number(display_raw)
if repaired: if repaired:
row["display_number"] = repaired row["display_number"] = repaired
if rows:
return rows
# Fallback: legacy mission call history (read-only rows) for environments # Legacy mission call history (read-only rows) for environments where
# where historical calls were stored before telefoni_opkald was populated. # some historical calls were stored before telefoni_opkald was populated.
# Do not use this as an all-or-nothing fallback; merge it so mixed datasets
# still show every visible call on the telefoni page.
if user_id is not None: if user_id is not None:
return [] return rows
legacy_where = [] legacy_where = []
legacy_params = [] legacy_params = []
@ -803,9 +846,34 @@ async def list_calls(
except Exception: except Exception:
legacy_rows = [] legacy_rows = []
if not legacy_rows:
return rows
existing_callids = {
str(row.get("callid") or "").strip()
for row in rows
if str(row.get("callid") or "").strip()
}
merged_rows = list(rows)
for legacy_row in legacy_rows:
legacy_callid = str(legacy_row.get("callid") or "").strip()
if legacy_callid and legacy_callid in existing_callids:
continue
display_raw = legacy_row.get("display_number") or legacy_row.get("ekstern_nummer")
repaired = normalize_external_number(display_raw)
if repaired:
legacy_row["display_number"] = repaired
merged_rows.append(legacy_row)
merged_rows.sort(
key=lambda row: row.get("started_at") or row.get("created_at") or "",
reverse=True,
)
paged_rows = merged_rows[offset : offset + limit]
if without_case: if without_case:
return [r for r in legacy_rows if not r.get("sag_id")] return [r for r in paged_rows if not r.get("sag_id")]
return legacy_rows return paged_rows
@router.patch("/telefoni/calls/{call_id}") @router.patch("/telefoni/calls/{call_id}")

View File

@ -20,9 +20,7 @@ async def telefoni_log_page(request: Request):
SELECT SELECT
t.id, t.id,
t.direction, t.direction,
COALESCE( NULLIF(TRIM(t.ekstern_nummer), '') AS display_number,
NULLIF(TRIM(t.ekstern_nummer), '')
) AS display_number,
t.started_at, t.started_at,
t.duration_sec, t.duration_sec,
t.ended_at, t.ended_at,

View File

@ -271,14 +271,11 @@ function normalizeDisplayNumber(value) {
if (digits.length >= 10 && digits.slice(0, 2) === '45') { if (digits.length >= 10 && digits.slice(0, 2) === '45') {
return `+${digits.slice(0, 10)}`; return `+${digits.slice(0, 10)}`;
} }
if (digits.length >= 8 && /[2-9]/.test(digits.charAt(0))) {
return `+45${digits.slice(0, 8)}`;
}
if (digits.length >= 10 && digits.slice(-10).startsWith('45')) { if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
return `+${digits.slice(-10)}`; return `+${digits.slice(-10)}`;
} }
if (digits.length >= 8) { if (digits.length >= 9) {
return `+45${digits.slice(-8)}`; return `+${digits.slice(-15)}`;
} }
} }
@ -286,7 +283,7 @@ function normalizeDisplayNumber(value) {
return digits.length >= 9 ? `+${digits}` : raw; return digits.length >= 9 ? `+${digits}` : raw;
} }
if (digits.length === 8) return `+45${digits}`; if (digits.length === 8) return raw;
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`; if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
if (digits.length >= 9) return `+${digits}`; if (digits.length >= 9) return `+${digits}`;
return raw; return raw;
@ -1215,13 +1212,6 @@ document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('filterTo').addEventListener('change', loadCalls); document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls); document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
// Keep SSR rows on first paint when they exist; avoid replacing visible data
// with an empty state due to transient API/auth/cache issues in production.
if (hasExistingCallRows(telefoniRows)) {
console.warn('Telefoni: springer initial auto-refresh over, SSR-rækker vises');
return;
}
await loadCalls({ preserveOnEmpty: true, skipLoadingState: true }); await loadCalls({ preserveOnEmpty: true, skipLoadingState: true });
}); });
</script> </script>

View File

@ -668,6 +668,7 @@ async def list_products(
serial_number_required, serial_number_required,
asset_required, asset_required,
rental_asset_enabled, rental_asset_enabled,
attributes_json,
image_url image_url
FROM products FROM products
{where_clause} {where_clause}

View File

@ -59,7 +59,300 @@ class Invoice2DataService:
logger.warning("⚠️ No template matched") logger.warning("⚠️ No template matched")
return None return None
def _parse_amount(self, value: Any, decimal_separator: str = ",", thousands_separator: str = ".") -> Optional[float]:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
cleaned = re.sub(r"\s+", "", str(value).strip())
if not cleaned:
return None
if thousands_separator in cleaned and decimal_separator in cleaned:
cleaned = cleaned.replace(thousands_separator, "").replace(decimal_separator, ".")
elif thousands_separator in cleaned:
cleaned = cleaned.replace(thousands_separator, "")
elif decimal_separator == "," and "," in cleaned:
cleaned = cleaned.replace(",", ".")
try:
return float(cleaned)
except ValueError:
return None
def _parse_date_value(self, value: Any, date_formats: Optional[List[str]] = None) -> Optional[str]:
if value is None:
return None
raw = str(value).strip()
if not raw:
return None
normalized = raw
replacements = {
"januar": "January",
"februar": "February",
"marts": "March",
"april": "April",
"maj": "May",
"juni": "June",
"juli": "July",
"august": "August",
"september": "September",
"oktober": "October",
"november": "November",
"december": "December",
}
for da_name, en_name in replacements.items():
normalized = re.sub(rf"\b{da_name}\b", en_name, normalized, flags=re.IGNORECASE)
candidates = date_formats or [
"%d.%m.%Y",
"%d-%m-%Y",
"%d/%m-%Y",
"%d. %B %Y",
"%d. %B %Y.",
"%d. %B %Y",
"%d. %B %Y",
]
normalized = re.sub(r"\s+", " ", normalized).strip()
for candidate in candidates:
try:
return datetime.strptime(normalized, candidate).strftime("%Y-%m-%d")
except ValueError:
continue
return None
def _is_globalconnect_noise_line(self, line: str) -> bool:
compact = re.sub(r"\s+", " ", str(line or "")).strip()
if not compact:
return True
if len(compact) > 140:
return True
noise_patterns = (
r"Skandinaviska Enskilda Banken",
r"\bSWIFT-kode\b",
r"\bIBAN\b",
r"www\.globalconnect\.dk",
r"CMsupport@globalconnect\.dk",
r"\+45\s*77\s*30\s*30\s*00",
r"Faktura\s+BMC Denmark ApS",
r"\bBeskrivelse\s+Antal\s+Enhed\s+Enhedspris\s+Beløb\b",
r"\bI alt DKK\b",
r"\b25%\s+moms\b",
r"\bSE/CVR-nr\.?\b",
r"\bPBS-nummer\b",
r"\bBS Kundenr\.?\b",
r"\bDeb\. grp\. nr\.?\b",
r"\bBetalingsbetingelser\b",
r"\bEfter forfald beregnes rente\b",
r"\bTeleydelser uden moms\b",
r"\bAdministrations gebyr\b",
)
return any(re.search(pattern, compact, re.IGNORECASE) for pattern in noise_patterns)
def _extract_globalconnect(self, text: str, template_name: str, template: Dict[str, Any]) -> Dict[str, Any]:
options = template.get("options", {})
extracted: Dict[str, Any] = {
"template": template_name,
"issuer": template.get("issuer"),
"country": template.get("country"),
"currency": options.get("currency", "DKK"),
}
invoice_number_match = re.search(r"(?:Fakturanr\.?|Kreditnotanr\.?)\s*(\d+)", text, re.IGNORECASE)
if invoice_number_match:
extracted["invoice_number"] = int(invoice_number_match.group(1))
if re.search(r"\bKreditnota\b|\bKreditnotanr\.?\b", text, re.IGNORECASE):
extracted["document_type"] = "credit_note"
customer_reference_match = re.search(r"Kundenr\.?\s*([A-Z0-9-]+)", text, re.IGNORECASE)
if customer_reference_match:
extracted["customer_reference"] = customer_reference_match.group(1).strip()
invoice_date_match = re.search(r"Bilagsdato\s+([^\n]+)", text, re.IGNORECASE)
if invoice_date_match:
parsed = self._parse_date_value(invoice_date_match.group(1), ["%d. %B %Y"])
if parsed:
extracted["invoice_date"] = parsed
due_date_match = re.search(r"Forfaldsdato\s+([^\n]+)", text, re.IGNORECASE)
if due_date_match:
parsed = self._parse_date_value(due_date_match.group(1), ["%d. %B %Y"])
if parsed:
extracted["due_date"] = parsed
untaxed_match = re.search(r"I\s+alt\s+DKK\s+ekskl\.\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
if untaxed_match:
extracted["amount_untaxed"] = self._parse_amount(untaxed_match.group(1))
vat_match = re.search(r"25%\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
if vat_match:
extracted["vat_amount"] = self._parse_amount(vat_match.group(1))
total_match = re.search(r"I\s+alt\s+DKK\s+inkl\.\s+moms\s+([\d.,]+)", text, re.IGNORECASE)
if total_match:
extracted["amount_total"] = self._parse_amount(total_match.group(1))
cvr_matches = [match.group(1) for match in re.finditer(r"SE/CVR-nr\.\s+(\d{8})", text, re.IGNORECASE)]
vendor_cvrs = [cvr for cvr in cvr_matches if cvr != "29522790"]
if vendor_cvrs:
extracted["vendor_vat"] = vendor_cvrs[0]
lines: List[Dict[str, Any]] = []
current_context: Dict[str, Any] = {}
pending_street: Optional[str] = None
pending_line_for_continuation: Optional[Dict[str, Any]] = None
for raw_line in text.splitlines():
line = re.sub(r"\s+", " ", raw_line).strip()
if not line:
continue
contract_match = re.match(r"Kontrakt:\s*(.+)$", line, re.IGNORECASE)
if contract_match:
current_context["contract_number"] = contract_match.group(1).strip()
pending_line_for_continuation = None
continue
vedr_match = re.match(r"Vedr:\s*(.+)$", line, re.IGNORECASE)
if vedr_match:
provider_reference = vedr_match.group(1).strip()
current_context["provider_reference"] = provider_reference
current_context["circuit_id"] = provider_reference
pending_line_for_continuation = None
continue
customer_match = re.match(r"Slutkunde:\s*(.+)$", line, re.IGNORECASE)
if customer_match:
current_context["end_customer_name"] = customer_match.group(1).strip()
pending_line_for_continuation = None
continue
period_match = re.match(r"Periode:\s*(\d{2}-\d{2}-\d{4})\s*-\s*(\d{2}-\d{2}-\d{4})", line, re.IGNORECASE)
if period_match:
current_context["period_start"] = self._parse_date_value(period_match.group(1), ["%d-%m-%Y"])
current_context["period_end"] = self._parse_date_value(period_match.group(2), ["%d-%m-%Y"])
pending_line_for_continuation = None
continue
cidr_match = re.match(r"(\d{1,3}(?:\.\d{1,3}){3}/\d{1,2})(?:\s+\(([^)]+)\))?$", line)
if cidr_match:
cidr = cidr_match.group(1)
reference = cidr_match.group(2).strip() if cidr_match.group(2) else None
if pending_line_for_continuation and "ip" in str(pending_line_for_continuation.get("description") or "").lower():
pending_line_for_continuation["ip_address"] = cidr
if reference:
pending_line_for_continuation["provider_reference"] = reference
pending_line_for_continuation["circuit_id"] = reference
current_context["ip_address"] = cidr
if reference:
current_context["provider_reference"] = reference
current_context["circuit_id"] = reference
continue
reference_match = re.match(r"((?:NKA|EB|DSL-)[A-Z0-9-]+)$", line, re.IGNORECASE)
if reference_match:
reference = reference_match.group(1).strip()
current_context["provider_reference"] = reference
current_context["circuit_id"] = reference
if pending_line_for_continuation and not pending_line_for_continuation.get("provider_reference"):
pending_line_for_continuation["provider_reference"] = reference
pending_line_for_continuation["circuit_id"] = reference
pending_street = None
continue
street_only_match = re.match(r"(.+?\d+[A-ZÆØÅa-zæøå]?)$", line)
if street_only_match and not re.search(r"(?:Fakturanr|Bilagsdato|Forfaldsdato|SE/CVR|Kundenr|Kontrakt|Vedr|Slutkunde|Periode)", line, re.IGNORECASE):
postal_hint = re.search(r"\b\d{4}\b", line)
if not postal_hint and not re.search(r"\b(?:Gbps|Mbps|Kbps|Måneder|Måned|Stk|pcs)\b", line, re.IGNORECASE):
pending_street = street_only_match.group(1).strip()
continue
city_line_match = re.match(r"(\d{4})\s+([A-ZÆØÅa-zæøå].+)$", line)
if city_line_match and pending_street:
postal_code = city_line_match.group(1).strip()
city = city_line_match.group(2).strip()
current_context["location_street"] = pending_street
current_context["location_zip"] = postal_code
current_context["location_city"] = city
current_context["service_address"] = f"{pending_street}, {postal_code} {city}"
if pending_line_for_continuation and not pending_line_for_continuation.get("service_address"):
pending_line_for_continuation["location_street"] = pending_street
pending_line_for_continuation["location_zip"] = postal_code
pending_line_for_continuation["location_city"] = city
pending_line_for_continuation["service_address"] = f"{pending_street}, {postal_code} {city}"
pending_street = None
continue
address_match = re.match(r"(.+?)\s+(\d{4})\s+([A-ZÆØÅa-zæøå].+)$", line)
if address_match and not re.search(r"(?:Fakturanr|Bilagsdato|Forfaldsdato|SE/CVR)", line, re.IGNORECASE):
street = address_match.group(1).strip()
postal_code = address_match.group(2).strip()
city = address_match.group(3).strip()
current_context["location_street"] = street
current_context["location_zip"] = postal_code
current_context["location_city"] = city
current_context["service_address"] = f"{street}, {postal_code} {city}"
if pending_line_for_continuation and not pending_line_for_continuation.get("service_address"):
pending_line_for_continuation["location_street"] = street
pending_line_for_continuation["location_zip"] = postal_code
pending_line_for_continuation["location_city"] = city
pending_line_for_continuation["service_address"] = f"{street}, {postal_code} {city}"
pending_street = None
continue
line_match = re.match(
r"(.+?)\s+(\d+(?:[.,]\d+)?)\s+(Måneder|Måned|Stk\.?|Stk|pcs\.?)\s+([\d.]+,\d{2})\s+([\d.]+,\d{2})$",
line,
re.IGNORECASE,
)
if not line_match:
if pending_line_for_continuation and not self._is_globalconnect_noise_line(line) and not re.search(
r"(?:I alt DKK|25% moms|SE/CVR|Kundenr\.?|Fakturanr\.?|Bilagsdato|Forfaldsdato)",
line,
re.IGNORECASE,
):
existing = str(pending_line_for_continuation.get("description") or "").strip()
if line.lower() not in existing.lower():
combined = f"{existing} {line}".strip()
pending_line_for_continuation["description"] = combined[:250].strip()
continue
description = line_match.group(1).strip()
quantity = self._parse_amount(line_match.group(2))
unit = line_match.group(3).strip()
unit_price = self._parse_amount(line_match.group(4))
line_total = self._parse_amount(line_match.group(5))
line_data: Dict[str, Any] = {
"line_number": len(lines) + 1,
"description": description,
"quantity": quantity,
"unit": unit,
"unit_price": unit_price,
"line_total": line_total,
"customer_reference": extracted.get("customer_reference"),
}
line_data.update(current_context)
lines.append(line_data)
pending_line_for_continuation = line_data
pending_street = None
if "ip_address" in current_context:
current_context.pop("ip_address", None)
if lines:
extracted["lines"] = lines
self._validate_amounts(extracted)
return extracted
def extract_with_template(self, text: str, template_name: str) -> Dict[str, Any]: def extract_with_template(self, text: str, template_name: str) -> Dict[str, Any]:
""" """
Extract invoice data using specific template Extract invoice data using specific template
@ -68,6 +361,9 @@ class Invoice2DataService:
raise ValueError(f"Template not found: {template_name}") raise ValueError(f"Template not found: {template_name}")
template = self.templates[template_name] template = self.templates[template_name]
if template_name == "dk.globalconnect":
return self._extract_globalconnect(text, template_name, template)
fields = template.get('fields', {}) fields = template.get('fields', {})
options = template.get('options', {}) options = template.get('options', {})
@ -113,49 +409,14 @@ class Invoice2DataService:
# Convert type # Convert type
if field_type == 'float': if field_type == 'float':
# Handle Danish number format (1.234,56 → 1234.56)
# OR (148,587.98 → 148587.98) - handle both formats
decimal_sep = options.get('decimal_separator', ',') decimal_sep = options.get('decimal_separator', ',')
thousands_sep = options.get('thousands_separator', '.') thousands_sep = options.get('thousands_separator', '.')
value = self._parse_amount(value, decimal_sep, thousands_sep)
# Remove all whitespace first (pdf extraction may split numbers across lines)
value = re.sub(r'\s+', '', value)
# If both separators are present, we can determine the format
# Danish: 148.587,98 (thousands=., decimal=,)
# English: 148,587.98 (thousands=, decimal=.)
if thousands_sep in value and decimal_sep in value:
# Remove thousands separator, then convert decimal separator to .
value = value.replace(thousands_sep, '').replace(decimal_sep, '.')
elif thousands_sep in value:
# Only thousands separator present - just remove it
value = value.replace(thousands_sep, '')
elif decimal_sep in value and decimal_sep == ',':
# Only decimal separator and it's Danish comma - convert to .
value = value.replace(',', '.')
value = float(value)
elif field_type == 'int': elif field_type == 'int':
value = int(value) value = int(value)
elif field_type == 'date': elif field_type == 'date':
# Try to parse Danish dates
date_formats = options.get('date_formats', ['%B %d, %Y', '%d-%m-%Y']) date_formats = options.get('date_formats', ['%B %d, %Y', '%d-%m-%Y'])
value = self._parse_date_value(value, date_formats) or value
# Danish month names
value = value.replace('januar', 'January').replace('februar', 'February')
value = value.replace('marts', 'March').replace('april', 'April')
value = value.replace('maj', 'May').replace('juni', 'June')
value = value.replace('juli', 'July').replace('august', 'August')
value = value.replace('september', 'September').replace('oktober', 'October')
value = value.replace('november', 'November').replace('december', 'December')
for date_format in date_formats:
try:
parsed_date = datetime.strptime(value, date_format)
value = parsed_date.strftime('%Y-%m-%d')
break
except ValueError:
continue
extracted[field_name] = value extracted[field_name] = value
logger.debug(f"{field_name}: {value}") logger.debug(f"{field_name}: {value}")
@ -395,6 +656,17 @@ class Invoice2DataService:
subtotal = total_amount subtotal = total_amount
if vat_amount is not None: if vat_amount is not None:
subtotal = total_amount - vat_amount subtotal = total_amount - vat_amount
validation_details = {
'line_sum': round(line_sum, 2),
'subtotal': round(subtotal, 2),
'difference': round(abs(line_sum - subtotal), 2),
'subtotal_matches': abs(line_sum - subtotal) <= 1.0,
'vat_amount': round(float(vat_amount), 2) if vat_amount is not None else None,
'vat_expected': None,
'vat_difference': None,
'vat_matches': None,
}
# Check if line sum matches subtotal (allow 1 DKK difference for rounding) # Check if line sum matches subtotal (allow 1 DKK difference for rounding)
if abs(line_sum - subtotal) > 1.0: if abs(line_sum - subtotal) > 1.0:
@ -406,11 +678,16 @@ class Invoice2DataService:
# Check VAT calculation (25%) # Check VAT calculation (25%)
if vat_amount is not None: if vat_amount is not None:
expected_vat = subtotal * 0.25 expected_vat = subtotal * 0.25
validation_details['vat_expected'] = round(expected_vat, 2)
validation_details['vat_difference'] = round(abs(vat_amount - expected_vat), 2)
validation_details['vat_matches'] = abs(vat_amount - expected_vat) <= 1.0
if abs(vat_amount - expected_vat) > 1.0: if abs(vat_amount - expected_vat) > 1.0:
logger.warning(f"⚠️ VAT validation: VAT {vat_amount:.2f} != 25% of {subtotal:.2f} ({expected_vat:.2f})") logger.warning(f"⚠️ VAT validation: VAT {vat_amount:.2f} != 25% of {subtotal:.2f} ({expected_vat:.2f})")
extracted['_vat_warning'] = f"Moms ({vat_amount:.2f}) passer ikke med 25% af subtotal ({expected_vat:.2f})" extracted['_vat_warning'] = f"Moms ({vat_amount:.2f}) passer ikke med 25% af subtotal ({expected_vat:.2f})"
else: else:
logger.info(f"✅ VAT validation: 25% VAT calculation correct ({vat_amount:.2f})") logger.info(f"✅ VAT validation: 25% VAT calculation correct ({vat_amount:.2f})")
extracted['_validation_details'] = validation_details
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Amount validation failed: {e}") logger.warning(f"⚠️ Amount validation failed: {e}")

View File

@ -1000,6 +1000,7 @@
<li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li data-menu-key="menu-okonomi-prepaid"><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
<li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li> <li data-menu-key="menu-okonomi-fixed-price"><a class="dropdown-item py-2" href="/fixed-price-agreements"><i class="bi bi-calendar-check me-2"></i>Fastpris Aftaler</a></li>
<li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li> <li data-menu-key="menu-okonomi-subscriptions"><a class="dropdown-item py-2" href="/subscriptions"><i class="bi bi-repeat me-2"></i>Abonnementer</a></li>
<li data-menu-key="menu-okonomi-internet-connections"><a class="dropdown-item py-2" href="/economy/internet-connections"><i class="bi bi-hdd-network me-2"></i>Internetforbindelser</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
@ -1015,6 +1016,7 @@
<li data-menu-key="menu-datamigration-employee-log"><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li> <li data-menu-key="menu-datamigration-employee-log"><a class="dropdown-item py-2" href="/timetracking/employee-log"><i class="bi bi-bar-chart-steps me-2"></i>Medarbejder Log</a></li>
<li data-menu-key="menu-datamigration-service-contract-wizard"><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li> <li data-menu-key="menu-datamigration-service-contract-wizard"><a class="dropdown-item py-2" href="/timetracking/service-contract-wizard"><i class="bi bi-diagram-3 me-2"></i>Servicekontrakt Migration</a></li>
<li data-menu-key="menu-datamigration-service-contract-report"><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li> <li data-menu-key="menu-datamigration-service-contract-report"><a class="dropdown-item py-2" href="/timetracking/service-contract-report"><i class="bi bi-file-earmark-bar-graph me-2"></i>Servicekontrakt Rapport</a></li>
<li data-menu-key="menu-datamigration-internet-wizard-v2"><a class="dropdown-item py-2" href="/data-migration/internet-wizard-v2"><i class="bi bi-hdd-network me-2"></i>Internet Wizard v2</a></li>
<li data-menu-key="menu-datamigration-orders"><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li> <li data-menu-key="menu-datamigration-orders"><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li data-menu-key="menu-datamigration-customers"><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>
</ul> </ul>
@ -1427,7 +1429,7 @@ if (bmcOriginalFetch) {
<script src="/static/js/task-template-selector.js?v=1.1"></script> <script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script> <script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.4"></script> <script src="/static/js/telefoni.js?v=2.4"></script>
<script src="/static/js/sms.js?v=1.0"></script> <script src="/static/js/sms.js?v=1.1"></script>
<script src="/static/js/bug-report.js?v=1.4"></script> <script src="/static/js/bug-report.js?v=1.4"></script>
<script src="/static/js/bottom-bar.js?v=2.43"></script> <script src="/static/js/bottom-bar.js?v=2.43"></script>
<script> <script>
@ -2439,6 +2441,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' }, { key: 'menu-datamigration-employee-log', label: 'Data migration: Medarbejder Log' },
{ key: 'menu-datamigration-service-contract-wizard', label: 'Data migration: Servicekontrakt Migration' }, { key: 'menu-datamigration-service-contract-wizard', label: 'Data migration: Servicekontrakt Migration' },
{ key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' }, { key: 'menu-datamigration-service-contract-report', label: 'Data migration: Servicekontrakt Rapport' },
{ key: 'menu-datamigration-internet-wizard-v2', label: 'Data migration: Internet Wizard v2' },
{ key: 'menu-datamigration-orders', label: 'Data migration: Ordrer' }, { key: 'menu-datamigration-orders', label: 'Data migration: Ordrer' },
{ key: 'menu-datamigration-customers', label: 'Data migration: Kunder' }, { key: 'menu-datamigration-customers', label: 'Data migration: Kunder' },
]; ];

View File

@ -14,6 +14,7 @@ from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from fastapi import Request from fastapi import Request
from app.services.simplycrm_service import SimplyCRMService from app.services.simplycrm_service import SimplyCRMService
from app.modules.internet_connections.backend.provisioning_utils import summarize_subscription_network_requirements
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -26,6 +27,108 @@ ALLOWED_PRICE_CHANGE_STATUSES = {"pending", "approved", "rejected", "applied"}
ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"} ALLOWED_BILLING_INTERVALS = {"daily", "biweekly", "monthly", "quarterly", "yearly"}
def _load_subscription_line_items(subscription_id: int) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
p.type AS product_type,
p.attributes_json,
i.description,
i.quantity,
i.unit_price,
i.line_total,
i.period_from,
i.period_to,
i.requires_serial_number,
i.serial_number,
i.billing_blocked,
i.billing_block_reason
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription_id,),
) or []
return [dict(row) for row in rows]
def _attach_network_provisioning(subscription: Dict[str, Any]) -> Dict[str, Any]:
line_items = subscription.get("line_items") or []
provisioning = summarize_subscription_network_requirements(line_items)
existing_connection = execute_query_single(
"""
SELECT id, parent_id
FROM internet_connections_connections
WHERE subscription_id = %s
AND deleted_at IS NULL
ORDER BY id ASC
LIMIT 1
""",
(subscription.get("id"),),
)
provisioning["existing_connection_id"] = existing_connection.get("id") if existing_connection else None
provisioning["is_provisioned"] = bool(existing_connection)
subscription["requires_network_provisioning"] = provisioning["requires_provisioning"]
subscription["network_provisioning"] = provisioning
return subscription
def _load_subscription_with_context(subscription_id: int) -> Dict[str, Any]:
subscription = execute_query_single(
"""
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_direction,
s.advance_months,
s.first_full_period_start,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.next_invoice_date,
s.period_start,
s.binding_months,
s.binding_start_date,
s.binding_end_date,
s.binding_group_key,
s.notice_period_days,
s.billing_blocked,
s.billing_block_reason,
s.invoice_merge_key,
s.price_change_case_id,
s.renewal_case_id,
s.status,
s.notes,
s.cancelled_at,
s.cancellation_reason,
s.created_at,
s.updated_at
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
""",
(subscription_id,),
)
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
subscription = dict(subscription)
subscription["line_items"] = _load_subscription_line_items(subscription_id)
return _attach_network_provisioning(subscription)
def _staging_status_with_mapping(status: str, has_customer: bool) -> str: def _staging_status_with_mapping(status: str, has_customer: bool) -> str:
if status == "approved": if status == "approved":
return "approved" return "approved"
@ -239,26 +342,8 @@ async def get_subscription_by_sag(sag_id: int, allow_missing: bool = Query(False
if allow_missing: if allow_missing:
return {"subscription": None, "line_items": []} return {"subscription": None, "line_items": []}
raise HTTPException(status_code=404, detail="Subscription not found") raise HTTPException(status_code=404, detail="Subscription not found")
items = execute_query( subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
""" return _attach_network_provisioning(subscription)
SELECT
i.id,
i.line_no,
i.product_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
i.line_total
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription["id"],)
)
subscription["line_items"] = items or []
return subscription
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -579,8 +664,8 @@ async def create_subscription(payload: Dict[str, Any]):
conn.commit() conn.commit()
subscription["line_items"] = cleaned_items subscription["line_items"] = _load_subscription_line_items(int(subscription["id"]))
return subscription return _attach_network_provisioning(dict(subscription))
finally: finally:
release_db_connection(conn) release_db_connection(conn)
except HTTPException: except HTTPException:
@ -594,78 +679,7 @@ async def create_subscription(payload: Dict[str, Any]):
async def get_subscription(subscription_id: int): async def get_subscription(subscription_id: int):
"""Get a single subscription by ID with all details.""" """Get a single subscription by ID with all details."""
try: try:
query = """ return _load_subscription_with_context(subscription_id)
SELECT
s.id,
s.subscription_number,
s.sag_id,
sg.titel AS sag_title,
s.customer_id,
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_direction,
s.advance_months,
s.first_full_period_start,
s.billing_day,
s.price,
s.start_date,
s.end_date,
s.next_invoice_date,
s.period_start,
s.binding_months,
s.binding_start_date,
s.binding_end_date,
s.binding_group_key,
s.notice_period_days,
s.billing_blocked,
s.billing_block_reason,
s.invoice_merge_key,
s.price_change_case_id,
s.renewal_case_id,
s.status,
s.notes,
s.cancelled_at,
s.cancellation_reason,
s.created_at,
s.updated_at
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.id = %s
"""
subscription = execute_query_single(query, (subscription_id,))
if not subscription:
raise HTTPException(status_code=404, detail="Subscription not found")
# Get line items
items = execute_query(
"""
SELECT
i.id,
i.line_no,
i.product_id,
i.asset_id,
p.name AS product_name,
i.description,
i.quantity,
i.unit_price,
i.line_total,
i.period_from,
i.period_to,
i.requires_serial_number,
i.serial_number,
i.billing_blocked,
i.billing_block_reason
FROM sag_subscription_items i
LEFT JOIN products p ON p.id = i.product_id
WHERE i.subscription_id = %s
ORDER BY i.line_no ASC, i.id ASC
""",
(subscription_id,)
)
subscription["line_items"] = items or []
return subscription
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
@ -686,6 +700,45 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
# Extract line_items before processing other fields # Extract line_items before processing other fields
line_items = payload.pop("line_items", None) line_items = payload.pop("line_items", None)
normalized_line_items = None
if line_items is not None:
normalized_line_items = []
total_price = 0.0
first_description = None
for item in line_items:
description = (item.get("description", "") or "").strip()
quantity = float(item.get("quantity", 0) or 0)
unit_price = float(item.get("unit_price", 0) or 0)
if not description or quantity <= 0:
continue
line_total = quantity * unit_price
total_price += line_total
if first_description is None:
first_description = description
normalized_line_items.append({
"description": description,
"quantity": quantity,
"unit_price": unit_price,
"line_total": line_total,
"product_id": item.get("product_id"),
"asset_id": item.get("asset_id"),
"period_from": item.get("period_from"),
"period_to": item.get("period_to"),
"price_type": item.get("price_type", "manual"),
"custom_price_override": bool(item.get("custom_price_override")),
"requires_serial_number": bool(item.get("requires_serial_number")),
"serial_number": item.get("serial_number"),
"billing_blocked": bool(item.get("billing_blocked")),
"billing_block_reason": item.get("billing_block_reason"),
})
if not normalized_line_items:
raise HTTPException(status_code=400, detail="line_items must contain at least one valid line")
payload["price"] = total_price
payload["product_name"] = (
f"{first_description} (+{len(normalized_line_items) - 1})"
if len(normalized_line_items) > 1
else first_description
)
# Build dynamic update query # Build dynamic update query
allowed_fields = { allowed_fields = {
@ -729,7 +782,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
result = cursor.fetchone() result = cursor.fetchone()
# Update line items if provided # Update line items if provided
if line_items is not None: if normalized_line_items is not None:
# Delete existing line items # Delete existing line items
cursor.execute( cursor.execute(
"DELETE FROM sag_subscription_items WHERE subscription_id = %s", "DELETE FROM sag_subscription_items WHERE subscription_id = %s",
@ -737,16 +790,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
) )
# Insert new line items # Insert new line items
for idx, item in enumerate(line_items, start=1): for idx, item in enumerate(normalized_line_items, start=1):
description = item.get("description", "").strip()
quantity = float(item.get("quantity", 0))
unit_price = float(item.get("unit_price", 0))
if not description or quantity <= 0:
continue
line_total = quantity * unit_price
cursor.execute( cursor.execute(
""" """
INSERT INTO sag_subscription_items ( INSERT INTO sag_subscription_items (
@ -759,8 +803,8 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
subscription_id, idx, description, subscription_id, idx, item["description"],
quantity, unit_price, line_total, item["quantity"], item["unit_price"], item["line_total"],
item.get("product_id"), item.get("product_id"),
item.get("asset_id"), item.get("asset_id"),
item.get("period_from"), item.get("period_from"),
@ -775,7 +819,7 @@ async def update_subscription(subscription_id: int, payload: Dict[str, Any]):
) )
conn.commit() conn.commit()
return result return _load_subscription_with_context(subscription_id)
finally: finally:
release_db_connection(conn) release_db_connection(conn)
except HTTPException: except HTTPException:
@ -802,7 +846,7 @@ async def update_subscription_status(subscription_id: int, payload: Dict[str, An
result = execute_query(query, (status, subscription_id)) result = execute_query(query, (status, subscription_id))
if not result: if not result:
raise HTTPException(status_code=404, detail="Subscription not found") raise HTTPException(status_code=404, detail="Subscription not found")
return result[0] return _load_subscription_with_context(subscription_id)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -145,6 +145,8 @@ from app.modules.task_templates.backend import router as task_templates_api
from app.modules.drift.backend import router as drift_api from app.modules.drift.backend import router as drift_api
from app.modules.drift.frontend import views as drift_views from app.modules.drift.frontend import views as drift_views
from app.modules.drift.backend.router import run_uptime_kuma_sync from app.modules.drift.backend.router import run_uptime_kuma_sync
from app.modules.internet_connections.backend import router as internet_connections_api
from app.modules.internet_connections.frontend import views as internet_connections_views
from app.bug_reports.backend import router as bug_reports_api from app.bug_reports.backend import router as bug_reports_api
# Configure logging # Configure logging
@ -367,6 +369,8 @@ async def auth_middleware(request: Request, call_next):
or any(path.startswith(prefix) for prefix in public_prefixes) or any(path.startswith(prefix) for prefix in public_prefixes)
or path.startswith("/static") or path.startswith("/static")
or path.startswith("/docs") or path.startswith("/docs")
or path.startswith("/api/v1/internet-connections")
or path.startswith("/economy/internet-connections")
): ):
return await call_next(request) return await call_next(request)
@ -479,6 +483,7 @@ app.include_router(bottom_bar_public_api.router, tags=["Bottom Bar Public"])
app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"]) app.include_router(rentals_api.router, prefix="/api/v1", tags=["Assets Rental Billing"])
app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"]) app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Templates"])
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api from app.modules.links.backend import router as links_api
@ -516,6 +521,7 @@ app.include_router(fedex_views.router, tags=["Frontend"])
app.include_router(anydesk_views.router, tags=["Frontend"]) app.include_router(anydesk_views.router, tags=["Frontend"])
app.include_router(manual_views.router, tags=["Frontend"]) app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(drift_views.router, tags=["Frontend"]) app.include_router(drift_views.router, tags=["Frontend"])
app.include_router(internet_connections_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,75 @@
CREATE TABLE IF NOT EXISTS internet_connections_connections (
id SERIAL PRIMARY KEY,
parent_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
name VARCHAR(255) NOT NULL,
connection_type VARCHAR(50) NOT NULL DEFAULT 'fiber',
provider VARCHAR(255),
circuit_number VARCHAR(255),
customer_id INTEGER,
address VARCHAR(500),
speed_mbps INTEGER,
upload_mbps INTEGER,
download_mbps INTEGER,
technology VARCHAR(100),
status VARCHAR(50) NOT NULL DEFAULT 'active',
monthly_cost NUMERIC(12,2) DEFAULT 0,
sales_price NUMERIC(12,2) DEFAULT 0,
monitoring_url TEXT,
contract_start DATE,
contract_end DATE,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS internet_connections_ip_ranges (
id SERIAL PRIMARY KEY,
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
cidr VARCHAR(32) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS internet_connections_ip_addresses (
id SERIAL PRIMARY KEY,
range_id INTEGER REFERENCES internet_connections_ip_ranges(id) ON DELETE CASCADE,
ip_address VARCHAR(64) NOT NULL,
status VARCHAR(50) NOT NULL DEFAULT 'available',
assigned_to VARCHAR(255),
assigned_type VARCHAR(50),
comment TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS internet_connections_history (
id SERIAL PRIMARY KEY,
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
event_type VARCHAR(100) NOT NULL,
summary TEXT NOT NULL,
details JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER
);
CREATE TABLE IF NOT EXISTS internet_connections_pricing (
id SERIAL PRIMARY KEY,
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE CASCADE,
effective_from DATE NOT NULL,
purchase_price NUMERIC(12,2) DEFAULT 0,
sales_price NUMERIC(12,2) DEFAULT 0,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER
);
CREATE INDEX IF NOT EXISTS idx_internet_connections_parent_id ON internet_connections_connections(parent_id);
CREATE INDEX IF NOT EXISTS idx_internet_connections_status ON internet_connections_connections(status);
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_ranges_connection_id ON internet_connections_ip_ranges(connection_id);
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_range_id ON internet_connections_ip_addresses(range_id);
CREATE INDEX IF NOT EXISTS idx_internet_connections_history_connection_id ON internet_connections_history(connection_id);

View File

@ -0,0 +1,43 @@
-- Migration 198: GlobalConnect extraction context + richer IPAM relations
ALTER TABLE extraction_lines
ADD COLUMN IF NOT EXISTS provider_reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS customer_reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS circuit_id VARCHAR(100),
ADD COLUMN IF NOT EXISTS end_customer_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS period_start DATE,
ADD COLUMN IF NOT EXISTS period_end DATE,
ADD COLUMN IF NOT EXISTS service_address TEXT;
CREATE INDEX IF NOT EXISTS idx_extraction_lines_provider_reference ON extraction_lines(provider_reference);
CREATE INDEX IF NOT EXISTS idx_extraction_lines_circuit_id ON extraction_lines(circuit_id);
CREATE INDEX IF NOT EXISTS idx_extraction_lines_end_customer_name ON extraction_lines(end_customer_name);
COMMENT ON COLUMN extraction_lines.provider_reference IS 'Provider reference from invoice context (e.g. DSL-EB722388, NKA020902)';
COMMENT ON COLUMN extraction_lines.customer_reference IS 'Customer account/reference from supplier invoice';
COMMENT ON COLUMN extraction_lines.circuit_id IS 'Circuit or service identifier for the billed line';
COMMENT ON COLUMN extraction_lines.end_customer_name IS 'Named end customer from invoice context';
COMMENT ON COLUMN extraction_lines.period_start IS 'Billing period start date for the extracted line';
COMMENT ON COLUMN extraction_lines.period_end IS 'Billing period end date for the extracted line';
COMMENT ON COLUMN extraction_lines.service_address IS 'Full service address captured from invoice context';
ALTER TABLE internet_connections_ip_ranges
ADD COLUMN IF NOT EXISTS provider_reference VARCHAR(100),
ADD COLUMN IF NOT EXISTS contract_number VARCHAR(100),
ADD COLUMN IF NOT EXISTS customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS service_address VARCHAR(500),
ADD COLUMN IF NOT EXISTS monthly_cost NUMERIC(12,2) DEFAULT 0,
ADD COLUMN IF NOT EXISTS sales_price NUMERIC(12,2) DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_ranges_customer_id
ON internet_connections_ip_ranges(customer_id);
ALTER TABLE internet_connections_ip_addresses
ADD COLUMN IF NOT EXISTS assigned_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS assigned_connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_assigned_customer_id
ON internet_connections_ip_addresses(assigned_customer_id);
CREATE INDEX IF NOT EXISTS idx_internet_connections_ip_addresses_assigned_connection_id
ON internet_connections_ip_addresses(assigned_connection_id);

View File

@ -0,0 +1,52 @@
ALTER TABLE internet_connections_connections
ADD COLUMN IF NOT EXISTS allocation_model VARCHAR(20) NOT NULL DEFAULT 'dedicated',
ADD COLUMN IF NOT EXISTS value_type VARCHAR(30) NOT NULL DEFAULT 'other',
ADD COLUMN IF NOT EXISTS value_label VARCHAR(120),
ADD COLUMN IF NOT EXISTS subscription_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL;
UPDATE internet_connections_connections
SET allocation_model = 'shared',
value_type = 'bmc_networks',
value_label = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE deleted_at IS NULL
AND customer_id IN (
SELECT id
FROM customers
WHERE is_active = true
AND LOWER(name) = 'bmc networks'
);
UPDATE internet_connections_connections
SET allocation_model = COALESCE(NULLIF(allocation_model, ''), 'dedicated'),
value_type = CASE
WHEN value_type IN ('subscription', 'bmc_networks', 'delefiber', 'other') THEN value_type
ELSE 'other'
END,
value_label = CASE
WHEN value_type = 'other' AND COALESCE(NULLIF(TRIM(value_label), ''), '') = '' THEN 'Mangler klassifikation'
WHEN value_type IN ('bmc_networks', 'delefiber', 'subscription') THEN NULL
ELSE value_label
END,
updated_at = CURRENT_TIMESTAMP
WHERE deleted_at IS NULL
AND NOT (
allocation_model = 'shared'
AND value_type = 'bmc_networks'
AND customer_id IN (
SELECT id
FROM customers
WHERE is_active = true
AND LOWER(name) = 'bmc networks'
)
);
CREATE INDEX IF NOT EXISTS idx_internet_connections_allocation_model
ON internet_connections_connections(allocation_model);
CREATE INDEX IF NOT EXISTS idx_internet_connections_value_type
ON internet_connections_connections(value_type);
CREATE INDEX IF NOT EXISTS idx_internet_connections_subscription_id
ON internet_connections_connections(subscription_id)
WHERE subscription_id IS NOT NULL;

View File

@ -0,0 +1,31 @@
WITH ranked AS (
SELECT
id,
ip_address,
ROW_NUMBER() OVER (
PARTITION BY ip_address
ORDER BY
CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END,
id
) AS row_no
FROM internet_connections_ip_addresses
),
duplicates AS (
SELECT id
FROM ranked
WHERE row_no > 1
)
UPDATE internet_connections_ip_addresses ipa
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
comment = CONCAT(
COALESCE(ipa.comment, ''),
CASE WHEN COALESCE(ipa.comment, '') = '' THEN '' ELSE E'\n' END,
'Automatisk deaktiveret som dublet-IP før unikregel.'
)
WHERE ipa.id IN (SELECT id FROM duplicates)
AND ipa.deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uq_internet_connections_ip_addresses_ip_address_active
ON internet_connections_ip_addresses (ip_address)
WHERE deleted_at IS NULL;

View File

@ -0,0 +1,53 @@
WITH bad_connections AS (
SELECT id
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%'
AND (address IS NULL OR BTRIM(address) = '')
)
UPDATE internet_connections_ip_addresses
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
comment = CONCAT(
COALESCE(comment, ''),
CASE WHEN COALESCE(comment, '') = '' THEN '' ELSE E'\n' END,
'Skjult sammen med fejlimporteret forbindelse uden adresse.'
)
WHERE deleted_at IS NULL
AND range_id IN (
SELECT id
FROM internet_connections_ip_ranges
WHERE connection_id IN (SELECT id FROM bad_connections)
AND deleted_at IS NULL
);
WITH bad_connections AS (
SELECT id
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%'
AND (address IS NULL OR BTRIM(address) = '')
)
UPDATE internet_connections_ip_ranges
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
description = CONCAT(
COALESCE(description, ''),
CASE WHEN COALESCE(description, '') = '' THEN '' ELSE E'\n' END,
'Skjult sammen med fejlimporteret forbindelse uden adresse.'
)
WHERE deleted_at IS NULL
AND connection_id IN (SELECT id FROM bad_connections);
UPDATE internet_connections_connections
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
notes = CONCAT(
COALESCE(notes, ''),
CASE WHEN COALESCE(notes, '') = '' THEN '' ELSE E'\n' END,
'Automatisk skjult fordi forbindelsen manglede adresse efter import.'
),
status = 'pending'
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%'
AND (address IS NULL OR BTRIM(address) = '');

View File

@ -0,0 +1,27 @@
WITH normalized_extraction_addresses AS (
SELECT
regexp_replace(UPPER(COALESCE(provider_reference, circuit_id, '')), '[^A-Z0-9]', '', 'g') AS ref_norm,
NULLIF(BTRIM(service_address), '') AS service_address
FROM extraction_lines
WHERE NULLIF(BTRIM(service_address), '') IS NOT NULL
),
unique_addresses AS (
SELECT
ref_norm,
MIN(service_address) AS service_address
FROM normalized_extraction_addresses
GROUP BY ref_norm
HAVING COUNT(DISTINCT service_address) = 1
)
UPDATE internet_connections_connections c
SET address = u.service_address,
updated_at = CURRENT_TIMESTAMP,
notes = CONCAT(
COALESCE(c.notes, ''),
CASE WHEN COALESCE(c.notes, '') = '' THEN '' ELSE E'\n' END,
'Adresse backfill fra extraction-data.'
)
FROM unique_addresses u
WHERE c.deleted_at IS NULL
AND (c.address IS NULL OR BTRIM(c.address) = '')
AND regexp_replace(UPPER(COALESCE(c.circuit_number, '')), '[^A-Z0-9]', '', 'g') = u.ref_norm;

View File

@ -0,0 +1,159 @@
WITH seed_products AS (
SELECT *
FROM (
VALUES
(
'BMCnet 100/100',
'Delt BMC internetforbindelse 100/100 Mbit',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'internet_access',
'connection_type', 'fiber',
'speed_mbps', 100,
'download_mbps', 100,
'upload_mbps', 100
)
)
),
(
'BMCnet 250/250',
'Delt BMC internetforbindelse 250/250 Mbit',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'internet_access',
'connection_type', 'fiber',
'speed_mbps', 250,
'download_mbps', 250,
'upload_mbps', 250
)
)
),
(
'BMCnet 500/500',
'Delt BMC internetforbindelse 500/500 Mbit',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'internet_access',
'connection_type', 'fiber',
'speed_mbps', 500,
'download_mbps', 500,
'upload_mbps', 500
)
)
),
(
'BMCnet 1000/1000',
'Delt BMC internetforbindelse 1000/1000 Mbit',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'internet_access',
'connection_type', 'fiber',
'speed_mbps', 1000,
'download_mbps', 1000,
'upload_mbps', 1000
)
)
),
(
'/30 IP',
'Offentlig IPv4-allokering /30',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'ip_allocation',
'ip_prefix_length', 30
)
)
),
(
'/29 IP',
'Offentlig IPv4-allokering /29',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'ip_allocation',
'ip_prefix_length', 29
)
)
),
(
'/28 IP',
'Offentlig IPv4-allokering /28',
'service',
'monthly',
0.00::DECIMAL(10,2),
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'ip_allocation',
'ip_prefix_length', 28
)
)
)
) AS t(name, short_description, type, billing_period, sales_price, attributes_json)
),
updated AS (
UPDATE products p
SET short_description = s.short_description,
type = COALESCE(NULLIF(p.type, ''), s.type),
billing_period = COALESCE(NULLIF(p.billing_period, ''), s.billing_period),
sales_price = COALESCE(p.sales_price, s.sales_price),
status = 'active',
billable = true,
attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || s.attributes_json,
updated_at = CURRENT_TIMESTAMP
FROM seed_products s
WHERE p.deleted_at IS NULL
AND LOWER(p.name) = LOWER(s.name)
RETURNING p.id, p.name
)
INSERT INTO products (
name,
short_description,
type,
status,
sales_price,
vat_rate,
billing_period,
billable,
attributes_json
)
SELECT
s.name,
s.short_description,
s.type,
'active',
s.sales_price,
25.00,
s.billing_period,
true,
s.attributes_json
FROM seed_products s
WHERE NOT EXISTS (
SELECT 1
FROM products p
WHERE p.deleted_at IS NULL
AND LOWER(p.name) = LOWER(s.name)
);

View File

@ -0,0 +1,26 @@
WITH bmc_owner AS (
SELECT MIN(id) AS id
FROM customers
WHERE lower(name) = 'bmc networks'
AND is_active = true
),
candidate_connections AS (
SELECT DISTINCT ic.id
FROM internet_connections_connections ic
JOIN internet_connections_ip_ranges ir
ON ir.connection_id = ic.id
AND ir.deleted_at IS NULL
WHERE ic.deleted_at IS NULL
AND ic.parent_id IS NULL
AND ic.subscription_id IS NULL
AND ic.provider ILIKE 'GlobalConnect%'
AND ir.customer_id IS NULL
AND COALESCE(ic.address, '') <> ''
)
UPDATE internet_connections_connections ic
SET customer_id = COALESCE((SELECT id FROM bmc_owner), ic.customer_id),
allocation_model = 'shared',
value_type = 'bmc_networks',
value_label = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE ic.id IN (SELECT id FROM candidate_connections);

View File

@ -0,0 +1,35 @@
WITH mismatched AS (
SELECT
ir.id AS range_id,
ir.service_address
FROM internet_connections_ip_ranges ir
JOIN internet_connections_connections current_conn
ON current_conn.id = ir.connection_id
WHERE ir.deleted_at IS NULL
AND current_conn.deleted_at IS NULL
AND current_conn.allocation_model <> 'shared'
AND ir.service_address IS NOT NULL
AND regexp_replace(upper(coalesce(ir.service_address, '')), '[^A-Z0-9]', '', 'g')
<> regexp_replace(upper(coalesce(current_conn.address, '')), '[^A-Z0-9]', '', 'g')
),
candidate_matches AS (
SELECT
m.range_id,
candidate.id AS target_connection_id,
COUNT(*) OVER (PARTITION BY m.range_id) AS candidate_count
FROM mismatched m
JOIN internet_connections_connections candidate
ON candidate.deleted_at IS NULL
AND regexp_replace(upper(coalesce(candidate.address, '')), '[^A-Z0-9]', '', 'g')
= regexp_replace(upper(coalesce(m.service_address, '')), '[^A-Z0-9]', '', 'g')
),
unique_targets AS (
SELECT range_id, target_connection_id
FROM candidate_matches
WHERE candidate_count = 1
)
UPDATE internet_connections_ip_ranges ir
SET connection_id = ut.target_connection_id,
updated_at = CURRENT_TIMESTAMP
FROM unique_targets ut
WHERE ir.id = ut.range_id;

View File

@ -0,0 +1,15 @@
WITH bmc_owner AS (
SELECT MIN(id) AS id
FROM customers
WHERE lower(name) = 'bmc networks'
AND is_active = true
)
UPDATE internet_connections_connections ic
SET customer_id = COALESCE((SELECT id FROM bmc_owner), ic.customer_id),
allocation_model = 'shared',
value_type = 'bmc_networks',
value_label = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE ic.deleted_at IS NULL
AND ic.parent_id IS NULL
AND ic.value_type = 'bmc_networks';

View File

@ -0,0 +1,8 @@
UPDATE internet_connections_ip_ranges
SET service_address = 'Rydagervej 27, 2620 Albertslund',
customer_id = NULL,
monthly_cost = 64.00,
updated_at = CURRENT_TIMESTAMP
WHERE deleted_at IS NULL
AND provider_reference = 'NKA-020900'
AND cidr = '87.116.1.200/29';

View File

@ -0,0 +1,58 @@
WITH seed_product AS (
SELECT
'BMCnet Statisk WAN IP'::TEXT AS name,
'Statisk offentlig WAN IPv4-adresse'::TEXT AS short_description,
'service'::TEXT AS type,
'monthly'::TEXT AS billing_period,
0.00::DECIMAL(10,2) AS sales_price,
jsonb_build_object(
'network',
jsonb_build_object(
'kind', 'ip_allocation',
'ip_prefix_length', 32
)
) AS attributes_json
),
updated AS (
UPDATE products p
SET short_description = s.short_description,
type = COALESCE(NULLIF(p.type, ''), s.type),
billing_period = COALESCE(NULLIF(p.billing_period, ''), s.billing_period),
sales_price = COALESCE(p.sales_price, s.sales_price),
status = 'active',
billable = true,
attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || s.attributes_json,
updated_at = CURRENT_TIMESTAMP
FROM seed_product s
WHERE p.deleted_at IS NULL
AND LOWER(p.name) = LOWER(s.name)
RETURNING p.id
)
INSERT INTO products (
name,
short_description,
type,
status,
sales_price,
vat_rate,
billing_period,
billable,
attributes_json
)
SELECT
s.name,
s.short_description,
s.type,
'active',
s.sales_price,
25.00,
s.billing_period,
true,
s.attributes_json
FROM seed_product s
WHERE NOT EXISTS (
SELECT 1
FROM products p
WHERE p.deleted_at IS NULL
AND LOWER(p.name) = LOWER(s.name)
);

View File

@ -0,0 +1,68 @@
CREATE TABLE IF NOT EXISTS internet_connections_customer_documents (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
filename VARCHAR(500) NOT NULL,
original_filename VARCHAR(500) NOT NULL,
file_path VARCHAR(1000) NOT NULL,
file_size INTEGER,
mime_type VARCHAR(120),
checksum VARCHAR(64) NOT NULL,
extracted_text TEXT,
notes TEXT,
uploaded_by INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_customer
ON internet_connections_customer_documents(customer_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_connection
ON internet_connections_customer_documents(connection_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_checksum
ON internet_connections_customer_documents(checksum);
CREATE INDEX IF NOT EXISTS idx_internet_customer_documents_created_at
ON internet_connections_customer_documents(created_at DESC)
WHERE deleted_at IS NULL;
CREATE OR REPLACE FUNCTION update_internet_customer_documents_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trigger_update_internet_customer_documents_updated_at
ON internet_connections_customer_documents;
CREATE TRIGGER trigger_update_internet_customer_documents_updated_at
BEFORE UPDATE ON internet_connections_customer_documents
FOR EACH ROW
EXECUTE FUNCTION update_internet_customer_documents_updated_at();
COMMENT ON TABLE internet_connections_customer_documents IS 'Kundeoplaeste tekstfiler til internetforbindelser og historisk research';
COMMENT ON COLUMN internet_connections_customer_documents.extracted_text IS 'Udtrukket/forsynlig tekst som wizard v2 og AI kan soege i';
CREATE TABLE IF NOT EXISTS internet_connections_customer_document_segments (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL REFERENCES internet_connections_customer_documents(id) ON DELETE CASCADE,
block_index INTEGER NOT NULL,
block_title VARCHAR(255),
content TEXT NOT NULL,
ip_addresses JSONB NOT NULL DEFAULT '[]'::jsonb,
cidr_blocks JSONB NOT NULL DEFAULT '[]'::jsonb,
references_json JSONB NOT NULL DEFAULT '[]'::jsonb,
socket_numbers JSONB NOT NULL DEFAULT '[]'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(document_id, block_index)
);
CREATE INDEX IF NOT EXISTS idx_internet_customer_document_segments_document
ON internet_connections_customer_document_segments(document_id);

View File

@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Reprocess stored supplier-invoice files through the app code."""
from __future__ import annotations
import argparse
import asyncio
import json
from app.billing.backend.supplier_invoices import reprocess_uploaded_file
from app.core.database import init_db
async def _run(file_ids: list[int]) -> list[dict]:
results = []
for file_id in file_ids:
result = await reprocess_uploaded_file(file_id)
results.append({"file_id": file_id, "result": result})
return results
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("file_ids", nargs="+", type=int)
args = parser.parse_args()
init_db()
payload = asyncio.run(_run(args.file_ids))
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,172 @@
#!/usr/bin/env python3
"""Reset internet connection data and rebuild from latest GlobalConnect extractions."""
from __future__ import annotations
import argparse
import json
from collections import defaultdict
from app.billing.backend.supplier_invoices import _sync_globalconnect_extraction_to_internet
from app.core.database import execute_query, execute_query_single, execute_update, init_db
def latest_globalconnect_extractions() -> list[dict]:
rows = execute_query(
"""
SELECT DISTINCT ON (e.file_id)
e.extraction_id,
e.file_id,
e.vendor_name,
e.document_id,
e.document_date,
e.created_at,
i.filename
FROM extractions e
JOIN incoming_files i ON i.file_id = e.file_id
WHERE LOWER(COALESCE(e.vendor_name, '')) LIKE '%%globalconnect%%'
ORDER BY e.file_id, e.created_at DESC, e.extraction_id DESC
"""
) or []
return [dict(row) for row in rows]
def reset_all_internet_data() -> dict:
summary: dict[str, int] = {}
counts = execute_query_single(
"""
SELECT
(SELECT COUNT(*) FROM internet_connections_connections WHERE deleted_at IS NULL) AS connections,
(SELECT COUNT(*) FROM internet_connections_ip_ranges WHERE deleted_at IS NULL) AS ip_ranges,
(SELECT COUNT(*) FROM internet_connections_ip_addresses WHERE deleted_at IS NULL) AS ip_addresses,
(SELECT COUNT(*) FROM internet_connections_pricing) AS pricing,
(SELECT COUNT(*) FROM internet_connections_history) AS history
"""
) or {}
summary["before_connections"] = int(counts.get("connections") or 0)
summary["before_ip_ranges"] = int(counts.get("ip_ranges") or 0)
summary["before_ip_addresses"] = int(counts.get("ip_addresses") or 0)
summary["before_pricing"] = int(counts.get("pricing") or 0)
summary["before_history"] = int(counts.get("history") or 0)
summary["deleted_ip_addresses"] = execute_update(
"""
UPDATE internet_connections_ip_addresses
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
comment = CONCAT(
COALESCE(comment, ''),
CASE WHEN COALESCE(comment, '') = '' THEN '' ELSE E'\n' END,
'Nulstillet før ren genimport af internetforbindelser.'
)
WHERE deleted_at IS NULL
"""
)
summary["deleted_ip_ranges"] = execute_update(
"""
UPDATE internet_connections_ip_ranges
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
description = CONCAT(
COALESCE(description, ''),
CASE WHEN COALESCE(description, '') = '' THEN '' ELSE E'\n' END,
'Nulstillet før ren genimport af internetforbindelser.'
)
WHERE deleted_at IS NULL
"""
)
summary["deleted_connections"] = execute_update(
"""
UPDATE internet_connections_connections
SET deleted_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP,
status = 'inactive',
notes = CONCAT(
COALESCE(notes, ''),
CASE WHEN COALESCE(notes, '') = '' THEN '' ELSE E'\n' END,
'Nulstillet før ren genimport af internetforbindelser.'
)
WHERE deleted_at IS NULL
"""
)
summary["deleted_pricing"] = execute_update("DELETE FROM internet_connections_pricing")
summary["deleted_history"] = execute_update("DELETE FROM internet_connections_history")
return summary
def rebuild_from_globalconnect() -> dict:
extractions = latest_globalconnect_extractions()
results = []
totals = defaultdict(int)
for extraction_stub in extractions:
extraction = execute_query_single(
"""
SELECT *
FROM extractions
WHERE extraction_id = %s
""",
(extraction_stub["extraction_id"],),
)
if not extraction:
continue
result = _sync_globalconnect_extraction_to_internet(dict(extraction))
result_summary = {
"file_id": extraction_stub["file_id"],
"extraction_id": extraction_stub["extraction_id"],
"filename": extraction_stub["filename"],
"document_id": extraction_stub["document_id"],
"connections_synced": int(result.get("connections_synced") or 0),
"connections_created": int(result.get("connections_created") or 0),
"connections_updated": int(result.get("connections_updated") or 0),
"ip_ranges_synced": int(result.get("ip_ranges_synced") or 0),
"skipped_connection_lines": int(result.get("skipped_connection_lines") or 0),
"skipped_ip_range_lines": int(result.get("skipped_orphan_ip_ranges") or 0),
"verification": result.get("verification") or {},
}
results.append(result_summary)
totals["files_processed"] += 1
totals["connections_synced"] += result_summary["connections_synced"]
totals["connections_created"] += result_summary["connections_created"]
totals["connections_updated"] += result_summary["connections_updated"]
totals["ip_ranges_synced"] += result_summary["ip_ranges_synced"]
totals["skipped_connection_lines"] += result_summary["skipped_connection_lines"]
totals["skipped_ip_range_lines"] += result_summary["skipped_ip_range_lines"]
counts = execute_query_single(
"""
SELECT
(SELECT COUNT(*) FROM internet_connections_connections WHERE deleted_at IS NULL) AS active_connections,
(SELECT COUNT(*) FROM internet_connections_connections WHERE deleted_at IS NULL AND (address IS NULL OR BTRIM(address) = '')) AS missing_address_connections,
(SELECT COUNT(*) FROM internet_connections_ip_ranges WHERE deleted_at IS NULL) AS active_ip_ranges,
(SELECT COUNT(*) FROM internet_connections_ip_addresses WHERE deleted_at IS NULL) AS active_ip_addresses
"""
) or {}
return {
"totals": dict(totals),
"results": results,
"post_counts": {
"active_connections": int(counts.get("active_connections") or 0),
"missing_address_connections": int(counts.get("missing_address_connections") or 0),
"active_ip_ranges": int(counts.get("active_ip_ranges") or 0),
"active_ip_addresses": int(counts.get("active_ip_addresses") or 0),
},
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--skip-rebuild", action="store_true", help="Only reset internet data")
args = parser.parse_args()
init_db()
payload = {
"reset": reset_all_internet_data(),
"rebuild": None if args.skip_rebuild else rebuild_from_globalconnect(),
}
print(json.dumps(payload, ensure_ascii=False, indent=2, default=str))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -3,11 +3,23 @@ let smsModalInstance = null;
function normalizeSmsNumber(number) { function normalizeSmsNumber(number) {
const raw = String(number || '').trim(); const raw = String(number || '').trim();
if (!raw) return ''; if (!raw) return '';
let cleaned = raw.replace(/[^\d+]/g, ''); const cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.startsWith('+')) cleaned = cleaned.slice(1); if (!cleaned) return '';
if (cleaned.startsWith('00')) cleaned = cleaned.slice(2);
if (/^\d{8}$/.test(cleaned)) cleaned = `45${cleaned}`; if (cleaned.startsWith('+')) {
return cleaned; const digits = cleaned.slice(1).replace(/\D+/g, '');
return digits.length >= 9 ? digits : '';
}
if (cleaned.startsWith('00')) {
const digits = cleaned.slice(2).replace(/\D+/g, '');
return digits.length >= 9 ? digits : '';
}
const digits = cleaned.replace(/\D+/g, '');
if (/^\d{8}$/.test(digits)) return `45${digits}`;
if (digits.length >= 9) return digits;
return '';
} }
async function sendSms(number, message, sender = null, contactId = null) { async function sendSms(number, message, sender = null, contactId = null) {

View File

@ -0,0 +1,797 @@
import sys
from decimal import Decimal
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.billing.backend import supplier_invoices as supplier_module
def test_sync_globalconnect_extraction_creates_connections_and_ip_ranges(monkeypatch):
created_connections = []
created_ranges = []
ensured_ranges = []
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "100 Mbps fiberforbindelse",
"provider_reference": "NKA008214",
"circuit_id": "NKA008214",
"line_total": 2850.0,
"unit_price": 950.0,
"quantity": 3,
"end_customer_name": "Malerfirmaet Gert Jensen ApS",
"service_address": "Metalbuen 26, 2750 Ballerup",
},
{
"description": "IPv4 IP-adresser",
"provider_reference": "NKA008214",
"circuit_id": "NKA008214",
"ip_address": "152.115.84.232/29",
"line_total": 192.0,
"unit_price": 64.0,
"quantity": 3,
"end_customer_name": "Malerfirmaet Gert Jensen ApS",
"service_address": "Metalbuen 26, 2750 Ballerup",
},
]
def fake_load_lines(_):
return lines
def fake_load_customers():
return [{"id": 322, "name": "Malerfirmaet Gert Jensen ApS", "address": "Metalbuen 26", "postal_code": "2750", "city": "Ballerup"}]
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_connections" in query and "WHERE id = %s" in query:
return {"address": "Metalbuen 26, 2750 Ballerup", "allocation_model": "dedicated"}
if "FROM internet_connections_pricing" in query:
return None
if "FROM internet_connections_ip_ranges" in query:
return None
if "COUNT(*) AS total" in query and "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
if "FROM internet_connections_ip_addresses" in query and "WHERE ip_address = %s" in query:
return None
return None
def fake_execute_query(query, params=None):
if "FROM internet_connections_connections" in query:
return []
if "FROM internet_connections_ip_ranges" in query:
return []
return []
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_connections" in query:
created_connections.append(params)
return 501
if "INSERT INTO internet_connections_ip_ranges" in query:
created_ranges.append(params)
return 601
return 1
def fake_execute_update(query, params=None):
if "INSERT INTO internet_connections_ip_addresses" in query:
ensured_ranges.append(params[0])
return 1
monkeypatch.setattr(supplier_module, "_load_extraction_lines", fake_load_lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", fake_load_customers)
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
monkeypatch.setattr(supplier_module, "execute_update", fake_execute_update)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["skipped"] is False
assert result["connections_synced"] == 1
assert result["connections_synced"] == 1
assert created_connections
assert created_ranges
assert ensured_ranges
assert created_connections[0][0] == "Malerfirmaet Gert Jensen ApS"
assert created_connections[0][9] == 100
assert created_connections[0][10] == 100
assert created_connections[0][11] == 100
assert created_connections[0][13] == "dedicated"
assert created_connections[0][14] == "other"
assert created_ranges[0][2] == "152.115.84.232/29"
def test_sync_globalconnect_infers_dsl_kbps_speed(monkeypatch):
created_connections = []
extraction = {
"extraction_id": 175,
"vendor_name": "GlobalConnect A/S",
"document_id": "3016392",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "20480/2048 Kbps ADSL forbindelse",
"provider_reference": "DSL-EB680288",
"circuit_id": "DSL-EB680288",
"line_total": 420.0,
"unit_price": 140.0,
"quantity": 3,
"end_customer_name": "Malerfirmaet Gert Jensen ApS",
"service_address": "Metalbuen 26, 2750 Ballerup",
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(
supplier_module,
"_load_active_customers_for_matching",
lambda: [{"id": 322, "name": "Malerfirmaet Gert Jensen ApS", "address": "Metalbuen 26", "postal_code": "2750", "city": "Ballerup"}],
)
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_connections" in query:
created_connections.append(params)
return 700
return 1
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: 1)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 1
assert created_connections
assert created_connections[0][9] == 20
assert created_connections[0][10] == 2
assert created_connections[0][11] == 20
def test_sync_globalconnect_marks_uncertain_connection_pending(monkeypatch):
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "1 Gbps fiberforbindelse",
"provider_reference": "NKA020902",
"circuit_id": "NKA020902",
"line_total": 5417.4,
"unit_price": 1805.8,
"quantity": 3,
"end_customer_name": None,
"service_address": None,
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0
assert result["skipped_connection_lines"] == 1
assert result["verification"]["requires_manual_review"] is True
assert result["skipped_items"][0]["reason"] == "Mangler serviceadresse"
def test_upsert_globalconnect_connection_requires_service_address(monkeypatch):
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
connection_id = supplier_module._upsert_globalconnect_connection(
reference="NKA020902",
lines=[{
"description": "1 Gbps fiberforbindelse",
"provider_reference": "NKA020902",
"circuit_id": "NKA020902",
"line_total": 5417.4,
"unit_price": 1805.8,
"quantity": 3,
"end_customer_name": None,
"service_address": None,
}],
invoice_date="2026-01-01",
invoice_number="3018657",
customers=[],
)
assert connection_id is None
def test_sync_globalconnect_assigns_shared_bmc_value_model(monkeypatch):
created_connections = []
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "IPv4 IP-adresser",
"provider_reference": "NKA008225",
"circuit_id": "NKA008225",
"ip_address": "152.115.57.96/27",
"line_total": 768.0,
"unit_price": 256.0,
"quantity": 3,
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(supplier_module, "_resolve_internal_bmc_customer", lambda: {"id": 1662, "name": "BMC Networks"})
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_connections" in query:
created_connections.append(params)
return 800
return 1
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: 1)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0
assert result["ip_ranges_synced"] == 0
assert result["skipped_orphan_ip_ranges"] == 1
assert created_connections == []
def test_upsert_globalconnect_connection_assigns_delefiber_for_internal_shared_owner(monkeypatch):
created_connections = []
line = {
"description": "1 Gbps fiberforbindelse MPLS VPN",
"provider_reference": "NKA020900",
"circuit_id": "NKA020900",
"line_total": 4950.0,
"unit_price": 1650.0,
"quantity": 3,
"service_address": "Rydagervej 27, 2620 Albertslund",
"end_customer_name": "",
}
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
monkeypatch.setattr(supplier_module, "_resolve_internal_bmc_customer", lambda: {"id": 1662, "name": "BMC Networks"})
monkeypatch.setattr(supplier_module, "_ensure_connection_pricing_entry", lambda **kwargs: None)
monkeypatch.setattr(supplier_module, "_append_connection_history", lambda *args, **kwargs: None)
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_connections" in query:
created_connections.append(params)
return 901
return 1
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
connection_id = supplier_module._upsert_globalconnect_connection(
reference="NKA020900",
lines=[line],
invoice_date="2026-01-01",
invoice_number="3018657",
customers=[],
)
assert connection_id == 901
assert created_connections
assert created_connections[0][2] == 1662
assert created_connections[0][-3] == "shared"
assert created_connections[0][-2] == "delefiber"
def test_sync_globalconnect_attaches_ip_range_to_existing_connection(monkeypatch):
created_ranges = []
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "IPv4 IP-adresser",
"provider_reference": "NKA008225",
"circuit_id": "NKA008225",
"ip_address": "152.115.57.96/27",
"line_total": 768.0,
"unit_price": 256.0,
"quantity": 3,
"service_address": "Metalbuen 26, 2750 Ballerup",
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [{"id": 16, "address": "Metalbuen 26, 2750 Ballerup"}] if "FROM internet_connections_connections" in query else [],
)
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_ranges" in query:
return None
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_insert", lambda query, params=None: 601 if "INSERT INTO internet_connections_ip_ranges" in query else 1)
def fake_execute_update(query, params=None):
return 1
monkeypatch.setattr(supplier_module, "execute_update", fake_execute_update)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0
assert result["ip_ranges_synced"] == 1
assert result["skipped_orphan_ip_ranges"] == 0
assert result["line_audit"][0]["result"] == "linked_existing_connection"
def test_sync_globalconnect_normalizes_reference_without_dash(monkeypatch):
created_ranges = []
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "IPv4 IP-adresser",
"provider_reference": "NKA008225",
"circuit_id": "NKA008225",
"ip_address": "152.115.57.96/27",
"line_total": 768.0,
"unit_price": 256.0,
"quantity": 3,
"service_address": "Metalbuen 26, 2750 Ballerup",
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [{"id": 32, "address": "Metalbuen 26, 2750 Ballerup"}] if "FROM internet_connections_connections" in query else [],
)
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_ranges" in query:
return None
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_ip_ranges" in query:
created_ranges.append(params)
return 3
return 1
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: 1)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["ip_ranges_synced"] == 1
assert created_ranges
assert created_ranges[0][0] == 32
def test_sync_globalconnect_merges_duplicate_connections(monkeypatch):
updates = []
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "100 Mbps fiberforbindelse",
"provider_reference": "NKA008225",
"circuit_id": "NKA008225",
"line_total": 768.0,
"unit_price": 256.0,
"quantity": 3,
"service_address": "Metalbuen 26, 2750 Ballerup",
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
def fake_execute_query(query, params=None):
if "FROM internet_connections_connections" in query:
return [{"id": 16, "customer_id": None, "address": None}, {"id": 32, "customer_id": None, "address": None}]
if "FROM internet_connections_ip_ranges" in query:
return []
return []
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_ranges" in query:
return None
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_insert", lambda query, params=None: 3)
monkeypatch.setattr(supplier_module, "_ensure_internet_change_case", lambda **kwargs: 99)
monkeypatch.setattr(supplier_module, "_ensure_connection_pricing_entry", lambda **kwargs: None)
def fake_execute_update(query, params=None):
updates.append((query, params))
return 1
monkeypatch.setattr(supplier_module, "execute_update", fake_execute_update)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 1
assert any(
"UPDATE internet_connections_ip_ranges" in query and params[0] == 16 and params[1] == 32
for query, params in updates
)
assert any(
"UPDATE internet_connections_connections" in query and params[1] == 32
for query, params in updates
)
def test_sync_globalconnect_skips_reference_when_address_conflicts(monkeypatch):
extraction = {
"extraction_id": 176,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "100 Mbps fiberforbindelse",
"provider_reference": "NKA008225",
"circuit_id": "NKA008225",
"line_total": 768.0,
"unit_price": 256.0,
"quantity": 3,
"service_address": "Metalbuen 26, 2750 Ballerup",
}
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(
supplier_module,
"execute_query",
lambda query, params=None: [{"id": 16, "address": "Andenvej 99, 2100 København Ø"}] if "FROM internet_connections_connections" in query else [],
)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 0
assert result["verification"]["requires_manual_review"] is True
assert "anden adresse" in result["skipped_items"][0]["reason"].lower()
def test_sync_globalconnect_skips_ip_range_when_connection_reference_has_other_service_address(monkeypatch):
extraction = {
"extraction_id": 180,
"vendor_name": "GlobalConnect A/S",
"document_id": "3018657",
"document_date": "2026-01-01",
"llm_response_json": {"template": "dk.globalconnect"},
}
lines = [
{
"description": "1 Gbps fiberforbindelse MPLS VPN",
"provider_reference": "NKA020900",
"circuit_id": "NKA020900",
"line_total": 4950.0,
"unit_price": 1650.0,
"quantity": 3,
"service_address": "Rydagervej 27, 2620 Albertslund",
},
{
"description": "IPv4 IP-adresser",
"provider_reference": "NKA-020900",
"circuit_id": "NKA-020900",
"ip_address": "87.116.1.200/29",
"line_total": 384.0,
"unit_price": 128.0,
"quantity": 3,
"service_address": "Tobaksvejen 25, 2860 Søborg",
},
]
monkeypatch.setattr(supplier_module, "_load_extraction_lines", lambda _: lines)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [])
monkeypatch.setattr(supplier_module, "_ensure_connection_pricing_entry", lambda **kwargs: None)
monkeypatch.setattr(supplier_module, "_append_connection_history", lambda *args, **kwargs: None)
monkeypatch.setattr(supplier_module, "_ensure_internet_change_case", lambda **kwargs: None)
def fake_execute_query(query, params=None):
if "FROM internet_connections_connections" in query:
if "regexp_replace(UPPER(COALESCE(circuit_number, ''))" in query:
return []
return []
if "FROM internet_connections_ip_ranges" in query:
return []
return []
def fake_execute_query_single(query, params=None):
if "FROM customers" in query:
return None
if "FROM internet_connections_connections" in query and "WHERE id = %s" in query:
return {
"address": "Rydagervej 27, 2620 Albertslund",
"allocation_model": "dedicated",
}
if "FROM internet_connections_ip_ranges" in query:
return None
if "FROM internet_connections_ip_addresses" in query:
return {"total": 0}
return None
created_connections = []
def fake_execute_insert(query, params=None):
if "INSERT INTO internet_connections_connections" in query:
created_connections.append(params)
return 96
if "INSERT INTO internet_connections_ip_ranges" in query:
raise AssertionError("IP-range should not be created when service address conflicts")
return 1
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_insert", fake_execute_insert)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: 1)
result = supplier_module._sync_globalconnect_extraction_to_internet(extraction)
assert result["connections_synced"] == 1
assert result["ip_ranges_synced"] == 0
assert result["skipped_orphan_ip_ranges"] == 1
assert created_connections
skipped_ip_entries = [entry for entry in result["line_audit"] if entry["classification"] == "ip_range"]
assert skipped_ip_entries
assert skipped_ip_entries[0]["status"] == "skipped"
assert "ingen forbindelse fundet" in (skipped_ip_entries[0]["reason"] or "").lower()
def test_upsert_globalconnect_connection_logs_changes_and_creates_case(monkeypatch):
history_calls = []
case_calls = []
updates = []
customer = {
"id": 322,
"name": "Malerfirmaet Gert Jensen ApS",
"address": "Metalbuen 26",
"postal_code": "2750",
"city": "Ballerup",
}
line = {
"description": "200 Mbps fiberforbindelse",
"provider_reference": "NKA008214",
"circuit_id": "NKA008214",
"line_total": 360.0,
"unit_price": 120.0,
"quantity": 3,
"end_customer_name": "Malerfirmaet Gert Jensen ApS",
"service_address": "Metalbuen 26, 2750 Ballerup",
}
def fake_execute_query(query, params=None):
if "FROM internet_connections_connections" in query:
return [
{
"id": 16,
"customer_id": 322,
"address": "Metalbuen 26, 2750 Ballerup",
"monthly_cost": Decimal("100.00"),
"technology": "Fiber",
"connection_type": "fiber",
"circuit_number": "NKA008214",
"speed_mbps": 100,
"download_mbps": 100,
"upload_mbps": 100,
"status": "active",
"allocation_model": "dedicated",
"value_type": "other",
"value_label": None,
}
]
return []
monkeypatch.setattr(supplier_module, "execute_query", fake_execute_query)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: updates.append((query, params)) or 1)
monkeypatch.setattr(supplier_module, "_append_connection_history", lambda *args: history_calls.append(args))
monkeypatch.setattr(supplier_module, "_ensure_internet_change_case", lambda **kwargs: case_calls.append(kwargs) or 91)
monkeypatch.setattr(supplier_module, "_ensure_connection_pricing_entry", lambda **kwargs: None)
connection_id = supplier_module._upsert_globalconnect_connection(
reference="NKA008214",
lines=[line],
invoice_date="2026-01-01",
invoice_number="3018657",
customers=[customer],
)
assert connection_id == 16
assert updates
assert history_calls
assert history_calls[0][1] == "supplier_invoice_sync_changed"
assert history_calls[0][3]["changes"]["monthly_cost"] == {"from": "100.00", "to": "120.00"}
assert history_calls[0][3]["changes"]["speed_mbps"] == {"from": "100", "to": "200"}
assert case_calls
assert case_calls[0]["connection_id"] == 16
assert case_calls[0]["changes"]["download_mbps"] == {"from": "100", "to": "200"}
def test_upsert_globalconnect_ip_range_logs_changes_and_creates_case(monkeypatch):
history_calls = []
case_calls = []
customer = {
"id": 322,
"name": "Malerfirmaet Gert Jensen ApS",
"address": "Metalbuen 26",
"postal_code": "2750",
"city": "Ballerup",
}
line = {
"description": "IPv4 IP-adresser",
"provider_reference": "NKA008214",
"circuit_id": "NKA008214",
"contract_number": "GC-NEW",
"ip_address": "152.115.84.232/29",
"line_total": 240.0,
"unit_price": 80.0,
"quantity": 3,
"end_customer_name": "Malerfirmaet Gert Jensen ApS",
"service_address": "Metalbuen 26, 2750 Ballerup",
}
def fake_execute_query_single(query, params=None):
if "FROM internet_connections_ip_ranges" in query:
return {
"id": 88,
"provider_reference": "NKA008214",
"contract_number": "GC-OLD",
"customer_id": None,
"service_address": "Gammel adresse 1, 2000 Frederiksberg",
"monthly_cost": Decimal("64.00"),
}
if "FROM internet_connections_ip_addresses" in query:
return {"total": 6}
return None
monkeypatch.setattr(supplier_module, "execute_query", lambda query, params=None: [])
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_update", lambda query, params=None: 1)
monkeypatch.setattr(supplier_module, "_append_connection_history", lambda *args: history_calls.append(args))
monkeypatch.setattr(supplier_module, "_ensure_internet_change_case", lambda **kwargs: case_calls.append(kwargs) or 92)
monkeypatch.setattr(supplier_module, "_ensure_ip_addresses_for_range", lambda range_id, cidr: 6)
monkeypatch.setattr(supplier_module, "_load_active_customers_for_matching", lambda: [customer])
range_id = supplier_module._upsert_globalconnect_ip_range(16, line, "3018657")
assert range_id == 88
assert history_calls
assert history_calls[0][1] == "supplier_invoice_ip_range_changed"
assert history_calls[0][3]["changes"]["contract_number"] == {"from": "GC-OLD", "to": "GC-NEW"}
assert history_calls[0][3]["changes"]["monthly_cost"] == {"from": "64.00", "to": "80.00"}
assert case_calls
assert case_calls[0]["reference"] == "NKA008214"
assert case_calls[0]["changes"]["service_address"]["to"] == "Metalbuen 26, 2750 Ballerup"
def test_supplier_sync_skips_ip_addresses_that_already_exist(monkeypatch):
inserted = []
def fake_execute_query_single(query, params=None):
if "COUNT(*) AS total" in query and "internet_connections_ip_addresses" in query:
return {"total": 0}
if "FROM internet_connections_ip_addresses" in query and "WHERE ip_address = %s" in query:
if params[0] == "152.115.84.233":
return {"id": 91}
return None
return None
def fake_execute_update(query, params=None):
if "INSERT INTO internet_connections_ip_addresses" in query:
inserted.append(params[1])
return 1
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_update", fake_execute_update)
created = supplier_module._ensure_ip_addresses_for_range(601, "152.115.84.232/30")
assert created == 1
assert inserted == ["152.115.84.234"]
def test_append_amount_validation_case_note_creates_system_comment(monkeypatch):
comments = []
def fake_execute_query_single(query, params=None):
if "FROM sag_kommentarer" in query:
return None
return None
def fake_execute_update(query, params=None):
if "INSERT INTO sag_kommentarer" in query:
comments.append(params)
return 1
monkeypatch.setattr(supplier_module, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(supplier_module, "execute_update", fake_execute_update)
supplier_module._append_amount_validation_case_note(
sag_id=91,
invoice_id=17,
invoice_number="3018657",
validation_details={
"line_sum": 600.0,
"subtotal": 800.0,
"difference": 200.0,
"vat_amount": 200.0,
"vat_expected": 200.0,
"vat_difference": 0.0,
},
validation_warning="Varelinjer sum (600.00) passer ikke med subtotal (800.00)",
vat_warning=None,
file_id=176,
)
assert comments
assert comments[0][0] == 91
assert "Subtotal-advarsel" in comments[0][2]
assert "Afvigelse: 200.0" in comments[0][2]

View File

@ -0,0 +1,661 @@
import sys
import asyncio
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi.testclient import TestClient
from main import app
def test_internet_connections_module_routes_are_available():
client = TestClient(app)
health_response = client.get('/api/v1/internet-connections/health')
assert health_response.status_code == 200
assert health_response.json()['service'] == 'internet-connections-module'
page_response = client.get('/economy/internet-connections')
assert page_response.status_code == 200
assert (
'Internetforbindelser' in page_response.text
or "window.location.href = '/login'" in page_response.text
)
detail_response = client.get('/economy/internet-connections/1')
assert detail_response.status_code == 200
assert (
'IP-ranges' in detail_response.text
or "window.location.href = '/login'" in detail_response.text
)
def test_create_ip_range_rejects_invalid_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/ip-ranges', json={
'name': 'LAN',
'cidr': 'not-a-cidr',
'description': 'Test range',
})
assert response.status_code == 400
assert 'CIDR' in response.json()['detail']
def test_create_connection_requires_address(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [])
client = TestClient(app)
response = client.post('/api/v1/internet-connections', json={
'name': 'Uden adresse',
'provider': 'GlobalConnect A/S',
'status': 'active',
'allocation_model': 'shared',
'value_type': 'bmc_networks',
})
assert response.status_code == 400
assert 'address is required' in response.json()['detail']
def test_list_ip_ranges_includes_ipam_details(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'FROM internet_connections_ip_ranges' in query:
return [{
'id': 9,
'connection_id': 2,
'name': 'LAN',
'cidr': '10.0.0.0/24',
'description': 'Test range',
}]
if 'FROM internet_connections_ip_addresses' in query:
return [{
'id': 1,
'range_id': 9,
'ip_address': '10.0.0.1',
'status': 'in_use',
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/2/ip-ranges')
assert response.status_code == 200
payload = response.json()[0]
assert payload['network_address'] == '10.0.0.0'
assert payload['usable_hosts'] == 254
assert payload['used_addresses'] == 1
def test_migration_wizard_v2_query_requires_real_segment_match(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if "FROM internet_connections_customer_documents" in query:
return [{
"id": 101,
"customer_id": 77,
"connection_id": None,
"original_filename": "management_net.txt",
"filename": "management_net.txt",
"file_size": 1024,
"mime_type": "text/plain",
"extracted_text": "1 UNTAGGED Native Management\nVlan 50 Not in Use\nIP Informationer",
"notes": None,
"created_at": None,
}]
if "FROM internet_connections_customer_document_segments" in query:
return [{
"id": 201,
"document_id": 101,
"block_index": 0,
"block_title": "IP Informationer",
"content": "1 UNTAGGED Native Management\nVlan 50 Not in Use\nIP Informationer",
"ip_addresses": [],
"cidr_blocks": [],
"references_json": [],
"socket_numbers": [],
}]
return []
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
monkeypatch.setattr(internet_router, "_ensure_document_segments", lambda document_id, extracted_text: 1)
payload = asyncio.run(internet_router._build_customer_document_hits(77, "Karise", "stageone"))
assert payload["segments"] == []
assert payload["documents"] == []
def test_migration_wizard_v2_query_keeps_precise_segment_hits(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if "FROM internet_connections_customer_documents" in query:
return [{
"id": 102,
"customer_id": 77,
"connection_id": None,
"original_filename": "karise.txt",
"filename": "karise.txt",
"file_size": 2048,
"mime_type": "text/plain",
"extracted_text": "StageOne uplink til Karise\nPort 1 StageOne WAN U20",
"notes": None,
"created_at": None,
}]
if "FROM internet_connections_customer_document_segments" in query:
return [{
"id": 202,
"document_id": 102,
"block_index": 0,
"block_title": "StageOne uplink til Karise",
"content": "StageOne uplink til Karise\nPort 1 StageOne WAN U20",
"ip_addresses": [],
"cidr_blocks": [],
"references_json": [],
"socket_numbers": ["U20"],
}]
return []
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
monkeypatch.setattr(internet_router, "_ensure_document_segments", lambda document_id, extracted_text: 1)
payload = asyncio.run(internet_router._build_customer_document_hits(77, "Karise", "stageone"))
assert len(payload["segments"]) == 1
assert payload["segments"][0]["title"] == "StageOne uplink til Karise"
assert payload["documents"][0]["snippet_count"] == 1
def test_create_ip_range_auto_generates_addresses_from_cidr(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
created_addresses = []
def fake_execute_query(query, params=None):
if 'INSERT INTO internet_connections_ip_ranges' in query:
return [{
'id': 15,
'connection_id': 2,
'name': 'LAN',
'cidr': '192.168.1.0/30',
'description': 'Auto generated',
}]
if 'INSERT INTO internet_connections_ip_addresses' in query:
created_addresses.append(params[1])
return [{
'id': len(created_addresses),
'range_id': 15,
'ip_address': params[1],
'status': 'available',
}]
if 'INSERT INTO internet_connections_history' in query:
return [{
'id': 20,
'connection_id': 2,
'event_type': 'ip_range_created',
'summary': 'Created IP range',
'details': {},
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/ip-ranges', json={
'name': 'LAN',
'cidr': '192.168.1.0/30',
'description': 'Auto generated',
})
assert response.status_code == 200
assert len(created_addresses) == 2
assert created_addresses == ['192.168.1.1', '192.168.1.2']
def test_create_ip_range_skips_duplicate_existing_ip_addresses(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
created_addresses = []
def fake_execute_query(query, params=None):
if 'INSERT INTO internet_connections_ip_ranges' in query:
return [{
'id': 15,
'connection_id': 2,
'name': 'LAN',
'cidr': '192.168.1.0/30',
'description': 'Auto generated',
}]
if 'FROM internet_connections_ip_addresses' in query and 'WHERE ip_address = %s' in query:
if params[0] == '192.168.1.1':
return [{'id': 99, 'ip_address': '192.168.1.1'}]
return []
if 'INSERT INTO internet_connections_ip_addresses' in query:
created_addresses.append(params[1])
return [{
'id': len(created_addresses),
'range_id': 15,
'ip_address': params[1],
'status': 'available',
}]
if 'INSERT INTO internet_connections_history' in query:
return [{'id': 20}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/ip-ranges', json={
'name': 'LAN',
'cidr': '192.168.1.0/30',
'description': 'Auto generated',
})
assert response.status_code == 200
assert created_addresses == ['192.168.1.2']
def test_update_ip_address_status_changes_record(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
updated = []
def fake_execute_query(query, params=None):
if 'UPDATE internet_connections_ip_addresses' in query:
updated.append(params)
return [{'id': 4, 'status': 'reserved'}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.put('/api/v1/internet-connections/2/ip-addresses/4', json={
'status': 'reserved',
'assigned_to': 'Test device',
'comment': 'Reserved for switch',
})
assert response.status_code == 200
assert updated[0][0] == 'reserved'
assert updated[0][1] == 'Test device'
def test_create_ip_address_rejects_duplicate_ip(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'FROM internet_connections_ip_addresses' in query and 'WHERE ip_address = %s' in query:
return [{'id': 4, 'range_id': 2}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/ip-addresses', json={
'range_id': 7,
'ip_address': '10.0.0.10',
'status': 'available',
})
assert response.status_code == 409
assert 'findes allerede' in response.json()['detail']
def test_list_ip_addresses_returns_structured_payload(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'FROM internet_connections_ip_addresses' in query and 'JOIN internet_connections_ip_ranges' in query:
return [{
'id': 7,
'range_id': 3,
'ip_address': '10.0.0.10',
'status': 'in_use',
'assigned_to': 'Router',
'assigned_type': 'device',
'comment': 'Main gateway',
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/2/ip-addresses')
assert response.status_code == 200
payload = response.json()[0]
assert payload['status_label'] == 'I brug'
assert payload['badge_class'] == 'bg-primary'
def test_create_ip_range_writes_history(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
calls = []
def fake_execute_query(query, params=None):
calls.append((query, params))
if 'INSERT INTO internet_connections_ip_ranges' in query:
return [{
'id': 1,
'connection_id': 2,
'name': 'LAN',
'cidr': '10.0.0.0/24',
'description': 'Test range',
}]
if 'INSERT INTO internet_connections_history' in query:
return [{
'id': 11,
'connection_id': 2,
'event_type': 'ip_range_created',
'summary': 'Created IP range',
'details': {},
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/ip-ranges', json={
'name': 'LAN',
'cidr': '10.0.0.0/24',
'description': 'Test range',
})
assert response.status_code == 200
assert any('INSERT INTO internet_connections_history' in query for query, _ in calls)
def test_ip_address_status_summary_endpoint(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'COUNT(*)' in query and 'internet_connections_ip_addresses' in query:
return [{
'available': 2,
'in_use': 1,
'reserved': 1,
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/2/ip-addresses/summary')
assert response.status_code == 200
assert response.json()['available'] == 2
assert response.json()['in_use'] == 1
def test_pricing_history_can_be_created(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'INSERT INTO internet_connections_pricing' in query:
return [{
'id': 7,
'connection_id': 2,
'effective_from': '2026-07-01',
'purchase_price': 1500,
'sales_price': 1800,
'notes': 'Ny aftale',
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.post('/api/v1/internet-connections/2/pricing', json={
'effective_from': '2026-07-01',
'purchase_price': 1500,
'sales_price': 1800,
'notes': 'Ny aftale',
})
assert response.status_code == 200
assert response.json()['sales_price'] == 1800
def test_contract_overview_endpoint_reports_status(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'FROM internet_connections_connections' in query and 'contract_end' in query:
return [{
'id': 3,
'name': 'Test connection',
'provider': 'BMC',
'contract_start': '2025-01-01',
'contract_end': '2025-12-31',
'status': 'active',
'sales_price': 900,
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/contracts')
assert response.status_code == 200
assert response.json()[0]['contract_status'] == 'expired'
def test_contract_overview_endpoint_supports_status_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'status = %s' in query:
return [{
'id': 4,
'name': 'Filtered contract',
'provider': 'Nordic',
'contract_start': '2026-01-01',
'contract_end': '2026-12-31',
'status': 'active',
'sales_price': 1200,
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/contracts', params={'status': 'active'})
assert response.status_code == 200
assert response.json()[0]['status'] == 'active'
def test_contract_overview_page_contains_table_controls():
client = TestClient(app)
response = client.get('/economy/internet-connections')
assert response.status_code == 200
assert (
'connectionsTableBody' in response.text
or "window.location.href = '/login'" in response.text
)
assert (
'pageSummaryText' in response.text
or "window.location.href = '/login'" in response.text
)
def test_contract_overview_returns_empty_list_when_query_fails(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fail_execute_query(query, params=None):
raise RuntimeError('database unavailable')
monkeypatch.setattr(internet_router, 'execute_query', fail_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/contracts')
assert response.status_code == 200
assert response.json() == []
def test_list_connections_returns_empty_list_when_query_fails(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fail_execute_query(query, params=None):
raise RuntimeError('database unavailable')
monkeypatch.setattr(internet_router, 'execute_query', fail_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections')
assert response.status_code == 200
assert response.json() == []
def test_pricing_summary_returns_zeroes_when_query_fails(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fail_execute_query(query, params=None):
raise RuntimeError('database unavailable')
monkeypatch.setattr(internet_router, 'execute_query', fail_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/pricing/summary')
assert response.status_code == 200
assert response.json() == {
'total_connections': 0,
'active_connections': 0,
'shared_head_connections': 0,
'total_purchase_cost': 0,
'total_sales_price': 0,
'total_margin': 0,
}
def test_create_connection_requires_subscription_id_for_subscription_value(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [])
client = TestClient(app)
response = client.post('/api/v1/internet-connections', json={
'name': 'Shared transit',
'address': 'Testvej 1, 8000 Aarhus C',
'allocation_model': 'shared',
'value_type': 'subscription',
})
assert response.status_code == 400
assert 'subscription_id' in response.json()['detail']
def test_create_connection_requires_value_label_for_other(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
monkeypatch.setattr(internet_router, 'execute_query', lambda query, params=None: [])
client = TestClient(app)
response = client.post('/api/v1/internet-connections', json={
'name': 'Carrier edge',
'address': 'Testvej 1, 8000 Aarhus C',
'allocation_model': 'dedicated',
'value_type': 'other',
})
assert response.status_code == 400
assert 'value_label' in response.json()['detail']
def test_list_connections_supports_shared_only_filter(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
assert "ic.allocation_model = 'shared' AND ic.parent_id IS NULL" in query
return [{
'id': 10,
'parent_id': None,
'name': 'BMC Networks · NKA008225',
'provider': 'GlobalConnect',
'customer_id': 1662,
'customer_name': 'BMC Networks',
'parent_name': None,
'address': None,
'status': 'active',
'monthly_cost': 256,
'sales_price': 0,
'margin_amount': -256,
'technology': 'Fiber',
'connection_type': 'fiber',
'circuit_number': 'NKA008225',
'speed_mbps': 100,
'upload_mbps': 100,
'download_mbps': 100,
'monitoring_url': None,
'contract_start': None,
'contract_end': None,
'allocation_model': 'shared',
'value_type': 'bmc_networks',
'value_label': None,
'subscription_id': None,
'subscription_number': None,
'subscription_product_name': None,
'subscription_customer_name': None,
'ip_range_count': 3,
'total_ip_addresses': 62,
'in_use_ip_addresses': 0,
'reserved_ip_addresses': 0,
'available_ip_addresses': 62,
}]
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections', params={'shared_only': 'true'})
assert response.status_code == 200
payload = response.json()[0]
assert payload['allocation_model_label'] == 'Delt'
assert payload['value_type_label'] == 'BMC Networks'
assert payload['is_shared_head'] is True
def test_subscription_options_endpoint_returns_lookup_rows(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query(query, params=None):
if 'FROM sag_subscriptions s' in query:
return [{
'id': 9,
'subscription_number': 'SUB-1001',
'product_name': 'Internet 1G',
'customer_id': 1662,
'customer_name': 'BMC Networks',
'status': 'active',
}]
return []
monkeypatch.setattr(internet_router, 'execute_query', fake_execute_query)
client = TestClient(app)
response = client.get('/api/v1/internet-connections/subscription-options', params={'q': '1G'})
assert response.status_code == 200
assert response.json()[0]['subscription_number'] == 'SUB-1001'

View File

@ -0,0 +1,116 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from app.services.invoice2data_service import Invoice2DataService
def test_globalconnect_parser_extracts_contextual_lines():
service = Invoice2DataService()
sample_text = """
GlobalConnect A/S
Kundenr. D30190
Fakturanr. 3016392
Bilagsdato 1. januar 2026
Forfaldsdato 1. april 2026
Kontrakt: 20292
Vedr: DSL-EB722388
Metalbuen 26 2750 Ballerup
Slutkunde: MALERFIRMAET GERT JENSEN ApS VOIP
Periode: 01-01-2026 - 31-03-2026
20480/2048 Kbps ADSL forbindelse 3 Stk. 140,00 420,00
Kontrakt: 20140005-00
Vedr: IP adresser
152.115.84.232/29 (NKA-008214)
Industrivej 7 2605 Brondby
IPv4 IP-adresser 3 Stk. 64,00 192,00
I alt DKK ekskl. moms 612,00
25% moms 153,00
I alt DKK inkl. moms 765,00
SE/CVR-nr. 26759722
29522790
""".strip()
extracted = service.extract_with_template(sample_text, "dk.globalconnect")
assert extracted["invoice_number"] == 3016392
assert extracted["invoice_date"] == "2026-01-01"
assert extracted["due_date"] == "2026-04-01"
assert extracted["customer_reference"] == "D30190"
assert extracted["vendor_vat"] == "26759722"
assert extracted["amount_total"] == 765.0
assert len(extracted["lines"]) == 2
first_line = extracted["lines"][0]
assert first_line["provider_reference"] == "DSL-EB722388"
assert first_line["contract_number"] == "20292"
assert first_line["end_customer_name"] == "MALERFIRMAET GERT JENSEN ApS VOIP"
assert first_line["period_start"] == "2026-01-01"
assert first_line["period_end"] == "2026-03-31"
second_line = extracted["lines"][1]
assert second_line["ip_address"] == "152.115.84.232/29"
assert second_line["provider_reference"] == "NKA-008214"
assert second_line["service_address"] == "Industrivej 7, 2605 Brondby"
def test_validation_details_are_added_on_subtotal_mismatch():
service = Invoice2DataService()
extracted = {
"total_amount": 1000.0,
"vat_amount": 200.0,
"lines": [
{"line_total": 500.0},
{"line_total": 100.0},
],
}
service._validate_amounts(extracted)
assert "_validation_warning" in extracted
assert extracted["_validation_details"]["line_sum"] == 600.0
assert extracted["_validation_details"]["subtotal"] == 800.0
assert extracted["_validation_details"]["difference"] == 200.0
assert extracted["_validation_details"]["subtotal_matches"] is False
def test_globalconnect_parser_handles_split_address_and_mpls_multiline_context():
service = Invoice2DataService()
sample_text = """
GlobalConnect A/S
Kundenr. D30190
Fakturanr. 3018657
Bilagsdato 1. januar 2026
Forfaldsdato 1. april 2026
NKA020900
Rydagervej 27
2620 Albertslund
1 Gbps fiberforbindelse 3 Måneder 1.650,00 4.950,00
MPLS VPN (layer 3) Serviceaftale Døgn 3 Måneder
1 Gbps Flatrate internet
IPv4 IP-adresser 3 Stk. 64,00 192,00
87.116.1.200/29 (NKA-020900)
Rydagervej 27
2620 Albertslund
I alt DKK ekskl. moms 5.142,00
25% moms 1.285,50
I alt DKK inkl. moms 6.427,50
SE/CVR-nr. 26759722
29522790
""".strip()
extracted = service.extract_with_template(sample_text, "dk.globalconnect")
assert len(extracted["lines"]) == 2
connection_line = extracted["lines"][0]
assert connection_line["provider_reference"] == "NKA020900"
assert connection_line["service_address"] == "Rydagervej 27, 2620 Albertslund"
assert "MPLS VPN" in connection_line["description"]
assert "1 Gbps Flatrate internet" in connection_line["description"]
ip_line = extracted["lines"][1]
assert ip_line["provider_reference"] == "NKA-020900"
assert ip_line["ip_address"] == "87.116.1.200/29"
assert ip_line["service_address"] == "Rydagervej 27, 2620 Albertslund"

View File

@ -0,0 +1,430 @@
import sys
import asyncio
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi.testclient import TestClient
from main import app
def test_subscription_by_sag_includes_network_provisioning(monkeypatch):
from app.subscriptions.backend import router as subscription_router
def fake_execute_query_single(query, params=None):
if "FROM sag_subscriptions s" in query and "WHERE s.sag_id = %s" in query:
return {
"id": 44,
"subscription_number": "SUB-44",
"sag_id": 9,
"customer_id": 1662,
"customer_name": "BMC Networks",
"product_name": "BMCnet 100/100",
"billing_interval": "monthly",
"billing_day": 1,
"price": 499,
"start_date": "2026-07-01",
"end_date": None,
"status": "draft",
"notes": None,
}
if "FROM internet_connections_connections" in query and "subscription_id = %s" in query:
return None
return None
def fake_execute_query(query, params=None):
if "FROM sag_subscription_items i" in query:
return [
{
"id": 100,
"line_no": 1,
"product_id": 501,
"product_name": "BMCnet 100/100",
"product_type": "service",
"attributes_json": {"network": {"kind": "internet_access", "download_mbps": 100, "upload_mbps": 100}},
"description": "BMCnet 100/100",
"quantity": 1,
"unit_price": 399,
"line_total": 399,
"period_from": None,
"period_to": None,
"requires_serial_number": False,
"serial_number": None,
"billing_blocked": False,
"billing_block_reason": None,
},
{
"id": 101,
"line_no": 2,
"product_id": 502,
"product_name": "/30 IP",
"product_type": "service",
"attributes_json": {"network": {"kind": "ip_allocation", "ip_prefix_length": 30}},
"description": "/30 IP",
"quantity": 1,
"unit_price": 100,
"line_total": 100,
"period_from": None,
"period_to": None,
"requires_serial_number": False,
"serial_number": None,
"billing_blocked": False,
"billing_block_reason": None,
},
]
return []
monkeypatch.setattr(subscription_router, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(subscription_router, "execute_query", fake_execute_query)
payload = asyncio.run(subscription_router.get_subscription_by_sag(9, True))
assert payload["requires_network_provisioning"] is True
assert payload["network_provisioning"]["internet_items"][0]["download_mbps"] == 100
assert payload["network_provisioning"]["ip_items"][0]["ip_prefix_length"] == 30
def test_subscription_provisioning_endpoint_returns_shared_heads(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query_single(query, params=None):
if "FROM sag_subscriptions s" in query:
return {
"id": 55,
"subscription_number": "SUB-55",
"sag_id": 7,
"customer_id": 21,
"customer_name": "Eksempel Kunde",
"product_name": "BMCnet 100/100",
"billing_interval": "monthly",
"price": 699,
"status": "draft",
"start_date": "2026-07-01",
}
return None
def fake_execute_query(query, params=None):
if "FROM sag_subscription_items i" in query:
return [
{
"id": 201,
"line_no": 1,
"product_id": 601,
"product_name": "BMCnet 100/100",
"product_type": "service",
"attributes_json": {"network": {"kind": "internet_access", "download_mbps": 100, "upload_mbps": 100}},
"description": "BMCnet 100/100",
"quantity": 1,
"unit_price": 499,
"line_total": 499,
},
{
"id": 202,
"line_no": 2,
"product_id": 602,
"product_name": "/30 IP",
"product_type": "service",
"attributes_json": {"network": {"kind": "ip_allocation", "ip_prefix_length": 30}},
"description": "/30 IP",
"quantity": 1,
"unit_price": 200,
"line_total": 200,
},
]
if "ic.subscription_id = %s" in query:
return []
if "ic.allocation_model = 'shared'" in query:
return [
{
"id": 88,
"parent_id": None,
"name": "BMC Networks · Albertslund",
"provider": "GlobalConnect",
"customer_id": 1662,
"customer_name": "BMC Networks",
"parent_name": None,
"address": "Rydagervej 27, 2620 Albertslund",
"status": "active",
"monthly_cost": 0,
"sales_price": 0,
"margin_amount": 0,
"technology": "Fiber",
"connection_type": "fiber",
"circuit_number": "NKA020900",
"speed_mbps": 1000,
"upload_mbps": 1000,
"download_mbps": 1000,
"monitoring_url": None,
"contract_start": None,
"contract_end": None,
"allocation_model": "shared",
"value_type": "bmc_networks",
"value_label": None,
"subscription_id": None,
"subscription_number": None,
"subscription_product_name": None,
"subscription_customer_name": None,
"ip_range_count": 1,
"total_ip_addresses": 2,
"in_use_ip_addresses": 0,
"reserved_ip_addresses": 0,
"available_ip_addresses": 2,
}
]
if "FROM internet_connections_ip_ranges ir" in query and "GROUP BY ir.id, c.name" in query:
return [
{
"id": 301,
"connection_id": 88,
"name": "87.116.1.200/30",
"cidr": "87.116.1.200/30",
"description": None,
"provider_reference": "NKA020900",
"contract_number": None,
"customer_id": None,
"service_address": "Rydagervej 27, 2620 Albertslund",
"monthly_cost": 0,
"sales_price": 0,
"customer_name": None,
"total_addresses": 2,
"available_addresses": 2,
"reserved_addresses": 0,
"in_use_addresses": 0,
}
]
return []
monkeypatch.setattr(internet_router, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
client = TestClient(app)
response = client.get("/api/v1/internet-connections/subscriptions/55/provisioning")
assert response.status_code == 200
payload = response.json()
assert payload["network_provisioning"]["requires_provisioning"] is True
assert payload["shared_heads"][0]["address"] == "Rydagervej 27, 2620 Albertslund"
assert payload["shared_heads"][0]["available_matching_ranges"][0]["cidr"] == "87.116.1.200/30"
def test_subscription_provisioning_endpoint_can_offer_derived_subrange(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query_single(query, params=None):
if "FROM sag_subscriptions s" in query:
return {
"id": 56,
"subscription_number": "SUB-56",
"sag_id": 7,
"customer_id": 21,
"customer_name": "Eksempel Kunde",
"product_name": "BMCnet 500/500 + /28",
"billing_interval": "monthly",
"price": 699,
"status": "draft",
"start_date": "2026-07-01",
}
return None
def fake_execute_query(query, params=None):
if "FROM sag_subscription_items i" in query:
return [
{
"id": 301,
"line_no": 1,
"product_id": 701,
"product_name": "BMCnet 500/500",
"product_type": "service",
"attributes_json": {"network": {"kind": "internet_access", "download_mbps": 500, "upload_mbps": 500}},
"description": "BMCnet 500/500",
"quantity": 1,
"unit_price": 499,
"line_total": 499,
},
{
"id": 302,
"line_no": 2,
"product_id": 702,
"product_name": "/28 IP",
"product_type": "service",
"attributes_json": {"network": {"kind": "ip_allocation", "ip_prefix_length": 28}},
"description": "/28 IP",
"quantity": 1,
"unit_price": 200,
"line_total": 200,
},
]
if "ic.subscription_id = %s" in query:
return []
if "ic.allocation_model = 'shared'" in query:
return [
{
"id": 188,
"parent_id": None,
"name": "BMC Networks · Albertslund",
"provider": "GlobalConnect",
"customer_id": 1662,
"customer_name": "BMC Networks",
"parent_name": None,
"address": "Rydagervej 27, 2620 Albertslund",
"status": "active",
"monthly_cost": 0,
"sales_price": 0,
"margin_amount": 0,
"technology": "Fiber",
"connection_type": "fiber",
"circuit_number": "NKA020900",
"speed_mbps": 1000,
"upload_mbps": 1000,
"download_mbps": 1000,
"monitoring_url": None,
"contract_start": None,
"contract_end": None,
"allocation_model": "shared",
"value_type": "bmc_networks",
"value_label": None,
"subscription_id": None,
"subscription_number": None,
"subscription_product_name": None,
"subscription_customer_name": None,
"ip_range_count": 1,
"total_ip_addresses": 62,
"in_use_ip_addresses": 0,
"reserved_ip_addresses": 0,
"available_ip_addresses": 62,
}
]
if "FROM internet_connections_ip_ranges ir" in query and "GROUP BY ir.id, c.name" in query:
return [
{
"id": 401,
"connection_id": 188,
"name": "83.136.94.128/26",
"cidr": "83.136.94.128/26",
"description": None,
"provider_reference": "NKA021047",
"contract_number": None,
"customer_id": None,
"service_address": "Herstedvang 14, 2620 Albertslund",
"monthly_cost": 0,
"sales_price": 0,
"customer_name": None,
"total_addresses": 62,
"available_addresses": 62,
"reserved_addresses": 0,
"in_use_addresses": 0,
}
]
return []
monkeypatch.setattr(internet_router, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
client = TestClient(app)
response = client.get("/api/v1/internet-connections/subscriptions/56/provisioning")
assert response.status_code == 200
payload = response.json()
assert payload["shared_heads"][0]["available_matching_range_count"] == 4
assert payload["shared_heads"][0]["available_matching_ranges"][0]["requested_cidr"].endswith("/28")
assert payload["shared_heads"][0]["available_matching_ranges"][0]["is_derived_candidate"] is True
def test_subscription_provisioning_requires_ip_selection_for_each_ip_product(monkeypatch):
from app.modules.internet_connections.backend import router as internet_router
def fake_execute_query_single(query, params=None):
if "FROM sag_subscriptions s" in query:
return {
"id": 66,
"subscription_number": "SUB-66",
"sag_id": 8,
"customer_id": 77,
"customer_name": "Kunde",
"product_name": "BMCnet 100/100",
"billing_interval": "monthly",
"price": 799,
"status": "draft",
"start_date": "2026-07-01",
}
if "AND ic.id = %s AND ic.allocation_model = 'shared'" in query:
return {
"id": 90,
"parent_id": None,
"name": "BMC Networks · Shared",
"provider": "GlobalConnect",
"customer_id": 1662,
"customer_name": "BMC Networks",
"parent_name": None,
"address": "Adresse 1",
"status": "active",
"monthly_cost": 0,
"sales_price": 0,
"margin_amount": 0,
"technology": "Fiber",
"connection_type": "fiber",
"circuit_number": "NKA1",
"speed_mbps": 1000,
"upload_mbps": 1000,
"download_mbps": 1000,
"monitoring_url": None,
"contract_start": None,
"contract_end": None,
"allocation_model": "shared",
"value_type": "bmc_networks",
"value_label": None,
"subscription_id": None,
"subscription_number": None,
"subscription_product_name": None,
"subscription_customer_name": None,
"ip_range_count": 1,
"total_ip_addresses": 2,
"in_use_ip_addresses": 0,
"reserved_ip_addresses": 0,
"available_ip_addresses": 2,
}
return None
def fake_execute_query(query, params=None):
if "FROM sag_subscription_items i" in query:
return [
{
"id": 210,
"line_no": 1,
"product_id": 610,
"product_name": "BMCnet 100/100",
"product_type": "service",
"attributes_json": {"network": {"kind": "internet_access", "download_mbps": 100, "upload_mbps": 100}},
"description": "BMCnet 100/100",
"quantity": 1,
"unit_price": 499,
"line_total": 499,
},
{
"id": 211,
"line_no": 2,
"product_id": 611,
"product_name": "/30 IP",
"product_type": "service",
"attributes_json": {"network": {"kind": "ip_allocation", "ip_prefix_length": 30}},
"description": "/30 IP",
"quantity": 1,
"unit_price": 300,
"line_total": 300,
},
]
if "ic.subscription_id = %s" in query:
return []
return []
monkeypatch.setattr(internet_router, "execute_query_single", fake_execute_query_single)
monkeypatch.setattr(internet_router, "execute_query", fake_execute_query)
client = TestClient(app)
response = client.post(
"/api/v1/internet-connections/subscriptions/66/provision",
json={"shared_connection_id": 90, "internet_item_id": 210, "ip_allocations": []},
)
assert response.status_code == 409
assert "IP-range" in response.json()["detail"]

View File

@ -190,6 +190,20 @@ if podman ps -a --format '{{.Names}} {{.Ports}}' | grep -E "${POSTGRES_BIND_ADDR
done done
fi fi
# Final raw socket guard: rootlessport can survive in a state where podman metadata
# does not clearly show the holder yet the host port is still bound.
if command -v ss >/dev/null 2>&1; then
if ss -ltn | awk '{print $4}' | grep -E "(^|:)${POSTGRES_PORT}$" >/dev/null 2>&1; then
echo "❌ Fejl: ${POSTGRES_BIND_ADDR}:${POSTGRES_PORT} lytter stadig paa hosten."
echo " Det er typisk en gammel rootlessport-proxy eller en anden service."
echo " Tjek med:"
echo " ss -ltnp | grep :${POSTGRES_PORT}"
echo " podman ps -a --format 'table {{.Names}}\\t{{.Status}}\\t{{.Ports}}'"
echo " Loesning: stop/fjern holderen eller saet en anden POSTGRES_PORT i .env."
exit 1
fi
fi
# Stop containers # Stop containers
echo "" echo ""
echo "⏹️ Stopper containere..." echo "⏹️ Stopper containere..."