bmc_hub/app/modules/internet_connections/templates/migration_wizard_v2.html
Christian 3311b8e590 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.
2026-07-09 23:44:30 +02:00

558 lines
20 KiB
HTML

{% 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 %}