@@ -1878,6 +2095,13 @@ document.addEventListener('DOMContentLoaded', () => {
}, { 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
const hardwareTab = document.querySelector('a[href="#hardware"]');
if (hardwareTab) {
@@ -5642,6 +5866,204 @@ function formatShortDate(value) {
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 = `
${escapeHtml(error.message || 'Ukendt fejl')}
`;
+ }
+}
+
+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
+ ? `
Ligger under ${escapeHtml(item.parent_name)}
`
+ : '';
+
+ const monitoring = item.monitoring_url
+ ? `
Overvågning`
+ : '';
+
+ return `
+
+
+
+
+
${escapeHtml(item.name || '-')}
+ ${escapeHtml(item.status || '-')}
+
+
${escapeHtml(item.provider || 'Ingen leverandør')} · ${escapeHtml(item.connection_type || item.technology || 'Forbindelse')}
+ ${parentLabel}
+
+
+
+
+
+ `;
+ }).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() {
const commentText = document.getElementById('commentText').textContent;
const commentInput = document.getElementById('internalCommentInput');
diff --git a/app/modules/internet_connections/__init__.py b/app/modules/internet_connections/__init__.py
new file mode 100644
index 0000000..fd54587
--- /dev/null
+++ b/app/modules/internet_connections/__init__.py
@@ -0,0 +1 @@
+"""Internet connections module package."""
diff --git a/app/modules/internet_connections/backend/provisioning_utils.py b/app/modules/internet_connections/backend/provisioning_utils.py
new file mode 100644
index 0000000..3c43e8d
--- /dev/null
+++ b/app/modules/internet_connections/backend/provisioning_utils.py
@@ -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],
+ }
diff --git a/app/modules/internet_connections/backend/router.py b/app/modules/internet_connections/backend/router.py
new file mode 100644
index 0000000..1581408
--- /dev/null
+++ b/app/modules/internet_connections/backend/router.py
@@ -0,0 +1,3122 @@
+import ipaddress
+import logging
+import re
+from datetime import date
+from pathlib import Path
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, File, Form, HTTPException, Query, UploadFile
+from pydantic import BaseModel
+
+from app.core.config import settings
+from app.core.database import (
+ execute_insert,
+ execute_query,
+ execute_query_single,
+ get_db_connection,
+ release_db_connection,
+ table_has_column,
+)
+from psycopg2.extras import Json, RealDictCursor
+from app.modules.internet_connections.backend.provisioning_utils import (
+ build_network_product_profile,
+ summarize_subscription_network_requirements,
+)
+from app.services.ollama_service import ollama_service
+
+logger = logging.getLogger(__name__)
+router = APIRouter()
+
+ALLOWED_ALLOCATION_MODELS = {"dedicated", "shared"}
+ALLOWED_VALUE_TYPES = {"subscription", "bmc_networks", "delefiber", "other"}
+INTERNET_CUSTOMER_DOC_EXTENSIONS = {".txt", ".csv", ".log", ".md"}
+INTERNET_RESEARCH_KEYWORDS = (
+ "internet",
+ "bredbaand",
+ "bredbånd",
+ "broadband",
+ "fiber",
+ "wan",
+ "mpls",
+ "vpn",
+ "ip",
+ "ipv4",
+ "ipv6",
+ "ip-adresse",
+ "globalconnect",
+ "global connect",
+ "sentia",
+ "dsl",
+)
+REFERENCE_PATTERNS = [
+ r"\bNKA[- ]?\d{4,}\b",
+ r"\bDSL[- ]?[A-Z0-9]+\b",
+ r"\bEB\d{4,}\b",
+ r"\bHB\d{4,}\b",
+ r"\bMPLS\b",
+]
+SOCKET_PATTERNS = [
+ r"\bstik(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
+ r"\budtag(?:\s*nr|\s*nummer)?[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
+ r"\bport[:#\s-]*([A-Za-z0-9./_-]{1,40})\b",
+]
+GENERIC_SEGMENT_TITLE_PATTERNS = (
+ "ip informationer",
+ "ip information",
+ "host is up",
+ "not in use",
+ "untagged native management",
+)
+
+
+def _internet_customer_doc_dir() -> Path:
+ base = Path(settings.UPLOAD_DIR).resolve()
+ target = base / "internet_customer_docs"
+ target.mkdir(parents=True, exist_ok=True)
+ return target
+
+
+def _sanitize_upload_name(filename: str) -> str:
+ cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", str(filename or "").strip())
+ cleaned = cleaned.strip("._")
+ return cleaned or "upload.txt"
+
+
+def _normalize_text_for_match(value: Optional[str]) -> str:
+ text = str(value or "").strip().lower()
+ return (
+ text.replace("æ", "ae")
+ .replace("ø", "oe")
+ .replace("å", "aa")
+ .replace("\n", " ")
+ .replace("\r", " ")
+ )
+
+
+def _tokenize_search_text(*parts: Optional[str]) -> List[str]:
+ combined = " ".join(str(part or "") for part in parts).strip().lower()
+ normalized = _normalize_text_for_match(combined)
+ tokens = []
+ for token in re.split(r"[^a-z0-9./+-]+", normalized):
+ token = token.strip()
+ if len(token) >= 2 and token not in tokens:
+ tokens.append(token)
+ return tokens
+
+
+def _build_research_terms(customer_name: str, query: Optional[str]) -> Dict[str, List[str]]:
+ customer_terms = [token for token in _tokenize_search_text(customer_name) if len(token) >= 3]
+ query_terms = [token for token in _tokenize_search_text(query) if len(token) >= 2]
+ internet_terms = list(INTERNET_RESEARCH_KEYWORDS)
+ combined = []
+ for term in customer_terms + query_terms + internet_terms:
+ if term not in combined:
+ combined.append(term)
+ return {
+ "customer_terms": customer_terms,
+ "query_terms": query_terms,
+ "internet_terms": internet_terms,
+ "all_terms": combined,
+ }
+
+
+def _count_term_hits(text: str, terms: List[str]) -> int:
+ normalized = _normalize_text_for_match(text)
+ return sum(1 for term in terms if term and term in normalized)
+
+
+def _extract_text_snippets(text: str, terms: List[str], limit: int = 4) -> List[str]:
+ normalized_terms = [term for term in (_normalize_text_for_match(term) for term in terms) if term]
+ if not text.strip():
+ return []
+
+ snippets: List[str] = []
+ seen = set()
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
+ for idx, line in enumerate(lines):
+ line_normalized = _normalize_text_for_match(line)
+ score = sum(1 for term in normalized_terms if term in line_normalized)
+ if score <= 0:
+ continue
+ start = max(idx - 1, 0)
+ end = min(idx + 2, len(lines))
+ snippet = " | ".join(lines[start:end])
+ snippet = re.sub(r"\s+", " ", snippet).strip()
+ if snippet and snippet not in seen:
+ seen.add(snippet)
+ snippets.append((score, snippet))
+
+ if not snippets and lines:
+ fallback = " | ".join(lines[: min(3, len(lines))])
+ return [re.sub(r"\s+", " ", fallback).strip()]
+
+ snippets.sort(key=lambda item: (-item[0], len(item[1])))
+ return [snippet for _, snippet in snippets[:limit]]
+
+
+def _is_generic_segment_title(title: Optional[str]) -> bool:
+ normalized_title = _normalize_text_for_match(title)
+ if not normalized_title:
+ return False
+ return any(pattern in normalized_title for pattern in GENERIC_SEGMENT_TITLE_PATTERNS)
+
+
+def _split_text_into_blocks(text: str) -> List[str]:
+ if not str(text or "").strip():
+ return []
+ raw_blocks = re.split(r"\n\s*\n+", str(text))
+ blocks: List[str] = []
+ for block in raw_blocks:
+ cleaned = re.sub(r"\s+\n", "\n", block).strip()
+ cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
+ if not cleaned:
+ continue
+ if len(cleaned) > 1800:
+ lines = [line.strip() for line in cleaned.splitlines() if line.strip()]
+ chunk: List[str] = []
+ chunk_len = 0
+ for line in lines:
+ if chunk and chunk_len + len(line) > 1200:
+ blocks.append("\n".join(chunk))
+ chunk = [line]
+ chunk_len = len(line)
+ else:
+ chunk.append(line)
+ chunk_len += len(line)
+ if chunk:
+ blocks.append("\n".join(chunk))
+ else:
+ blocks.append(cleaned)
+ return blocks
+
+
+def _extract_segment_entities(block: str) -> Dict[str, List[str]]:
+ cidr_blocks = []
+ ip_addresses = []
+ references = []
+ socket_numbers = []
+
+ for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block):
+ normalized = raw.strip()
+ if normalized not in cidr_blocks:
+ cidr_blocks.append(normalized)
+
+ for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", block):
+ normalized = raw.strip()
+ if any(normalized == cidr.split("/")[0] for cidr in cidr_blocks):
+ continue
+ try:
+ normalized_ip = _normalize_ip_address(normalized)
+ except HTTPException:
+ continue
+ if normalized_ip not in ip_addresses:
+ ip_addresses.append(normalized_ip)
+
+ for pattern in REFERENCE_PATTERNS:
+ for match in re.findall(pattern, block, flags=re.IGNORECASE):
+ normalized = re.sub(r"\s+", "", str(match).strip()).upper()
+ if normalized not in references:
+ references.append(normalized)
+
+ for pattern in SOCKET_PATTERNS:
+ for match in re.findall(pattern, block, flags=re.IGNORECASE):
+ normalized = str(match).strip()
+ if normalized and normalized not in socket_numbers:
+ socket_numbers.append(normalized)
+
+ return {
+ "ip_addresses": ip_addresses,
+ "cidr_blocks": cidr_blocks,
+ "references": references,
+ "socket_numbers": socket_numbers,
+ }
+
+
+def _segment_title(block: str, block_index: int) -> str:
+ first_line = next((line.strip() for line in block.splitlines() if line.strip()), "")
+ return (first_line[:120] if first_line else f"Blok {block_index + 1}") or f"Blok {block_index + 1}"
+
+
+def _index_customer_document_segments(document_id: int, extracted_text: str) -> int:
+ execute_query(
+ """
+ DELETE FROM internet_connections_customer_document_segments
+ WHERE document_id = %s
+ """,
+ (document_id,),
+ )
+ created = 0
+ for idx, block in enumerate(_split_text_into_blocks(extracted_text)):
+ entities = _extract_segment_entities(block)
+ execute_query(
+ """
+ INSERT INTO internet_connections_customer_document_segments (
+ document_id,
+ block_index,
+ block_title,
+ content,
+ ip_addresses,
+ cidr_blocks,
+ references_json,
+ socket_numbers
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ """,
+ (
+ document_id,
+ idx,
+ _segment_title(block, idx),
+ block,
+ Json(entities["ip_addresses"]),
+ Json(entities["cidr_blocks"]),
+ Json(entities["references"]),
+ Json(entities["socket_numbers"]),
+ ),
+ )
+ created += 1
+ return created
+
+
+def _get_document_segment_count(document_id: int) -> int:
+ row = execute_query_single(
+ """
+ SELECT COUNT(*) AS total
+ FROM internet_connections_customer_document_segments
+ WHERE document_id = %s
+ """,
+ (document_id,),
+ )
+ return int((row or {}).get("total") or 0)
+
+
+def _ensure_document_segments(document_id: int, extracted_text: Optional[str]) -> int:
+ existing_count = _get_document_segment_count(document_id)
+ if existing_count > 0:
+ return existing_count
+ if not str(extracted_text or "").strip():
+ return 0
+ try:
+ return _index_customer_document_segments(document_id, str(extracted_text or ""))
+ except Exception as exc:
+ logger.warning("Could not auto-index document segments for document %s: %s", document_id, exc)
+ return 0
+
+
+async def _read_invoice_text(file_path: Optional[str]) -> str:
+ if not file_path:
+ return ""
+ candidate = Path(file_path)
+ if not candidate.is_absolute():
+ candidate = Path(settings.UPLOAD_DIR).resolve() / candidate
+ if not candidate.exists() or not candidate.is_file():
+ return ""
+ try:
+ return await ollama_service._extract_text_from_file(candidate)
+ except Exception as exc:
+ logger.warning("Could not extract invoice text from %s: %s", candidate, exc)
+ return ""
+
+
+async def _build_invoice_hits(customer_id: int, customer_name: str, query: Optional[str], limit: int = 8) -> List[Dict[str, Any]]:
+ terms = _build_research_terms(customer_name, query)
+ customer_filter_sql = "NULL::INTEGER AS linked_customer_id"
+ customer_where_sql = ""
+ params: List[Any] = []
+ if table_has_column("supplier_invoices", "linked_customer_id"):
+ customer_filter_sql = "si.linked_customer_id"
+ customer_where_sql = "OR si.linked_customer_id = %s"
+ params.append(customer_id)
+
+ invoice_rows = execute_query(
+ f"""
+ SELECT
+ si.id,
+ si.invoice_number,
+ si.invoice_date,
+ si.total_amount,
+ si.currency,
+ COALESCE(v.name, si.vendor_name, 'Ukendt leverandør') AS vendor_name,
+ COALESCE(si.description, '') AS description,
+ COALESCE(si.notes, '') AS notes,
+ COALESCE(inf.file_path, si.file_path) AS file_path,
+ COALESCE(inf.original_filename, si.file_path, '') AS source_filename,
+ {customer_filter_sql}
+ FROM supplier_invoices si
+ LEFT JOIN vendors v ON v.id = si.vendor_id
+ LEFT JOIN extractions ext ON ext.extraction_id = si.extraction_id
+ LEFT JOIN incoming_files inf ON inf.file_id = ext.file_id
+ WHERE si.invoice_date >= (CURRENT_DATE - INTERVAL '18 months')
+ AND (
+ COALESCE(si.description, '') <> ''
+ OR COALESCE(si.notes, '') <> ''
+ OR COALESCE(si.file_path, '') <> ''
+ OR COALESCE(inf.file_path, '') <> ''
+ {customer_where_sql}
+ )
+ ORDER BY si.invoice_date DESC, si.id DESC
+ LIMIT 30
+ """,
+ tuple(params) if params else None,
+ ) or []
+
+ hits: List[Dict[str, Any]] = []
+ for row in invoice_rows:
+ extracted_text = await _read_invoice_text(row.get("file_path"))
+ combined_text = "\n".join(
+ part for part in [row.get("description"), row.get("notes"), extracted_text] if str(part or "").strip()
+ )
+ searchable_text = " ".join(
+ str(part or "") for part in [row.get("vendor_name"), row.get("invoice_number"), combined_text]
+ )
+ customer_hits = _count_term_hits(searchable_text, terms["customer_terms"])
+ query_hits = _count_term_hits(searchable_text, terms["query_terms"])
+ internet_hits = _count_term_hits(searchable_text, terms["internet_terms"])
+ directly_linked = row.get("linked_customer_id") == customer_id
+ if not directly_linked and customer_hits == 0 and query_hits == 0:
+ continue
+ if internet_hits == 0:
+ continue
+
+ snippets = _extract_text_snippets(
+ combined_text or searchable_text,
+ terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"],
+ limit=2,
+ )
+ score = (6 if directly_linked else 0) + (customer_hits * 3) + (query_hits * 4) + internet_hits
+ hits.append(
+ {
+ "invoice_id": row.get("id"),
+ "invoice_number": row.get("invoice_number"),
+ "invoice_date": row.get("invoice_date").isoformat() if row.get("invoice_date") else None,
+ "vendor_name": row.get("vendor_name"),
+ "total_amount": float(row.get("total_amount") or 0),
+ "currency": row.get("currency") or "DKK",
+ "source_filename": row.get("source_filename"),
+ "directly_linked": directly_linked,
+ "score": score,
+ "snippets": snippets,
+ }
+ )
+
+ hits.sort(key=lambda item: (-item["score"], item.get("invoice_date") or ""))
+ return hits[:limit]
+
+
+async def _build_customer_document_hits(customer_id: int, customer_name: str, query: Optional[str]) -> Dict[str, Any]:
+ document_rows = execute_query(
+ """
+ SELECT
+ id,
+ customer_id,
+ connection_id,
+ original_filename,
+ filename,
+ file_size,
+ mime_type,
+ extracted_text,
+ notes,
+ created_at
+ FROM internet_connections_customer_documents
+ WHERE (customer_id = %s OR customer_id IS NULL)
+ AND deleted_at IS NULL
+ ORDER BY created_at DESC, id DESC
+ """,
+ (customer_id,),
+ ) or []
+
+ terms = _build_research_terms(customer_name, query)
+ documents = []
+ snippets: List[str] = []
+ matched_segments: List[Dict[str, Any]] = []
+ doc_by_id: Dict[int, Dict[str, Any]] = {}
+ for row in document_rows:
+ doc_id = int(row["id"])
+ row_dict = dict(row)
+ row_dict["segment_count"] = _ensure_document_segments(doc_id, row_dict.get("extracted_text"))
+ doc_by_id[doc_id] = row_dict
+
+ segment_rows = execute_query(
+ """
+ SELECT
+ seg.id,
+ seg.document_id,
+ seg.block_index,
+ seg.block_title,
+ seg.content,
+ seg.ip_addresses,
+ seg.cidr_blocks,
+ seg.references_json,
+ seg.socket_numbers
+ FROM internet_connections_customer_document_segments seg
+ JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
+ WHERE doc.deleted_at IS NULL
+ AND (doc.customer_id = %s OR doc.customer_id IS NULL)
+ ORDER BY doc.created_at DESC, seg.block_index ASC
+ """,
+ (customer_id,),
+ ) or []
+
+ has_query = bool(terms["query_terms"])
+ normalized_query = _normalize_text_for_match(query) if query else ""
+ match_terms = terms["query_terms"] or terms["customer_terms"] or terms["internet_terms"]
+ doc_segment_count: Dict[int, int] = {}
+ for row in segment_rows:
+ content = str(row.get("content") or "")
+ title = str(row.get("block_title") or "")
+ references = [str(item) for item in (row.get("references_json") or [])]
+ sockets = [str(item) for item in (row.get("socket_numbers") or [])]
+ cidrs = [str(item) for item in (row.get("cidr_blocks") or [])]
+ ips = [str(item) for item in (row.get("ip_addresses") or [])]
+ searchable = " ".join([title, content, " ".join(references), " ".join(sockets), " ".join(cidrs), " ".join(ips)])
+ customer_hits = _count_term_hits(searchable, terms["customer_terms"])
+ query_hits = _count_term_hits(searchable, terms["query_terms"])
+ internet_hits = _count_term_hits(searchable, terms["internet_terms"])
+ explicit_entity_hit = 0
+ phrase_hit = 0
+ title_hit = 0
+ if terms["query_terms"]:
+ explicit_entity_hit = sum(
+ 1 for token in terms["query_terms"]
+ if token in _normalize_text_for_match(" ".join(ips + cidrs + references + sockets))
+ )
+ phrase_hit = 1 if normalized_query and normalized_query in _normalize_text_for_match(searchable) else 0
+ title_hit = _count_term_hits(title, terms["query_terms"])
+
+ if has_query:
+ if query_hits == 0 and explicit_entity_hit == 0 and phrase_hit == 0 and title_hit == 0:
+ continue
+ elif customer_hits == 0 and query_hits == 0 and explicit_entity_hit == 0:
+ continue
+
+ score = (customer_hits * 2) + (query_hits * 8) + internet_hits + (explicit_entity_hit * 10) + (phrase_hit * 12) + (title_hit * 6)
+ if _is_generic_segment_title(title) and explicit_entity_hit == 0 and phrase_hit == 0:
+ score -= 6
+ if has_query and query_hits > 0 and customer_hits == 0:
+ score += 4
+ if score <= 0:
+ continue
+
+ doc_id = int(row["document_id"])
+ doc_segment_count[doc_id] = doc_segment_count.get(doc_id, 0) + 1
+ segment_snippets = _extract_text_snippets(content, match_terms, limit=1)
+ snippet = segment_snippets[0] if segment_snippets else re.sub(r"\s+", " ", content[:240]).strip()
+ matched_segments.append(
+ {
+ "segment_id": int(row["id"]),
+ "document_id": doc_id,
+ "block_index": int(row.get("block_index") or 0),
+ "title": title or f"Blok {int(row.get('block_index') or 0) + 1}",
+ "score": score,
+ "snippet": snippet,
+ "ip_addresses": ips,
+ "cidr_blocks": cidrs,
+ "references": references,
+ "socket_numbers": sockets,
+ }
+ )
+ snippets.append(f"{doc_by_id.get(doc_id, {}).get('original_filename')}: {snippet}")
+
+ matched_segments.sort(key=lambda item: (-item["score"], item["document_id"], item["block_index"]))
+
+ for row in document_rows:
+ doc_id = int(row["id"])
+ row_dict = doc_by_id.get(doc_id, dict(row))
+ text = str(row_dict.get("extracted_text") or "")
+ doc_snippets = [seg["snippet"] for seg in matched_segments if seg["document_id"] == doc_id][:2]
+ matched_count = doc_segment_count.get(doc_id, 0)
+ if not doc_snippets and not has_query:
+ doc_snippets = _extract_text_snippets(text, match_terms, limit=2)
+ if has_query and matched_count <= 0:
+ continue
+ if matched_count <= 0 and row_dict.get("customer_id") != customer_id:
+ continue
+ documents.append(
+ {
+ "id": doc_id,
+ "connection_id": row_dict.get("connection_id"),
+ "original_filename": row_dict.get("original_filename"),
+ "file_size": row_dict.get("file_size"),
+ "mime_type": row_dict.get("mime_type"),
+ "notes": row_dict.get("notes"),
+ "created_at": row_dict.get("created_at").isoformat() if row_dict.get("created_at") else None,
+ "snippet_count": matched_count if has_query else doc_segment_count.get(doc_id, row_dict.get("segment_count", len(doc_snippets))),
+ "snippets": doc_snippets[:2],
+ }
+ )
+
+ return {"documents": documents, "snippets": snippets[:12], "segments": matched_segments[:16]}
+
+
+def _validate_cidr(cidr: str):
+ try:
+ return ipaddress.ip_network(cidr, strict=False)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid CIDR notation") from exc
+
+
+def _normalize_ip_address(ip_address: str) -> str:
+ try:
+ return str(ipaddress.ip_address(str(ip_address or "").strip()))
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid IP address") from exc
+
+
+def _normalize_match_text(value: Optional[str]) -> str:
+ normalized = str(value or "").strip().upper()
+ normalized = normalized.replace("Æ", "AE").replace("Ø", "OE").replace("Å", "AA")
+ return "".join(ch for ch in normalized if ch.isalnum())
+
+
+def _build_ip_range_payload(range_row: dict, address_rows: Optional[list[dict]] = None) -> dict:
+ payload = dict(range_row)
+ network = None
+ cidr = range_row.get("cidr")
+ if cidr:
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError:
+ network = None
+
+ payload["network_address"] = str(network.network_address) if network else None
+ payload["broadcast_address"] = str(network.broadcast_address) if network else None
+ payload["prefix_length"] = int(network.prefixlen) if network else None
+ payload["total_hosts"] = int(network.num_addresses) if network else 0
+
+ if network and network.num_addresses > 1:
+ usable_hosts = max(network.num_addresses - 2, 0)
+ elif network:
+ usable_hosts = 1 if network.num_addresses == 1 else 0
+ else:
+ usable_hosts = 0
+
+ payload["usable_hosts"] = usable_hosts
+ range_addresses = address_rows or []
+ payload["used_addresses"] = sum(
+ 1 for row in range_addresses if str(row.get("status", "available")).lower() != "available"
+ )
+ payload["available_addresses"] = max(usable_hosts - payload["used_addresses"], 0)
+ return payload
+
+
+def _range_alignment_for_connection(connection: Dict[str, Any], range_item: Dict[str, Any]) -> Dict[str, Any]:
+ connection_address = _normalize_match_text(connection.get("address"))
+ service_address = _normalize_match_text(range_item.get("service_address"))
+ connection_customer_id = connection.get("customer_id")
+ range_customer_id = range_item.get("customer_id")
+ is_shared_head = bool(connection.get("allocation_model") == "shared" and not connection.get("parent_id"))
+
+ if is_shared_head:
+ return {"belongs_to_connection": True, "alignment_reason": "shared_head", "alignment_warning": None}
+ if connection_address and service_address and connection_address == service_address:
+ return {"belongs_to_connection": True, "alignment_reason": "service_address", "alignment_warning": None}
+ if connection_customer_id and range_customer_id and int(connection_customer_id) == int(range_customer_id):
+ return {"belongs_to_connection": True, "alignment_reason": "customer", "alignment_warning": None}
+ if not service_address and not range_customer_id:
+ return {"belongs_to_connection": True, "alignment_reason": "unassigned", "alignment_warning": None}
+
+ warning = "Range matcher ikke forbindelsens adresse/kunde"
+ if range_item.get("service_address"):
+ warning = f"Range ligger paa anden serviceadresse: {range_item.get('service_address')}"
+ elif range_item.get("customer_name"):
+ warning = f"Range er bundet til anden kunde: {range_item.get('customer_name')}"
+ return {"belongs_to_connection": False, "alignment_reason": "mismatch", "alignment_warning": warning}
+
+
+def _create_history_entry(connection_id: int, event_type: str, summary: str, details: Optional[dict] = None):
+ execute_query(
+ """
+ INSERT INTO internet_connections_history (connection_id, event_type, summary, details)
+ VALUES (%s, %s, %s, %s)
+ """,
+ (connection_id, event_type, summary, Json(details or {})),
+ )
+
+
+def _create_ip_addresses_for_range(range_id: int, cidr: str):
+ network = _validate_cidr(cidr)
+ addresses = []
+ for host in list(network.hosts()):
+ addresses.append(str(host))
+
+ if not addresses:
+ return []
+
+ created = []
+ for ip_address in addresses:
+ existing = execute_query(
+ """
+ SELECT *
+ FROM internet_connections_ip_addresses
+ WHERE ip_address = %s
+ AND deleted_at IS NULL
+ LIMIT 1
+ """,
+ (ip_address,),
+ ) or []
+ if existing:
+ continue
+ rows = execute_query(
+ """
+ INSERT INTO internet_connections_ip_addresses (range_id, ip_address, status, assigned_to, assigned_type, comment)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (range_id, ip_address, "available", None, None, "Auto-generated from CIDR"),
+ ) or []
+ if rows:
+ created.append(dict(rows[0]))
+ return created
+
+
+def _get_contract_status(contract_end):
+ if not contract_end:
+ return "unknown"
+ if isinstance(contract_end, str):
+ try:
+ contract_end = date.fromisoformat(contract_end)
+ except ValueError:
+ return "unknown"
+ elif hasattr(contract_end, "date"):
+ contract_end = contract_end.date()
+ return "active" if contract_end >= date.today() else "expired"
+
+
+def _value_type_label(value_type: Optional[str]) -> str:
+ labels = {
+ "subscription": "Abonnement",
+ "bmc_networks": "BMC Networks",
+ "delefiber": "Delefiber",
+ "other": "Anden",
+ }
+ return labels.get(str(value_type or "").lower(), "Anden")
+
+
+def _allocation_model_label(allocation_model: Optional[str]) -> str:
+ labels = {
+ "dedicated": "Dedikeret",
+ "shared": "Delt",
+ }
+ return labels.get(str(allocation_model or "").lower(), "Dedikeret")
+
+
+def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
+ normalized = dict(payload)
+ address = str(normalized.get("address") or "").strip() or None
+ allocation_model = str(normalized.get("allocation_model") or "dedicated").strip().lower()
+ value_type = str(normalized.get("value_type") or "other").strip().lower()
+ value_label = str(normalized.get("value_label") or "").strip() or None
+ subscription_id = normalized.get("subscription_id")
+
+ if not address:
+ raise HTTPException(status_code=400, detail="address is required for internet connections")
+
+ if allocation_model not in ALLOWED_ALLOCATION_MODELS:
+ raise HTTPException(status_code=400, detail="allocation_model must be dedicated or shared")
+ if value_type not in ALLOWED_VALUE_TYPES:
+ raise HTTPException(status_code=400, detail="value_type must be subscription, bmc_networks, delefiber or other")
+
+ if value_type == "subscription":
+ if not subscription_id:
+ raise HTTPException(status_code=400, detail="subscription_id is required when value_type is subscription")
+ else:
+ subscription_id = None
+
+ if value_type == "other":
+ if not value_label:
+ value_label = "Mangler klassifikation"
+ else:
+ value_label = None
+
+ normalized["address"] = address
+ normalized["allocation_model"] = allocation_model
+ normalized["value_type"] = value_type
+ normalized["value_label"] = value_label
+ normalized["subscription_id"] = subscription_id
+ return normalized
+
+
+def _connection_select_sql(where_sql: str = "") -> str:
+ return f"""
+ SELECT
+ ic.id,
+ ic.parent_id,
+ ic.name,
+ ic.provider,
+ ic.customer_id,
+ c.name AS customer_name,
+ parent.name AS parent_name,
+ parent.allocation_model AS parent_allocation_model,
+ parent.value_type AS parent_value_type,
+ ic.address,
+ ic.status,
+ ic.monthly_cost,
+ ic.sales_price,
+ (COALESCE(ic.sales_price, 0) - COALESCE(ic.monthly_cost, 0)) AS margin_amount,
+ ic.technology,
+ ic.connection_type,
+ ic.circuit_number,
+ ic.speed_mbps,
+ ic.upload_mbps,
+ ic.download_mbps,
+ ic.monitoring_url,
+ ic.contract_start,
+ ic.contract_end,
+ ic.allocation_model,
+ ic.value_type,
+ ic.value_label,
+ ic.subscription_id,
+ sub.subscription_number,
+ sub.product_name AS subscription_product_name,
+ subc.name AS subscription_customer_name,
+ COALESCE(ip_stats.range_count, 0) AS ip_range_count,
+ COALESCE(ip_stats.total_addresses, 0) AS total_ip_addresses,
+ COALESCE(ip_stats.in_use_addresses, 0) AS in_use_ip_addresses,
+ COALESCE(ip_stats.reserved_addresses, 0) AS reserved_ip_addresses,
+ COALESCE(ip_stats.available_addresses, 0) AS available_ip_addresses,
+ COALESCE(child_stats.bmcnet_child_count, 0) AS bmcnet_child_count,
+ COALESCE(child_stats.bmcnet_child_active_count, 0) AS bmcnet_child_active_count,
+ COALESCE(child_stats.bmcnet_child_sales_price, 0) AS bmcnet_child_sales_price,
+ COALESCE(child_stats.bmcnet_child_monthly_cost, 0) AS bmcnet_child_monthly_cost,
+ COALESCE(child_stats.bmcnet_child_margin_amount, 0) AS bmcnet_child_margin_amount,
+ COALESCE(child_stats.bmcnet_child_ip_count, 0) AS bmcnet_child_ip_count
+ FROM internet_connections_connections ic
+ LEFT JOIN customers c ON c.id = ic.customer_id
+ LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
+ LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
+ LEFT JOIN customers subc ON subc.id = sub.customer_id
+ LEFT JOIN (
+ SELECT
+ ir.connection_id,
+ COUNT(DISTINCT ir.id) FILTER (WHERE ir.deleted_at IS NULL) AS range_count,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses
+ FROM internet_connections_ip_ranges ir
+ LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
+ WHERE ir.deleted_at IS NULL
+ GROUP BY ir.connection_id
+ ) ip_stats ON ip_stats.connection_id = ic.id
+ LEFT JOIN (
+ SELECT
+ child.parent_id AS connection_id,
+ COUNT(*) FILTER (WHERE child.deleted_at IS NULL AND child.value_type = 'subscription') AS bmcnet_child_count,
+ COUNT(*) FILTER (
+ WHERE child.deleted_at IS NULL
+ AND child.value_type = 'subscription'
+ AND child.status = 'active'
+ ) AS bmcnet_child_active_count,
+ COALESCE(SUM(child.sales_price) FILTER (
+ WHERE child.deleted_at IS NULL
+ AND child.value_type = 'subscription'
+ ), 0) AS bmcnet_child_sales_price,
+ COALESCE(SUM(child.monthly_cost) FILTER (
+ WHERE child.deleted_at IS NULL
+ AND child.value_type = 'subscription'
+ ), 0) AS bmcnet_child_monthly_cost,
+ COALESCE(SUM(COALESCE(child.sales_price, 0) - COALESCE(child.monthly_cost, 0)) FILTER (
+ WHERE child.deleted_at IS NULL
+ AND child.value_type = 'subscription'
+ ), 0) AS bmcnet_child_margin_amount,
+ COALESCE(SUM(
+ COALESCE(child_ip_stats.in_use_addresses, 0) + COALESCE(child_ip_stats.reserved_addresses, 0)
+ ) FILTER (
+ WHERE child.deleted_at IS NULL
+ AND child.value_type = 'subscription'
+ ), 0) AS bmcnet_child_ip_count
+ FROM internet_connections_connections child
+ LEFT JOIN (
+ SELECT
+ ir.connection_id,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses
+ FROM internet_connections_ip_ranges ir
+ LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
+ WHERE ir.deleted_at IS NULL
+ GROUP BY ir.connection_id
+ ) child_ip_stats ON child_ip_stats.connection_id = child.id
+ WHERE child.parent_id IS NOT NULL
+ GROUP BY child.parent_id
+ ) child_stats ON child_stats.connection_id = ic.id
+ WHERE ic.deleted_at IS NULL
+ {where_sql}
+ """
+
+
+def _decorate_connection_row(row: dict) -> dict:
+ item = dict(row)
+ value_label = str(item.get("value_label") or "").strip().lower()
+ is_bmcnet = bool(
+ item.get("parent_id")
+ and str(item.get("parent_allocation_model") or "").lower() == "shared"
+ and (
+ item.get("value_type") == "subscription"
+ or value_label == "bmcnet"
+ or value_label == "bmc networks"
+ )
+ )
+ item["value_type_label"] = _value_type_label(item.get("value_type"))
+ item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
+ item["is_shared_head"] = bool(item.get("allocation_model") == "shared" and not item.get("parent_id"))
+ item["is_bmcnet_connection"] = is_bmcnet
+ return item
+
+
+def _load_bmcnet_children(parent_connection_id: int) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ _connection_select_sql(
+ """
+ AND ic.parent_id = %s
+ AND parent.allocation_model = 'shared'
+ AND (
+ ic.value_type = 'subscription'
+ OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
+ )
+ """
+ )
+ + " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
+ (parent_connection_id,),
+ ) or []
+ return [_decorate_connection_row(dict(row)) for row in rows]
+
+
+def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]:
+ return {
+ "connection_count": len(children),
+ "active_connection_count": sum(1 for child in children if str(child.get("status") or "").lower() == "active"),
+ "sales_price": float(sum(float(child.get("sales_price") or 0) for child in children)),
+ "monthly_cost": float(sum(float(child.get("monthly_cost") or 0) for child in children)),
+ "margin_amount": float(sum(float(child.get("margin_amount") or 0) for child in children)),
+ "ip_in_use_count": int(
+ sum(
+ int(child.get("in_use_ip_addresses") or 0) + int(child.get("reserved_ip_addresses") or 0)
+ for child in children
+ )
+ ),
+ }
+
+
+class ConnectionCreatePayload(BaseModel):
+ name: str
+ provider: Optional[str] = None
+ customer_id: Optional[int] = None
+ parent_id: Optional[int] = None
+ address: Optional[str] = None
+ status: str = "active"
+ monthly_cost: float = 0
+ sales_price: float = 0
+ technology: Optional[str] = None
+ connection_type: str = "fiber"
+ circuit_number: Optional[str] = None
+ speed_mbps: Optional[int] = None
+ upload_mbps: Optional[int] = None
+ download_mbps: Optional[int] = None
+ monitoring_url: Optional[str] = None
+ contract_start: Optional[date] = None
+ contract_end: Optional[date] = None
+ notes: Optional[str] = None
+ allocation_model: str = "dedicated"
+ value_type: str = "other"
+ value_label: Optional[str] = None
+ subscription_id: Optional[int] = None
+
+
+class ConnectionUpdatePayload(BaseModel):
+ name: Optional[str] = None
+ provider: Optional[str] = None
+ customer_id: Optional[int] = None
+ parent_id: Optional[int] = None
+ address: Optional[str] = None
+ status: Optional[str] = None
+ monthly_cost: Optional[float] = None
+ sales_price: Optional[float] = None
+ technology: Optional[str] = None
+ connection_type: Optional[str] = None
+ circuit_number: Optional[str] = None
+ speed_mbps: Optional[int] = None
+ upload_mbps: Optional[int] = None
+ download_mbps: Optional[int] = None
+ monitoring_url: Optional[str] = None
+ contract_start: Optional[date] = None
+ contract_end: Optional[date] = None
+ notes: Optional[str] = None
+ allocation_model: Optional[str] = None
+ value_type: Optional[str] = None
+ value_label: Optional[str] = None
+ subscription_id: Optional[int] = None
+
+
+class PricingCreatePayload(BaseModel):
+ effective_from: date
+ purchase_price: float = 0
+ sales_price: float = 0
+ notes: Optional[str] = None
+
+
+class IpRangeCreatePayload(BaseModel):
+ name: str
+ cidr: str
+ description: Optional[str] = None
+ provider_reference: Optional[str] = None
+ contract_number: Optional[str] = None
+ customer_id: Optional[int] = None
+ service_address: Optional[str] = None
+ monthly_cost: float = 0
+ sales_price: float = 0
+
+
+class IpRangeUpdatePayload(BaseModel):
+ name: Optional[str] = None
+ description: Optional[str] = None
+ provider_reference: Optional[str] = None
+ contract_number: Optional[str] = None
+ customer_id: Optional[int] = None
+ service_address: Optional[str] = None
+ monthly_cost: Optional[float] = None
+ sales_price: Optional[float] = None
+
+
+class IpAddressCreatePayload(BaseModel):
+ range_id: int
+ ip_address: str
+ status: str = "available"
+ assigned_to: Optional[str] = None
+ assigned_type: Optional[str] = None
+ assigned_customer_id: Optional[int] = None
+ assigned_connection_id: Optional[int] = None
+ comment: Optional[str] = None
+
+
+class IpAddressUpdatePayload(BaseModel):
+ status: str = "available"
+ assigned_to: Optional[str] = None
+ assigned_type: Optional[str] = None
+ assigned_customer_id: Optional[int] = None
+ assigned_connection_id: Optional[int] = None
+ comment: Optional[str] = None
+
+
+class SubscriptionIpAllocationPayload(BaseModel):
+ subscription_item_id: int
+ range_id: int
+ requested_cidr: Optional[str] = None
+
+
+class SubscriptionProvisionPayload(BaseModel):
+ shared_connection_id: int
+ internet_item_id: Optional[int] = None
+ ip_allocations: List[SubscriptionIpAllocationPayload] = []
+
+
+class QuickBmcnetCreatePayload(BaseModel):
+ customer_id: int
+ case_title: Optional[str] = None
+ service_label: Optional[str] = None
+ address: Optional[str] = None
+ billing_interval: str = "monthly"
+ billing_day: int = 1
+ start_date: date
+ internet_product_id: int
+ internet_unit_price: Optional[float] = None
+ ip_product_id: Optional[int] = None
+ ip_unit_price: Optional[float] = None
+ notes: Optional[str] = None
+ range_id: Optional[int] = None
+ ip_address_id: Optional[int] = None
+ mark_gateway: bool = False
+
+
+def _load_subscription(subscription_id: int) -> Dict[str, Any]:
+ subscription = execute_query_single(
+ """
+ SELECT
+ s.id,
+ s.subscription_number,
+ s.sag_id,
+ s.customer_id,
+ c.name AS customer_name,
+ s.product_name,
+ s.billing_interval,
+ s.price,
+ s.status,
+ s.start_date
+ FROM sag_subscriptions s
+ 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")
+ return dict(subscription)
+
+
+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
+ 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 _load_subscription_connection(subscription_id: int) -> Optional[Dict[str, Any]]:
+ rows = execute_query(
+ _connection_select_sql(" AND ic.subscription_id = %s ") + " ORDER BY ic.id ASC LIMIT 1",
+ (subscription_id,),
+ ) or []
+ return _decorate_connection_row(dict(rows[0])) if rows else None
+
+
+def _resolve_group_id_by_name_tokens(tokens: List[str]) -> Optional[int]:
+ lowered = [str(token or "").strip().lower() for token in tokens if str(token or "").strip()]
+ if not lowered:
+ return None
+ clauses = " OR ".join(["LOWER(name) LIKE %s" for _ in lowered])
+ params = tuple(f"%{token}%" for token in lowered)
+ row = execute_query_single(
+ f"""
+ SELECT id
+ FROM groups
+ WHERE {clauses}
+ ORDER BY id
+ LIMIT 1
+ """,
+ params,
+ )
+ return int(row["id"]) if row and row.get("id") else None
+
+
+def _load_connection_ranges(connection_id: int) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ """
+ SELECT
+ ir.*,
+ c.name AS customer_name,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL) AS total_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'available') AS available_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'reserved') AS reserved_addresses,
+ COUNT(ipa.id) FILTER (WHERE ipa.deleted_at IS NULL AND ipa.status = 'in_use') AS in_use_addresses
+ FROM internet_connections_ip_ranges ir
+ LEFT JOIN customers c ON c.id = ir.customer_id
+ LEFT JOIN internet_connections_ip_addresses ipa ON ipa.range_id = ir.id
+ WHERE ir.connection_id = %s
+ AND ir.deleted_at IS NULL
+ GROUP BY ir.id, c.name
+ ORDER BY ir.id ASC
+ """,
+ (connection_id,),
+ ) or []
+ payload: List[Dict[str, Any]] = []
+ for row in rows:
+ item = _build_ip_range_payload(dict(row))
+ item["is_fully_available"] = bool(
+ item.get("total_addresses", 0) > 0 and item.get("available_addresses", 0) == item.get("total_addresses", 0)
+ )
+ payload.append(item)
+ return payload
+
+
+def _build_provisioning_candidates(range_item: Dict[str, Any], required_prefixes: List[int]) -> List[Dict[str, Any]]:
+ if not required_prefixes:
+ return []
+ if range_item.get("customer_id") is not None:
+ return []
+ if not range_item.get("is_fully_available"):
+ return []
+
+ cidr = str(range_item.get("cidr") or "").strip()
+ if not cidr:
+ return []
+
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError:
+ return []
+
+ candidates: List[Dict[str, Any]] = []
+ seen: set[tuple[int, str]] = set()
+ for requested_prefix in required_prefixes:
+ try:
+ prefix_value = int(requested_prefix)
+ except (TypeError, ValueError):
+ continue
+ if prefix_value < network.prefixlen:
+ continue
+ if prefix_value == network.prefixlen:
+ candidate = dict(range_item)
+ candidate["range_id"] = int(range_item["id"])
+ candidate["source_range_id"] = int(range_item["id"])
+ candidate["source_cidr"] = cidr
+ candidate["requested_cidr"] = cidr
+ candidate["is_derived_candidate"] = False
+ key = (candidate["source_range_id"], candidate["requested_cidr"])
+ if key not in seen:
+ candidates.append(candidate)
+ seen.add(key)
+ continue
+
+ for subnet in network.subnets(new_prefix=prefix_value):
+ requested_cidr = str(subnet)
+ key = (int(range_item["id"]), requested_cidr)
+ if key in seen:
+ continue
+ usable_hosts = max(subnet.num_addresses - 2, 0) if subnet.num_addresses > 1 else int(subnet.num_addresses)
+ candidates.append(
+ {
+ "id": f"{range_item['id']}:{requested_cidr}",
+ "range_id": int(range_item["id"]),
+ "source_range_id": int(range_item["id"]),
+ "source_cidr": cidr,
+ "requested_cidr": requested_cidr,
+ "cidr": requested_cidr,
+ "name": f"{range_item.get('name') or 'Range'} -> {requested_cidr}",
+ "description": range_item.get("description"),
+ "provider_reference": range_item.get("provider_reference"),
+ "contract_number": range_item.get("contract_number"),
+ "customer_id": None,
+ "customer_name": None,
+ "service_address": range_item.get("service_address"),
+ "monthly_cost": 0,
+ "sales_price": 0,
+ "network_address": str(subnet.network_address),
+ "broadcast_address": str(subnet.broadcast_address),
+ "prefix_length": int(subnet.prefixlen),
+ "total_hosts": int(subnet.num_addresses),
+ "usable_hosts": usable_hosts,
+ "total_addresses": usable_hosts,
+ "available_addresses": usable_hosts,
+ "reserved_addresses": 0,
+ "in_use_addresses": 0,
+ "used_addresses": 0,
+ "is_fully_available": True,
+ "is_derived_candidate": True,
+ }
+ )
+ seen.add(key)
+ return candidates
+
+
+def _load_shared_heads_for_provisioning(required_prefixes: List[int]) -> List[Dict[str, Any]]:
+ rows = execute_query(
+ _connection_select_sql(
+ """
+ AND ic.allocation_model = 'shared'
+ AND ic.parent_id IS NULL
+ """
+ )
+ + " ORDER BY ic.address ASC NULLS LAST, ic.name ASC",
+ ) or []
+ heads: List[Dict[str, Any]] = []
+ for row in rows:
+ head = _decorate_connection_row(dict(row))
+ ranges = _load_connection_ranges(int(head["id"]))
+ matching_ranges = []
+ for range_item in ranges:
+ matching_ranges.extend(_build_provisioning_candidates(range_item, required_prefixes))
+ head["available_matching_ranges"] = matching_ranges
+ head["available_matching_range_count"] = len(matching_ranges)
+ heads.append(head)
+ return heads
+
+
+@router.get("/internet-connections/health")
+async def internet_connections_health():
+ return {"status": "healthy", "service": "internet-connections-module"}
+
+
+@router.get("/internet-connections", response_model=List[dict])
+async def list_connections(
+ q: Optional[str] = Query(None),
+ customer_id: Optional[int] = Query(None),
+ provider: Optional[str] = Query(None),
+ status: Optional[str] = Query(None),
+ allocation_model: Optional[str] = Query(None),
+ value_type: Optional[str] = Query(None),
+ shared_only: bool = Query(False),
+ bmcnet_only: bool = Query(False),
+):
+ query = _connection_select_sql()
+ params: list[object] = []
+ if q:
+ query += " AND (ic.name ILIKE %s OR ic.provider ILIKE %s OR ic.address ILIKE %s OR c.name ILIKE %s OR ic.circuit_number ILIKE %s OR sub.subscription_number ILIKE %s OR sub.product_name ILIKE %s)"
+ term = f"%{q}%"
+ params.extend([term, term, term, term, term, term, term])
+ if customer_id:
+ query += " AND ic.customer_id = %s"
+ params.append(customer_id)
+ if provider:
+ query += " AND ic.provider ILIKE %s"
+ params.append(f"%{provider}%")
+ if status:
+ query += " AND ic.status = %s"
+ params.append(status)
+ if allocation_model:
+ query += " AND ic.allocation_model = %s"
+ params.append(allocation_model)
+ if value_type:
+ query += " AND ic.value_type = %s"
+ params.append(value_type)
+ if shared_only:
+ query += " AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL"
+ if bmcnet_only:
+ query += """
+ AND ic.parent_id IS NOT NULL
+ AND parent.allocation_model = 'shared'
+ AND (
+ ic.value_type = 'subscription'
+ OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
+ )
+ """
+ query += " ORDER BY c.name ASC NULLS LAST, ic.name ASC"
+ try:
+ rows = execute_query(query, tuple(params) if params else ()) or []
+ except Exception as exc:
+ logger.warning("Failed to load internet connections: %s", exc)
+ return []
+ return [_decorate_connection_row(dict(row)) for row in rows]
+
+
+@router.post("/internet-connections", response_model=dict)
+async def create_connection(payload: ConnectionCreatePayload):
+ normalized = _normalize_connection_payload(payload.model_dump())
+ try:
+ rows = execute_query(
+ """
+ INSERT INTO internet_connections_connections (
+ parent_id, name, provider, customer_id, address, status, monthly_cost, sales_price, technology,
+ connection_type, circuit_number, speed_mbps, upload_mbps, download_mbps, monitoring_url,
+ contract_start, contract_end, notes, allocation_model, value_type, value_label, subscription_id
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (
+ normalized.get("parent_id"),
+ normalized.get("name"),
+ normalized.get("provider"),
+ normalized.get("customer_id"),
+ normalized.get("address"),
+ normalized.get("status"),
+ normalized.get("monthly_cost"),
+ normalized.get("sales_price"),
+ normalized.get("technology"),
+ normalized.get("connection_type"),
+ normalized.get("circuit_number"),
+ normalized.get("speed_mbps"),
+ normalized.get("upload_mbps"),
+ normalized.get("download_mbps"),
+ normalized.get("monitoring_url"),
+ normalized.get("contract_start"),
+ normalized.get("contract_end"),
+ normalized.get("notes"),
+ normalized.get("allocation_model"),
+ normalized.get("value_type"),
+ normalized.get("value_label"),
+ normalized.get("subscription_id"),
+ ),
+ )
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.exception("Failed to create internet connection")
+ raise HTTPException(status_code=500, detail=f"Kunne ikke oprette forbindelse: {exc}") from exc
+ if not rows:
+ raise HTTPException(status_code=500, detail="Failed to create connection")
+ connection = dict(rows[0])
+ _create_history_entry(
+ int(connection["id"]),
+ "connection_created",
+ f"Oprettede forbindelse {payload.name}",
+ {
+ "provider": normalized.get("provider"),
+ "customer_id": normalized.get("customer_id"),
+ "parent_id": normalized.get("parent_id"),
+ "status": normalized.get("status"),
+ "allocation_model": normalized.get("allocation_model"),
+ "value_type": normalized.get("value_type"),
+ "subscription_id": normalized.get("subscription_id"),
+ },
+ )
+ return connection
+
+
+@router.put("/internet-connections/{connection_id:int}", response_model=dict)
+async def update_connection(connection_id: int, payload: ConnectionUpdatePayload):
+ existing_rows = execute_query(
+ _connection_select_sql(" AND ic.id = %s "),
+ (connection_id,),
+ ) or []
+ if not existing_rows:
+ raise HTTPException(status_code=404, detail="Connection not found")
+
+ existing = dict(existing_rows[0])
+ update_values = payload.model_dump(exclude_unset=True)
+ if not update_values:
+ return _decorate_connection_row(existing)
+
+ merged_values = dict(existing)
+ merged_values.update(update_values)
+ if (
+ str(merged_values.get("value_type") or "").strip().lower() == "other"
+ and not str(merged_values.get("value_label") or "").strip()
+ ):
+ merged_values["value_label"] = "Mangler klassifikation"
+ normalized = _normalize_connection_payload(merged_values)
+
+ set_parts = []
+ params: list[object] = []
+ changed: dict[str, object] = {}
+ allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id"}
+ for field in allowed_fields:
+ value = normalized.get(field)
+ set_parts.append(f"{field} = %s")
+ params.append(value)
+ if existing.get(field) != value:
+ changed[field] = value
+
+ params.append(connection_id)
+ try:
+ rows = execute_query(
+ f"""
+ UPDATE internet_connections_connections
+ SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ RETURNING *
+ """,
+ tuple(params),
+ ) or []
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.exception("Failed to update internet connection %s", connection_id)
+ raise HTTPException(status_code=500, detail=f"Kunne ikke gemme forbindelse: {exc}") from exc
+ if not rows:
+ raise HTTPException(status_code=500, detail="Failed to update connection")
+
+ if changed:
+ _create_history_entry(
+ connection_id,
+ "connection_updated",
+ f"Opdaterede forbindelse {rows[0].get('name')}",
+ changed,
+ )
+ return dict(rows[0])
+
+
+@router.get("/internet-connections/contracts")
+async def list_contracts(status: Optional[str] = Query(None), provider: Optional[str] = Query(None)):
+ query = """
+ SELECT
+ id,
+ name,
+ provider,
+ contract_start,
+ contract_end,
+ status,
+ sales_price,
+ allocation_model,
+ value_type,
+ subscription_id
+ FROM internet_connections_connections
+ WHERE deleted_at IS NULL
+ """
+ params: list[object] = []
+
+ if status:
+ query += " AND status = %s"
+ params.append(status)
+ if provider:
+ query += " AND provider ILIKE %s"
+ params.append(f"%{provider}%")
+
+ query += " ORDER BY contract_end IS NULL, contract_end ASC, name ASC"
+ try:
+ rows = execute_query(query, tuple(params) if params else ()) or []
+ except Exception as exc:
+ logger.warning("Failed to load contract overview: %s", exc)
+ return []
+
+ payload = []
+ for row in rows:
+ item = dict(row)
+ item["contract_status"] = _get_contract_status(item.get("contract_end"))
+ item["value_type_label"] = _value_type_label(item.get("value_type"))
+ item["allocation_model_label"] = _allocation_model_label(item.get("allocation_model"))
+ payload.append(item)
+ return payload
+
+
+@router.get("/internet-connections/{connection_id:int}", response_model=dict)
+async def get_connection(connection_id: int):
+ rows = execute_query(
+ _connection_select_sql(" AND ic.id = %s "),
+ (connection_id,),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=404, detail="Connection not found")
+ connection = _decorate_connection_row(dict(rows[0]))
+ if connection.get("is_shared_head"):
+ children = _load_bmcnet_children(connection_id)
+ connection["bmcnet_summary"] = _build_bmcnet_summary(children)
+ else:
+ connection["bmcnet_summary"] = _build_bmcnet_summary([])
+ return connection
+
+
+@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
+async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
+ where_parts = [" AND ic.parent_id = %s "]
+ if bmcnet_only:
+ where_parts.append(
+ """
+ AND parent.allocation_model = 'shared'
+ AND (
+ ic.value_type = 'subscription'
+ OR LOWER(COALESCE(ic.value_label, '')) IN ('bmcnet', 'bmc networks')
+ )
+ """
+ )
+ rows = execute_query(
+ _connection_select_sql("".join(where_parts)) + " ORDER BY c.name ASC NULLS LAST, ic.name ASC",
+ (connection_id,),
+ ) or []
+ return [_decorate_connection_row(dict(row)) for row in rows]
+
+
+@router.get("/internet-connections/pricing/summary")
+async def pricing_summary():
+ try:
+ rows = execute_query(
+ """
+ SELECT
+ COUNT(*) AS total_connections,
+ COUNT(*) FILTER (WHERE status = 'active') AS active_connections,
+ COUNT(*) FILTER (WHERE allocation_model = 'shared' AND parent_id IS NULL) AS shared_head_connections,
+ COALESCE(SUM(monthly_cost), 0) AS total_purchase_cost,
+ COALESCE(SUM(sales_price), 0) AS total_sales_price,
+ COALESCE(SUM(COALESCE(sales_price, 0) - COALESCE(monthly_cost, 0)), 0) AS total_margin
+ FROM internet_connections_connections
+ WHERE deleted_at IS NULL
+ """,
+ ) or []
+ except Exception as exc:
+ logger.warning("Failed to load pricing summary: %s", exc)
+ return {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
+ return dict(rows[0]) if rows else {"total_connections": 0, "active_connections": 0, "shared_head_connections": 0, "total_purchase_cost": 0, "total_sales_price": 0, "total_margin": 0}
+
+
+@router.get("/internet-connections/subscription-options", response_model=List[dict])
+async def subscription_options(q: Optional[str] = Query(None), status: str = Query("active")):
+ params: List[Any] = []
+ where = ["1=1"]
+ if status and status != "all":
+ where.append("s.status = %s")
+ params.append(status)
+ if q:
+ term = f"%{q}%"
+ where.append("(s.subscription_number ILIKE %s OR s.product_name ILIKE %s OR c.name ILIKE %s)")
+ params.extend([term, term, term])
+ rows = execute_query(
+ f"""
+ SELECT
+ s.id,
+ s.subscription_number,
+ s.product_name,
+ s.customer_id,
+ c.name AS customer_name,
+ s.status
+ FROM sag_subscriptions s
+ LEFT JOIN customers c ON c.id = s.customer_id
+ WHERE {" AND ".join(where)}
+ ORDER BY s.start_date DESC NULLS LAST, s.id DESC
+ LIMIT 50
+ """,
+ tuple(params) if params else (),
+ ) or []
+ return [dict(row) for row in rows]
+
+
+@router.get("/internet-connections/subscriptions/{subscription_id}/provisioning")
+async def get_subscription_provisioning(subscription_id: int):
+ subscription = _load_subscription(subscription_id)
+ line_items = _load_subscription_line_items(subscription_id)
+ requirements = summarize_subscription_network_requirements(line_items)
+ existing_connection = _load_subscription_connection(subscription_id)
+
+ return {
+ "subscription": subscription,
+ "line_items": line_items,
+ "network_provisioning": {
+ **requirements,
+ "existing_connection_id": existing_connection.get("id") if existing_connection else None,
+ "is_provisioned": bool(existing_connection),
+ },
+ "existing_connection": existing_connection,
+ "current_allocated_ranges": _load_connection_ranges(int(existing_connection["id"])) if existing_connection else [],
+ "shared_heads": _load_shared_heads_for_provisioning(requirements.get("required_ip_prefixes") or []),
+ }
+
+
+@router.post("/internet-connections/subscriptions/{subscription_id}/provision")
+async def provision_subscription_connection(subscription_id: int, payload: SubscriptionProvisionPayload):
+ subscription = _load_subscription(subscription_id)
+ line_items = _load_subscription_line_items(subscription_id)
+ requirements = summarize_subscription_network_requirements(line_items)
+ if not requirements["requires_provisioning"]:
+ raise HTTPException(status_code=400, detail="Subscription does not require network provisioning")
+
+ line_item_map = {int(item["id"]): item for item in line_items if item.get("id") is not None}
+
+ internet_item = None
+ if payload.internet_item_id is not None:
+ internet_item = line_item_map.get(int(payload.internet_item_id))
+ if not internet_item:
+ raise HTTPException(status_code=400, detail="Selected internet product is not part of the subscription")
+ internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
+ if internet_profile.get("kind") != "internet_access":
+ raise HTTPException(status_code=400, detail="Selected internet product is not an internet access line")
+ elif requirements.get("primary_internet_item"):
+ internet_item = line_item_map.get(int(requirements["primary_internet_item"]["subscription_item_id"]))
+ internet_profile = build_network_product_profile(internet_item, fallback_text=internet_item.get("description"))
+ else:
+ internet_profile = {}
+
+ requested_ip_items = requirements.get("ip_items") or []
+ requested_ip_item_ids = {int(item["subscription_item_id"]) for item in requested_ip_items if item.get("subscription_item_id") is not None}
+ provided_ip_item_ids = {int(item.subscription_item_id) for item in payload.ip_allocations}
+ if requested_ip_item_ids != provided_ip_item_ids:
+ raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje")
+
+ shared_connection = execute_query_single(
+ _connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "),
+ (payload.shared_connection_id,),
+ )
+ if not shared_connection:
+ raise HTTPException(status_code=404, detail="Shared head connection not found")
+
+ shared_connection = _decorate_connection_row(dict(shared_connection))
+ if not shared_connection.get("address"):
+ raise HTTPException(status_code=409, detail="Den valgte hovedforbindelse mangler adresse og kan ikke bruges")
+
+ existing_connection = _load_subscription_connection(subscription_id)
+
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ SELECT *
+ FROM internet_connections_connections
+ WHERE id = %s
+ AND deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (payload.shared_connection_id,),
+ )
+ locked_shared_connection = cursor.fetchone()
+ if not locked_shared_connection:
+ raise HTTPException(status_code=404, detail="Shared head connection not found")
+
+ existing_row = None
+ existing_connection_id = None
+ previous_parent_id = payload.shared_connection_id
+ if existing_connection:
+ existing_connection_id = int(existing_connection["id"])
+ cursor.execute(
+ """
+ SELECT *
+ FROM internet_connections_connections
+ WHERE id = %s
+ AND deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (existing_connection_id,),
+ )
+ existing_row = cursor.fetchone()
+ previous_parent_id = int(existing_row.get("parent_id") or payload.shared_connection_id)
+
+ selected_range_ids = [int(item.range_id) for item in payload.ip_allocations]
+ allocation_by_item = {
+ int(item.subscription_item_id): {
+ "range_id": int(item.range_id),
+ "requested_cidr": str(item.requested_cidr or "").strip() or None,
+ }
+ for item in payload.ip_allocations
+ }
+
+ for ip_item in requested_ip_items:
+ item_id = int(ip_item["subscription_item_id"])
+ allocation = allocation_by_item[item_id]
+ range_id = int(allocation["range_id"])
+ cursor.execute(
+ """
+ SELECT
+ ir.*
+ FROM internet_connections_ip_ranges ir
+ WHERE ir.id = %s
+ AND ir.connection_id = %s
+ AND ir.deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (range_id, payload.shared_connection_id),
+ )
+ range_row = cursor.fetchone()
+ if not range_row:
+ raise HTTPException(status_code=409, detail=f"IP-range #{range_id} er ikke ledigt paa den valgte hovedforbindelse")
+ cursor.execute(
+ """
+ SELECT
+ COUNT(*) FILTER (WHERE deleted_at IS NULL) AS total_addresses,
+ COUNT(*) FILTER (WHERE deleted_at IS NULL AND status = 'available') AS available_addresses
+ FROM internet_connections_ip_addresses
+ WHERE range_id = %s
+ """,
+ (range_id,),
+ )
+ range_stats = cursor.fetchone() or {}
+ prefix_length = _build_ip_range_payload(dict(range_row)).get("prefix_length")
+ expected_prefix = ip_item.get("ip_prefix_length")
+ requested_cidr = allocation.get("requested_cidr") or str(range_row.get("cidr") or "")
+ requested_network = _validate_cidr(requested_cidr)
+ source_network = _validate_cidr(str(range_row.get("cidr") or ""))
+ total_addresses = int(range_stats.get("total_addresses") or 0)
+ available_addresses = int(range_stats.get("available_addresses") or 0)
+ if total_addresses <= 0:
+ raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} har ingen adresser")
+ if not requested_network.subnet_of(source_network):
+ raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} ligger ikke i {range_row.get('cidr')}")
+ if expected_prefix is not None and int(requested_network.prefixlen) != int(expected_prefix):
+ raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} matcher ikke /{expected_prefix}")
+ if prefix_length is not None and int(requested_network.prefixlen) < int(prefix_length):
+ raise HTTPException(status_code=409, detail=f"IP-range {requested_cidr} er stoerre end kildeblokken {range_row.get('cidr')}")
+ if requested_cidr == str(range_row.get("cidr") or ""):
+ if available_addresses != total_addresses:
+ raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er ikke laengere ledigt")
+ if range_row.get("customer_id") is not None:
+ raise HTTPException(status_code=409, detail=f"IP-range {range_row.get('cidr')} er allerede bundet til en anden kunde")
+
+ current_range_rows: List[Dict[str, Any]] = []
+ if existing_connection_id:
+ cursor.execute(
+ """
+ SELECT id
+ FROM internet_connections_ip_ranges
+ WHERE connection_id = %s
+ AND deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (existing_connection_id,),
+ )
+ current_range_rows = [dict(row) for row in cursor.fetchall() or []]
+
+ connection_name = internet_item.get("description") if internet_item else subscription.get("product_name")
+ connection_name = str(connection_name or subscription.get("subscription_number") or "Internetforbindelse").strip()
+ sales_price = 0.0
+ if internet_item:
+ sales_price += float(internet_item.get("line_total") or 0)
+ for ip_item in requested_ip_items:
+ sales_price += float(ip_item.get("line_total") or 0)
+
+ connection_fields = (
+ connection_name,
+ locked_shared_connection.get("provider"),
+ subscription.get("customer_id"),
+ payload.shared_connection_id,
+ locked_shared_connection.get("address"),
+ "active",
+ sales_price,
+ locked_shared_connection.get("technology"),
+ internet_profile.get("connection_type") or locked_shared_connection.get("connection_type"),
+ internet_profile.get("speed_mbps"),
+ internet_profile.get("upload_mbps"),
+ internet_profile.get("download_mbps"),
+ "dedicated",
+ "subscription",
+ None,
+ subscription_id,
+ )
+
+ if existing_row:
+ cursor.execute(
+ """
+ UPDATE internet_connections_connections
+ SET name = %s,
+ provider = %s,
+ customer_id = %s,
+ parent_id = %s,
+ address = %s,
+ status = %s,
+ sales_price = %s,
+ technology = %s,
+ connection_type = %s,
+ speed_mbps = %s,
+ upload_mbps = %s,
+ download_mbps = %s,
+ allocation_model = %s,
+ value_type = %s,
+ value_label = %s,
+ subscription_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ RETURNING *
+ """,
+ connection_fields + (existing_connection_id,),
+ )
+ connection_row = cursor.fetchone()
+ else:
+ cursor.execute(
+ """
+ INSERT INTO internet_connections_connections (
+ name, provider, customer_id, parent_id, address, status, sales_price, technology,
+ connection_type, speed_mbps, upload_mbps, download_mbps,
+ allocation_model, value_type, value_label, subscription_id
+ ) VALUES (
+ %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
+ )
+ RETURNING *
+ """,
+ connection_fields,
+ )
+ connection_row = cursor.fetchone()
+ existing_connection_id = int(connection_row["id"])
+
+ released_range_ids: List[int] = []
+ for current_range in current_range_rows:
+ current_range_id = int(current_range["id"])
+ if current_range_id in selected_range_ids:
+ continue
+ cursor.execute(
+ """
+ UPDATE internet_connections_ip_ranges
+ SET connection_id = %s,
+ customer_id = NULL,
+ service_address = NULL,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (previous_parent_id, current_range_id),
+ )
+ cursor.execute(
+ """
+ UPDATE internet_connections_ip_addresses
+ SET status = 'available',
+ assigned_to = NULL,
+ assigned_type = NULL,
+ assigned_customer_id = NULL,
+ assigned_connection_id = NULL,
+ comment = NULL,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE range_id = %s
+ AND deleted_at IS NULL
+ """,
+ (current_range_id,),
+ )
+ released_range_ids.append(current_range_id)
+
+ allocated_ranges: List[Dict[str, Any]] = []
+ for ip_item in requested_ip_items:
+ item_id = int(ip_item["subscription_item_id"])
+ allocation = allocation_by_item[item_id]
+ range_id = int(allocation["range_id"])
+ requested_cidr = allocation.get("requested_cidr")
+ cursor.execute(
+ """
+ SELECT *
+ FROM internet_connections_ip_ranges
+ WHERE id = %s
+ AND deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (range_id,),
+ )
+ source_range = cursor.fetchone()
+ if not source_range:
+ raise HTTPException(status_code=404, detail=f"IP-range #{range_id} blev ikke fundet")
+
+ source_cidr = str(source_range.get("cidr") or "")
+ effective_cidr = requested_cidr or source_cidr
+ if effective_cidr == source_cidr:
+ cursor.execute(
+ """
+ UPDATE internet_connections_ip_ranges
+ SET connection_id = %s,
+ customer_id = %s,
+ service_address = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ RETURNING *
+ """,
+ (existing_connection_id, subscription.get("customer_id"), locked_shared_connection.get("address"), range_id),
+ )
+ allocated_range = cursor.fetchone()
+ cursor.execute(
+ """
+ UPDATE internet_connections_ip_addresses
+ SET status = 'reserved',
+ assigned_to = %s,
+ assigned_type = 'subscription',
+ assigned_customer_id = %s,
+ assigned_connection_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE range_id = %s
+ AND deleted_at IS NULL
+ """,
+ (subscription.get("subscription_number"), subscription.get("customer_id"), existing_connection_id, range_id),
+ )
+ else:
+ requested_network = _validate_cidr(effective_cidr)
+ host_addresses = [str(host) for host in requested_network.hosts()]
+ if not host_addresses:
+ raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} har ingen brugbare adresser")
+ cursor.execute(
+ """
+ SELECT id, ip_address, status
+ FROM internet_connections_ip_addresses
+ WHERE range_id = %s
+ AND deleted_at IS NULL
+ FOR UPDATE
+ """,
+ (range_id,),
+ )
+ source_addresses = cursor.fetchall() or []
+ source_address_map = {str(row.get("ip_address")): dict(row) for row in source_addresses}
+ missing_addresses = [ip for ip in host_addresses if ip not in source_address_map]
+ if missing_addresses:
+ raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} mangler adresser i lageret")
+ busy_addresses = [
+ ip for ip in host_addresses
+ if str(source_address_map[ip].get("status") or "available").lower() != "available"
+ ]
+ if busy_addresses:
+ raise HTTPException(status_code=409, detail=f"IP-range {effective_cidr} er ikke laengere ledigt")
+ cursor.execute(
+ """
+ INSERT INTO internet_connections_ip_ranges (
+ connection_id, name, cidr, description, provider_reference,
+ contract_number, customer_id, service_address, monthly_cost, sales_price
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 0, 0)
+ RETURNING *
+ """,
+ (
+ existing_connection_id,
+ f"{source_range.get('name') or 'IP-range'} -> {effective_cidr}",
+ effective_cidr,
+ source_range.get("description"),
+ source_range.get("provider_reference"),
+ source_range.get("contract_number"),
+ subscription.get("customer_id"),
+ locked_shared_connection.get("address"),
+ ),
+ )
+ allocated_range = cursor.fetchone()
+ allocated_range_id = int(allocated_range["id"])
+ cursor.execute(
+ """
+ UPDATE internet_connections_ip_addresses
+ SET range_id = %s,
+ status = 'reserved',
+ assigned_to = %s,
+ assigned_type = 'subscription',
+ assigned_customer_id = %s,
+ assigned_connection_id = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = ANY(%s)
+ """,
+ (
+ allocated_range_id,
+ subscription.get("subscription_number"),
+ subscription.get("customer_id"),
+ existing_connection_id,
+ [int(source_address_map[ip]["id"]) for ip in host_addresses],
+ ),
+ )
+ allocated_ranges.append(_build_ip_range_payload(dict(allocated_range)))
+
+ conn.commit()
+
+ _create_history_entry(
+ existing_connection_id,
+ "subscription_provisioned",
+ f"Provisionerede forbindelse fra abonnement {subscription.get('subscription_number')}",
+ {
+ "subscription_id": subscription_id,
+ "shared_connection_id": payload.shared_connection_id,
+ "internet_item_id": payload.internet_item_id,
+ "allocated_range_ids": selected_range_ids,
+ "released_range_ids": released_range_ids,
+ },
+ )
+ _create_history_entry(
+ payload.shared_connection_id,
+ "shared_capacity_allocated",
+ f"Allokerede kapacitet til abonnement {subscription.get('subscription_number')}",
+ {
+ "subscription_id": subscription_id,
+ "connection_id": existing_connection_id,
+ "allocated_range_ids": selected_range_ids,
+ },
+ )
+
+ return {
+ "subscription_id": subscription_id,
+ "connection": _load_subscription_connection(subscription_id),
+ "allocated_ranges": _load_connection_ranges(existing_connection_id),
+ "released_range_ids": released_range_ids,
+ "reused_existing_connection": bool(existing_connection),
+ }
+ finally:
+ release_db_connection(conn)
+
+
+@router.post("/internet-connections/{connection_id}/bmcnet-connections")
+async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload):
+ head_row = execute_query_single(
+ _connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "),
+ (connection_id,),
+ )
+ if not head_row:
+ raise HTTPException(status_code=404, detail="Delt hovedforbindelse blev ikke fundet")
+
+ head = _decorate_connection_row(dict(head_row))
+ customer = execute_query_single(
+ """
+ SELECT id, name
+ FROM customers
+ WHERE id = %s
+ LIMIT 1
+ """,
+ (payload.customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
+
+ address = str(payload.address or head.get("address") or "").strip()
+ if not address:
+ raise HTTPException(status_code=400, detail="Adresse er påkrævet for BMCnet-forbindelsen")
+
+ customer_name = str(customer.get("name") or "").strip() or f"Kunde #{payload.customer_id}"
+ service_label = str(payload.service_label or "").strip() or f"BMCnet - {customer_name}"
+ wizard_notes = str(payload.notes or "").strip() or None
+
+ product_ids = [int(payload.internet_product_id)]
+ if payload.ip_product_id:
+ product_ids.append(int(payload.ip_product_id))
+ product_rows = execute_query(
+ """
+ SELECT id, name, short_description, sales_price, attributes_json, type
+ FROM products
+ WHERE id = ANY(%s)
+ AND deleted_at IS NULL
+ """,
+ (product_ids,),
+ ) or []
+ product_map = {int(row["id"]): dict(row) for row in product_rows if row.get("id") is not None}
+
+ internet_product = product_map.get(int(payload.internet_product_id))
+ if not internet_product:
+ raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet")
+ internet_profile = build_network_product_profile(internet_product, fallback_text=internet_product.get("short_description"))
+ if internet_profile.get("kind") != "internet_access":
+ raise HTTPException(status_code=400, detail="Det valgte internetprodukt er ikke et netværksprodukt")
+
+ ip_product = None
+ ip_profile = {}
+ selected_ip_address = None
+ if payload.ip_product_id:
+ ip_product = product_map.get(int(payload.ip_product_id))
+ if not ip_product:
+ raise HTTPException(status_code=404, detail="IP-produktet blev ikke fundet")
+ ip_profile = build_network_product_profile(ip_product, fallback_text=ip_product.get("short_description"))
+ if ip_profile.get("kind") != "ip_allocation":
+ raise HTTPException(status_code=400, detail="Det valgte IP-produkt er ikke et IP-allokeringsprodukt")
+ is_static_wan_ip = bool(
+ int(ip_profile.get("ip_prefix_length") or 0) == 32
+ or "statisk wan" in f"{ip_product.get('name') or ''} {ip_product.get('short_description') or ''}".lower()
+ )
+ if is_static_wan_ip:
+ if not payload.ip_address_id:
+ raise HTTPException(status_code=400, detail="Vælg en ledig IP-adresse til Statisk WAN IP")
+ selected_ip_address = execute_query_single(
+ """
+ SELECT
+ ipa.id,
+ ipa.ip_address,
+ ipa.range_id,
+ ipa.status,
+ ir.connection_id,
+ ir.service_address,
+ ir.cidr,
+ child.name AS connection_name,
+ child.parent_id AS connection_parent_id
+ FROM internet_connections_ip_addresses ipa
+ JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
+ LEFT JOIN internet_connections_connections child ON child.id = ir.connection_id
+ WHERE ipa.id = %s
+ AND ipa.deleted_at IS NULL
+ AND ir.deleted_at IS NULL
+ LIMIT 1
+ """,
+ (payload.ip_address_id,),
+ )
+ if not selected_ip_address:
+ raise HTTPException(status_code=404, detail="Den valgte IP-adresse blev ikke fundet")
+ if int(selected_ip_address.get("connection_id") or 0) != int(connection_id):
+ if int(selected_ip_address.get("connection_parent_id") or 0) == int(connection_id):
+ child_name = str(selected_ip_address.get("connection_name") or "").strip()
+ child_label = child_name or f"Forbindelse #{selected_ip_address.get('connection_id')}"
+ raise HTTPException(
+ status_code=409,
+ detail=f"Den valgte IP-adresse er allerede reserveret på {child_label}. Opdater siden før du opretter igen.",
+ )
+ raise HTTPException(status_code=409, detail="Den valgte IP-adresse ligger ikke på den valgte hovedforbindelse")
+ if str(selected_ip_address.get("status") or "").lower() != "available":
+ raise HTTPException(status_code=409, detail="Den valgte IP-adresse er ikke længere ledig")
+ elif not payload.range_id:
+ raise HTTPException(status_code=400, detail="Vælg et IP-range til IP-produktet")
+ elif payload.range_id or payload.ip_address_id:
+ raise HTTPException(status_code=400, detail="Der er valgt IP-allokering uden et IP-produkt")
+
+ case_title = str(payload.case_title or "").strip() or f"BMCnet - {customer_name}"
+ description_lines = [
+ f"Hovedforbindelse: {head.get('name') or connection_id}",
+ f"Service: {service_label}",
+ f"Adresse: {address}",
+ f"Internetprodukt: {internet_product.get('name') or '-'}",
+ ]
+ if ip_product:
+ description_lines.append(f"IP-produkt: {ip_product.get('name') or '-'}")
+ if wizard_notes:
+ description_lines.append("")
+ description_lines.append(wizard_notes)
+
+ assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])
+ created_case = execute_query_single(
+ """
+ INSERT INTO sag_sager (
+ titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (
+ case_title,
+ "\n".join(description_lines),
+ "abonnement",
+ "åben",
+ payload.customer_id,
+ assigned_group_id,
+ 1,
+ ),
+ )
+ if not created_case or not created_case.get("id"):
+ raise HTTPException(status_code=500, detail="Kunne ikke oprette sag")
+
+ line_items = [{
+ "product_id": int(internet_product["id"]),
+ "description": str(internet_product.get("short_description") or internet_product.get("name") or "").strip(),
+ "quantity": 1,
+ "unit_price": float(payload.internet_unit_price if payload.internet_unit_price is not None else (internet_product.get("sales_price") or 0)),
+ }]
+ if ip_product:
+ line_items.append({
+ "product_id": int(ip_product["id"]),
+ "description": str(ip_product.get("short_description") or ip_product.get("name") or "").strip(),
+ "quantity": 1,
+ "unit_price": float(payload.ip_unit_price if payload.ip_unit_price is not None else (ip_product.get("sales_price") or 0)),
+ })
+
+ from app.subscriptions.backend.router import create_subscription as create_sag_subscription
+
+ subscription = await create_sag_subscription({
+ "sag_id": int(created_case["id"]),
+ "billing_interval": payload.billing_interval,
+ "billing_day": int(payload.billing_day),
+ "start_date": payload.start_date.isoformat(),
+ "notes": wizard_notes,
+ "line_items": line_items,
+ })
+ subscription_id = int(subscription["id"])
+ line_items_created = _load_subscription_line_items(subscription_id)
+ requirements = summarize_subscription_network_requirements(line_items_created)
+ internet_item = requirements.get("primary_internet_item")
+ if not internet_item:
+ raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men internetlinjen kunne ikke findes")
+
+ ip_allocations: List[SubscriptionIpAllocationPayload] = []
+ if ip_product:
+ ip_item = next((item for item in requirements.get("ip_items") or [] if int(item.get("product_id") or 0) == int(ip_product["id"])), None)
+ if not ip_item:
+ raise HTTPException(status_code=500, detail="Abonnementet blev oprettet men IP-linjen kunne ikke findes")
+ requested_cidr = None
+ allocation_range_id = int(payload.range_id or 0)
+ if selected_ip_address:
+ allocation_range_id = int(selected_ip_address["range_id"])
+ requested_cidr = f"{selected_ip_address['ip_address']}/32"
+ elif payload.range_id:
+ range_row = execute_query_single(
+ "SELECT cidr FROM internet_connections_ip_ranges WHERE id = %s AND deleted_at IS NULL",
+ (payload.range_id,),
+ )
+ if range_row and range_row.get("cidr"):
+ requested_cidr = str(range_row.get("cidr"))
+ ip_allocations.append(SubscriptionIpAllocationPayload(
+ subscription_item_id=int(ip_item["subscription_item_id"]),
+ range_id=allocation_range_id,
+ requested_cidr=requested_cidr,
+ ))
+
+ provisioned = await provision_subscription_connection(
+ subscription_id,
+ SubscriptionProvisionPayload(
+ shared_connection_id=connection_id,
+ internet_item_id=int(internet_item["subscription_item_id"]),
+ ip_allocations=ip_allocations,
+ ),
+ )
+
+ connection = provisioned.get("connection") or _load_subscription_connection(subscription_id)
+ if connection:
+ execute_query(
+ """
+ UPDATE internet_connections_connections
+ SET name = %s,
+ address = %s,
+ notes = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (
+ case_title,
+ address,
+ f"Service: {service_label}" if not wizard_notes else f"Service: {service_label}\n{wizard_notes}",
+ int(connection["id"]),
+ ),
+ )
+ if payload.range_id:
+ execute_query(
+ """
+ UPDATE internet_connections_ip_ranges
+ SET service_address = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE connection_id = %s
+ AND deleted_at IS NULL
+ """,
+ (address, int(connection["id"])),
+ )
+ if selected_ip_address and payload.mark_gateway:
+ execute_query(
+ """
+ UPDATE internet_connections_ip_addresses ipa
+ SET assigned_type = 'bmcnet_gateway',
+ comment = %s,
+ updated_at = CURRENT_TIMESTAMP
+ FROM internet_connections_ip_ranges ir
+ WHERE ipa.range_id = ir.id
+ AND ir.connection_id = %s
+ AND ipa.ip_address = %s
+ AND ipa.deleted_at IS NULL
+ """,
+ ("BMCnet kunde gateway", int(connection["id"]), str(selected_ip_address["ip_address"])),
+ )
+ connection = _load_subscription_connection(subscription_id)
+
+ _create_history_entry(
+ int(connection["id"]),
+ "bmcnet_connection_created",
+ f"Oprettede BMCnet-forbindelse via wizard fra sag #{created_case['id']}",
+ {
+ "parent_connection_id": connection_id,
+ "customer_id": payload.customer_id,
+ "service_label": service_label,
+ "subscription_id": subscription_id,
+ "sag_id": int(created_case["id"]),
+ },
+ )
+ return {
+ "sag": created_case,
+ "subscription": subscription,
+ "connection": connection,
+ "allocated_range_id": int(payload.range_id) if payload.range_id else None,
+ "allocated_ip_address_id": int(selected_ip_address["id"]) if selected_ip_address else None,
+ }
+
+
+@router.get("/internet-connections/{connection_id}/ip-ranges")
+async def list_ip_ranges(connection_id: int):
+ try:
+ connection_rows = execute_query(
+ _connection_select_sql(" AND ic.id = %s "),
+ (connection_id,),
+ ) or []
+ connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
+ range_rows = execute_query(
+ """
+ SELECT
+ ir.*,
+ c.name AS customer_name
+ FROM internet_connections_ip_ranges ir
+ LEFT JOIN customers c ON c.id = ir.customer_id
+ WHERE ir.connection_id = %s AND ir.deleted_at IS NULL
+ ORDER BY id ASC
+ """,
+ (connection_id,),
+ ) or []
+ address_rows = execute_query(
+ """
+ SELECT ipa.id, ipa.range_id, ipa.ip_address, ipa.status
+ FROM internet_connections_ip_addresses ipa
+ JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
+ WHERE ir.connection_id = %s AND ipa.deleted_at IS NULL
+ ORDER BY ipa.ip_address ASC
+ """,
+ (connection_id,),
+ ) or []
+ except Exception as exc:
+ logger.warning("Failed to load IP ranges: %s", exc)
+ return []
+
+ address_map: dict[int, list[dict]] = {}
+ for address in address_rows:
+ address_map.setdefault(int(address.get("range_id")), []).append(address)
+
+ payload = []
+ for row in range_rows:
+ item = _build_ip_range_payload(dict(row), address_map.get(int(row.get("id")), []))
+ if connection:
+ item.update(_range_alignment_for_connection(connection, item))
+ payload.append(item)
+ return payload
+
+
+@router.post("/internet-connections/{connection_id}/ip-ranges")
+async def create_ip_range(connection_id: int, payload: IpRangeCreatePayload):
+ cidr = payload.cidr
+ if not cidr:
+ raise HTTPException(status_code=400, detail="CIDR is required")
+ network = _validate_cidr(str(cidr))
+
+ duplicate_range = execute_query(
+ """
+ SELECT ir.id, ir.connection_id, ir.cidr, ic.name AS connection_name
+ FROM internet_connections_ip_ranges ir
+ LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
+ WHERE ir.cidr = %s
+ AND ir.deleted_at IS NULL
+ LIMIT 1
+ """,
+ (str(network),),
+ ) or []
+ if duplicate_range:
+ row = duplicate_range[0]
+ raise HTTPException(
+ status_code=409,
+ detail=f"IP-range {row.get('cidr')} findes allerede på forbindelse {row.get('connection_name') or row.get('connection_id')}",
+ )
+
+ host_ips = [str(host) for host in network.hosts()]
+ if host_ips:
+ conflicting_rows = execute_query(
+ """
+ SELECT
+ ipa.ip_address,
+ ir.cidr,
+ ic.id AS connection_id,
+ ic.name AS connection_name
+ FROM internet_connections_ip_addresses ipa
+ JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
+ LEFT JOIN internet_connections_connections ic ON ic.id = ir.connection_id
+ WHERE ipa.deleted_at IS NULL
+ AND ipa.ip_address = ANY(%s)
+ ORDER BY ipa.ip_address ASC
+ LIMIT 5
+ """,
+ (host_ips,),
+ ) or []
+ if conflicting_rows:
+ examples = ", ".join(
+ f"{row.get('ip_address')} ({row.get('connection_name') or row.get('connection_id')} / {row.get('cidr')})"
+ for row in conflicting_rows
+ )
+ raise HTTPException(
+ status_code=409,
+ detail=f"IP-range {network} overlapper eksisterende IP-adresser: {examples}",
+ )
+
+ try:
+ rows = execute_query(
+ """
+ INSERT INTO internet_connections_ip_ranges (
+ connection_id, name, cidr, description, provider_reference,
+ contract_number, customer_id, service_address, monthly_cost, sales_price
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (
+ connection_id,
+ payload.name,
+ str(network),
+ payload.description,
+ payload.provider_reference,
+ payload.contract_number,
+ payload.customer_id,
+ payload.service_address,
+ payload.monthly_cost,
+ payload.sales_price,
+ ),
+ ) or []
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.exception("Failed to create IP range on connection %s", connection_id)
+ raise HTTPException(status_code=500, detail=f"Kunne ikke oprette IP-range: {exc}") from exc
+ if not rows:
+ raise HTTPException(status_code=500, detail="Failed to create IP range")
+
+ range_row = dict(rows[0])
+ try:
+ _create_ip_addresses_for_range(int(range_row["id"]), str(network))
+ except HTTPException:
+ raise
+ except Exception as exc:
+ logger.exception("Failed to auto-create IP addresses for range %s", range_row.get("id"))
+ execute_query(
+ """
+ UPDATE internet_connections_ip_ranges
+ SET deleted_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """,
+ (range_row["id"],),
+ )
+ raise HTTPException(status_code=500, detail=f"IP-range blev oprettet men IP-adresser kunne ikke genereres: {exc}") from exc
+
+ _create_history_entry(
+ connection_id,
+ "ip_range_created",
+ f"Oprettede IP-range {payload.name}",
+ {
+ "range_name": payload.name,
+ "cidr": payload.cidr,
+ "provider_reference": payload.provider_reference,
+ "contract_number": payload.contract_number,
+ "customer_id": payload.customer_id,
+ },
+ )
+ return range_row
+
+
+@router.put("/internet-connections/{connection_id}/ip-ranges/{range_id}")
+async def update_ip_range(connection_id: int, range_id: int, payload: IpRangeUpdatePayload):
+ existing_rows = execute_query(
+ """
+ SELECT *
+ FROM internet_connections_ip_ranges
+ WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
+ LIMIT 1
+ """,
+ (range_id, connection_id),
+ ) or []
+ if not existing_rows:
+ raise HTTPException(status_code=404, detail="IP-range not found")
+
+ existing = dict(existing_rows[0])
+ update_values = payload.model_dump(exclude_unset=True)
+ if not update_values:
+ return _build_ip_range_payload(existing)
+
+ merged = dict(existing)
+ merged.update(update_values)
+ merged["name"] = str(merged.get("name") or "").strip() or str(existing.get("cidr") or "")
+ merged["description"] = str(merged.get("description") or "").strip() or None
+ merged["provider_reference"] = str(merged.get("provider_reference") or "").strip() or None
+ merged["contract_number"] = str(merged.get("contract_number") or "").strip() or None
+ merged["service_address"] = str(merged.get("service_address") or "").strip() or None
+
+ if not merged["name"]:
+ raise HTTPException(status_code=400, detail="Navn er påkrævet")
+
+ changed = {}
+ fields = [
+ "name",
+ "description",
+ "provider_reference",
+ "contract_number",
+ "customer_id",
+ "service_address",
+ "monthly_cost",
+ "sales_price",
+ ]
+ params: list[object] = []
+ set_parts: list[str] = []
+ for field in fields:
+ value = merged.get(field)
+ set_parts.append(f"{field} = %s")
+ params.append(value)
+ if existing.get(field) != value:
+ changed[field] = value
+
+ try:
+ rows = execute_query(
+ f"""
+ UPDATE internet_connections_ip_ranges
+ SET {", ".join(set_parts)}, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND connection_id = %s AND deleted_at IS NULL
+ RETURNING *
+ """,
+ tuple(params + [range_id, connection_id]),
+ ) or []
+ except Exception as exc:
+ logger.exception("Failed to update IP range %s on connection %s", range_id, connection_id)
+ raise HTTPException(status_code=500, detail=f"Kunne ikke gemme IP-range: {exc}") from exc
+
+ if not rows:
+ raise HTTPException(status_code=500, detail="Failed to update IP range")
+
+ if changed:
+ _create_history_entry(
+ connection_id,
+ "ip_range_updated",
+ f"Opdaterede IP-range {rows[0].get('name') or rows[0].get('cidr')}",
+ {
+ "range_id": range_id,
+ "cidr": rows[0].get("cidr"),
+ "changes": changed,
+ },
+ )
+
+ address_rows = execute_query(
+ """
+ SELECT *
+ FROM internet_connections_ip_addresses
+ WHERE range_id = %s AND deleted_at IS NULL
+ ORDER BY ip_address ASC
+ """,
+ (range_id,),
+ ) or []
+ return _build_ip_range_payload(dict(rows[0]), [dict(row) for row in address_rows])
+
+
+@router.get("/internet-connections/{connection_id}/ip-addresses")
+async def list_ip_addresses(connection_id: int):
+ connection_rows = execute_query(
+ _connection_select_sql(" AND ic.id = %s "),
+ (connection_id,),
+ ) or []
+ connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
+
+ where_sql = "ir.connection_id = %s"
+ params: tuple[object, ...]
+ if connection and connection.get("is_shared_head"):
+ where_sql = """
+ (
+ ir.connection_id = %s
+ OR ir.connection_id IN (
+ SELECT child.id
+ FROM internet_connections_connections child
+ WHERE child.parent_id = %s
+ AND child.deleted_at IS NULL
+ )
+ )
+ """
+ params = (connection_id, connection_id)
+ else:
+ params = (connection_id,)
+
+ rows = execute_query(
+ f"""
+ SELECT
+ ipa.*,
+ ir.name AS range_name,
+ ir.cidr AS range_cidr,
+ ir.connection_id AS range_connection_id,
+ ir.service_address AS range_service_address,
+ ir.customer_id AS range_customer_id,
+ rc.name AS range_customer_name,
+ c.name AS assigned_customer_name,
+ ic.name AS assigned_connection_name,
+ owner.name AS range_connection_name,
+ sub.id AS assigned_subscription_id,
+ sub.subscription_number AS assigned_subscription_number,
+ sub.sag_id AS assigned_subscription_sag_id
+ FROM internet_connections_ip_addresses ipa
+ JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
+ LEFT JOIN customers rc ON rc.id = ir.customer_id
+ LEFT JOIN customers c ON c.id = ipa.assigned_customer_id
+ LEFT JOIN internet_connections_connections ic ON ic.id = ipa.assigned_connection_id
+ LEFT JOIN internet_connections_connections owner ON owner.id = ir.connection_id
+ LEFT JOIN sag_subscriptions sub ON sub.id = ic.subscription_id
+ WHERE {where_sql} AND ipa.deleted_at IS NULL
+ ORDER BY ipa.ip_address ASC
+ """,
+ params,
+ ) or []
+ payload = []
+ for row in rows:
+ item = dict(row)
+ if connection:
+ item.update(
+ _range_alignment_for_connection(
+ connection,
+ {
+ "service_address": item.get("range_service_address"),
+ "customer_id": item.get("range_customer_id"),
+ "customer_name": item.get("range_customer_name"),
+ },
+ )
+ )
+ status = str(item.get("status", "available")).lower()
+ if status == "in_use":
+ item["status_label"] = "I brug"
+ item["badge_class"] = "bg-primary"
+ elif status == "reserved":
+ item["status_label"] = "Reserveret"
+ item["badge_class"] = "bg-warning"
+ else:
+ item["status_label"] = "Tilgængelig"
+ item["badge_class"] = "bg-success"
+ payload.append(item)
+ return payload
+
+
+@router.get("/internet-connections/{connection_id}/ip-addresses/summary")
+async def summarize_ip_addresses(connection_id: int):
+ connection_rows = execute_query(
+ _connection_select_sql(" AND ic.id = %s "),
+ (connection_id,),
+ ) or []
+ connection = _decorate_connection_row(dict(connection_rows[0])) if connection_rows else None
+
+ where_sql = "ir.connection_id = %s"
+ params: tuple[object, ...]
+ if connection and connection.get("is_shared_head"):
+ where_sql = """
+ (
+ ir.connection_id = %s
+ OR ir.connection_id IN (
+ SELECT child.id
+ FROM internet_connections_connections child
+ WHERE child.parent_id = %s
+ AND child.deleted_at IS NULL
+ )
+ )
+ """
+ params = (connection_id, connection_id)
+ else:
+ params = (connection_id,)
+
+ rows = execute_query(
+ f"""
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'available') AS available,
+ COUNT(*) FILTER (WHERE status = 'in_use') AS in_use,
+ COUNT(*) FILTER (WHERE status = 'reserved') AS reserved
+ FROM internet_connections_ip_addresses ipa
+ JOIN internet_connections_ip_ranges ir ON ir.id = ipa.range_id
+ WHERE {where_sql} AND ipa.deleted_at IS NULL
+ """,
+ params,
+ ) or []
+ if not rows:
+ return {"available": 0, "in_use": 0, "reserved": 0}
+ return dict(rows[0])
+
+
+@router.post("/internet-connections/{connection_id}/ip-addresses")
+async def create_ip_address(connection_id: int, payload: IpAddressCreatePayload):
+ normalized_ip = _normalize_ip_address(payload.ip_address)
+ duplicate = execute_query(
+ """
+ SELECT id, range_id
+ FROM internet_connections_ip_addresses
+ WHERE ip_address = %s
+ AND deleted_at IS NULL
+ LIMIT 1
+ """,
+ (normalized_ip,),
+ ) or []
+ if duplicate:
+ raise HTTPException(status_code=409, detail=f"IP-adressen {normalized_ip} findes allerede")
+
+ rows = execute_query(
+ """
+ INSERT INTO internet_connections_ip_addresses (
+ range_id, ip_address, status, assigned_to, assigned_type,
+ assigned_customer_id, assigned_connection_id, comment
+ )
+ SELECT ir.id, %s, %s, %s, %s, %s, %s, %s
+ FROM internet_connections_ip_ranges ir
+ WHERE ir.connection_id = %s AND ir.id = %s AND ir.deleted_at IS NULL
+ RETURNING *
+ """,
+ (
+ normalized_ip,
+ payload.status,
+ payload.assigned_to,
+ payload.assigned_type,
+ payload.assigned_customer_id,
+ payload.assigned_connection_id,
+ payload.comment,
+ connection_id,
+ payload.range_id,
+ ),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=404, detail="No IP range available for this connection")
+
+ _create_history_entry(
+ connection_id,
+ "ip_address_created",
+ f"Oprettede IP-adresse {normalized_ip}",
+ {
+ "ip_address": normalized_ip,
+ "status": payload.status,
+ "assigned_customer_id": payload.assigned_customer_id,
+ "assigned_connection_id": payload.assigned_connection_id,
+ },
+ )
+ return dict(rows[0])
+
+
+@router.put("/internet-connections/{connection_id}/ip-addresses/{address_id}")
+async def update_ip_address(connection_id: int, address_id: int, payload: IpAddressUpdatePayload):
+ rows = execute_query(
+ """
+ UPDATE internet_connections_ip_addresses ipa
+ SET status = %s,
+ assigned_to = %s,
+ assigned_type = %s,
+ assigned_customer_id = %s,
+ assigned_connection_id = %s,
+ comment = %s,
+ updated_at = CURRENT_TIMESTAMP
+ FROM internet_connections_ip_ranges ir
+ WHERE ipa.range_id = ir.id
+ AND ir.connection_id = %s
+ AND ipa.id = %s
+ AND ipa.deleted_at IS NULL
+ RETURNING ipa.*
+ """,
+ (
+ payload.status,
+ payload.assigned_to,
+ payload.assigned_type,
+ payload.assigned_customer_id,
+ payload.assigned_connection_id,
+ payload.comment,
+ connection_id,
+ address_id,
+ ),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=404, detail="IP address not found")
+
+ item = dict(rows[0])
+ _create_history_entry(
+ connection_id,
+ "ip_address_updated",
+ f"Opdaterede IP-adresse {item.get('ip_address')}",
+ {
+ "ip_address": item.get("ip_address"),
+ "status": item.get("status"),
+ "assigned_customer_id": item.get("assigned_customer_id"),
+ "assigned_connection_id": item.get("assigned_connection_id"),
+ },
+ )
+ return item
+
+
+@router.post("/internet-connections/{connection_id}/pricing", response_model=dict)
+async def create_connection_pricing(connection_id: int, payload: PricingCreatePayload):
+ rows = execute_query(
+ """
+ INSERT INTO internet_connections_pricing (
+ connection_id, effective_from, purchase_price, sales_price, notes
+ )
+ VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """,
+ (connection_id, payload.effective_from, payload.purchase_price, payload.sales_price, payload.notes),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=500, detail="Failed to create pricing entry")
+
+ execute_query(
+ """
+ UPDATE internet_connections_connections
+ SET monthly_cost = %s, sales_price = %s, updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s AND deleted_at IS NULL
+ """,
+ (payload.purchase_price, payload.sales_price, connection_id),
+ )
+
+ _create_history_entry(
+ connection_id,
+ "pricing_updated",
+ f"Opdaterede pris til {payload.sales_price} kr.",
+ {"effective_from": payload.effective_from.isoformat(), "purchase_price": payload.purchase_price, "sales_price": payload.sales_price},
+ )
+ return dict(rows[0])
+
+
+@router.get("/internet-connections/{connection_id}/pricing")
+async def get_connection_pricing(connection_id: int):
+ rows = execute_query(
+ """
+ SELECT
+ monthly_cost,
+ sales_price,
+ connection_type,
+ speed_mbps,
+ upload_mbps,
+ download_mbps,
+ contract_start,
+ contract_end,
+ status
+ FROM internet_connections_connections
+ WHERE id = %s AND deleted_at IS NULL
+ """,
+ (connection_id,),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=404, detail="Connection not found")
+
+ payload = dict(rows[0])
+ payload["contract_status"] = _get_contract_status(payload.get("contract_end"))
+ return payload
+
+
+@router.get("/internet-connections/{connection_id}/pricing/history")
+async def list_pricing_history(connection_id: int):
+ rows = execute_query(
+ """
+ SELECT *
+ FROM internet_connections_pricing
+ WHERE connection_id = %s
+ ORDER BY effective_from DESC, created_at DESC
+ """,
+ (connection_id,),
+ ) or []
+ return [dict(row) for row in rows]
+
+
+@router.get("/internet-connections/{connection_id}/history")
+async def list_history(connection_id: int):
+ rows = execute_query(
+ """
+ SELECT *
+ FROM internet_connections_history
+ WHERE connection_id = %s
+ ORDER BY created_at DESC
+ """,
+ (connection_id,),
+ ) or []
+ return [dict(row) for row in rows]
+
+
+@router.get("/internet-connections/customer-documents")
+async def list_customer_documents(customer_id: Optional[int] = Query(None, ge=1)):
+ if customer_id:
+ customer = execute_query_single(
+ "SELECT id, name FROM customers WHERE id = %s AND is_active = true",
+ (customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ payload = await _build_customer_document_hits(int(customer["id"]), str(customer["name"] or ""), None)
+ return {
+ "customer": {"id": int(customer["id"]), "name": customer["name"]},
+ "documents": payload["documents"],
+ "segments": payload["segments"],
+ }
+
+ rows = execute_query(
+ """
+ SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
+ FROM internet_connections_customer_documents
+ WHERE deleted_at IS NULL
+ ORDER BY created_at DESC, id DESC
+ LIMIT 100
+ """
+ ) or []
+ documents = []
+ for row in rows:
+ item = dict(row)
+ item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
+ item.pop("extracted_text", None)
+ documents.append(item)
+ return {"documents": documents, "customer": None}
+
+
+@router.post("/internet-connections/customer-documents/upload")
+async def upload_customer_document(
+ customer_id: Optional[int] = Form(None),
+ connection_id: Optional[int] = Form(None),
+ notes: Optional[str] = Form(None),
+ file: UploadFile = File(...),
+):
+ customer = None
+ if customer_id:
+ customer = execute_query_single(
+ "SELECT id, name FROM customers WHERE id = %s AND is_active = true",
+ (customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+
+ if connection_id:
+ connection = execute_query_single(
+ """
+ SELECT id, customer_id
+ FROM internet_connections_connections
+ WHERE id = %s
+ AND deleted_at IS NULL
+ """,
+ (connection_id,),
+ )
+ if not connection:
+ raise HTTPException(status_code=404, detail="Connection not found")
+ if customer_id and connection.get("customer_id") not in (None, customer_id):
+ raise HTTPException(status_code=400, detail="Connection belongs to a different customer")
+
+ original_filename = str(file.filename or "").strip()
+ suffix = Path(original_filename).suffix.lower()
+ if suffix not in INTERNET_CUSTOMER_DOC_EXTENSIONS:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Kun tekstfiler er tilladt ({', '.join(sorted(INTERNET_CUSTOMER_DOC_EXTENSIONS))})",
+ )
+
+ upload_dir = _internet_customer_doc_dir()
+ safe_name = _sanitize_upload_name(original_filename)
+ customer_prefix = str(customer_id) if customer_id else "shared"
+ stored_name = f"{customer_prefix}_{int(date.today().strftime('%Y%m%d'))}_{safe_name}"
+ target = upload_dir / stored_name
+
+ total_size = 0
+ max_size = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024
+ try:
+ with open(target, "wb") as handle:
+ while chunk := await file.read(8192):
+ total_size += len(chunk)
+ if total_size > max_size:
+ target.unlink(missing_ok=True)
+ raise HTTPException(
+ status_code=413,
+ detail=f"Fil for stor (max {settings.EMAIL_MAX_UPLOAD_SIZE_MB} MB)",
+ )
+ handle.write(chunk)
+
+ checksum = ollama_service.calculate_file_checksum(target)
+ existing = execute_query_single(
+ """
+ SELECT id
+ FROM internet_connections_customer_documents
+ WHERE customer_id = %s
+ AND checksum = %s
+ AND deleted_at IS NULL
+ LIMIT 1
+ """,
+ (customer_id, checksum),
+ )
+ if existing and customer_id:
+ target.unlink(missing_ok=True)
+ return {
+ "status": "duplicate",
+ "document_id": int(existing["id"]),
+ "message": "Filen er allerede uploadet paa denne kunde",
+ }
+ if not customer_id:
+ existing_shared = execute_query_single(
+ """
+ SELECT id
+ FROM internet_connections_customer_documents
+ WHERE customer_id IS NULL
+ AND checksum = %s
+ AND deleted_at IS NULL
+ LIMIT 1
+ """,
+ (checksum,),
+ )
+ if existing_shared:
+ target.unlink(missing_ok=True)
+ return {
+ "status": "duplicate",
+ "document_id": int(existing_shared["id"]),
+ "message": "Filen er allerede uploadet i det delte arkiv",
+ }
+
+ extracted_text = await ollama_service._extract_text_from_file(target)
+ mime_type = ollama_service._get_mime_type(target)
+ document_id = execute_insert(
+ """
+ INSERT INTO internet_connections_customer_documents (
+ customer_id,
+ connection_id,
+ filename,
+ original_filename,
+ file_path,
+ file_size,
+ mime_type,
+ checksum,
+ extracted_text,
+ notes
+ )
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING id
+ """,
+ (
+ customer_id,
+ connection_id,
+ stored_name,
+ original_filename,
+ str(target),
+ total_size,
+ mime_type,
+ checksum,
+ extracted_text,
+ str(notes or "").strip() or None,
+ ),
+ )
+ segment_count = _index_customer_document_segments(int(document_id), extracted_text)
+ return {
+ "status": "uploaded",
+ "document_id": int(document_id),
+ "filename": original_filename,
+ "segment_count": segment_count,
+ "customer": {"id": int(customer["id"]), "name": customer["name"]} if customer else None,
+ }
+ except HTTPException:
+ raise
+ except Exception as exc:
+ target.unlink(missing_ok=True)
+ logger.exception("Customer document upload failed")
+ raise HTTPException(status_code=500, detail=f"Upload failed: {exc}") from exc
+
+
+@router.get("/internet-connections/migration-wizard-v2/context")
+async def get_migration_wizard_v2_context(
+ customer_id: Optional[int] = Query(None, ge=1),
+ query: Optional[str] = Query(None),
+):
+ customer = None
+ customer_name = ""
+ if customer_id:
+ customer = execute_query_single(
+ """
+ SELECT id, name
+ FROM customers
+ WHERE id = %s
+ AND is_active = true
+ """,
+ (customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Customer not found")
+ customer_name = str(customer.get("name") or "")
+
+ if customer_id:
+ document_payload = await _build_customer_document_hits(customer_id, customer_name, query)
+ invoice_hits = await _build_invoice_hits(customer_id, customer_name, query)
+ else:
+ rows = execute_query(
+ """
+ SELECT id, customer_id, original_filename, file_size, mime_type, notes, created_at, extracted_text
+ FROM internet_connections_customer_documents
+ WHERE deleted_at IS NULL
+ ORDER BY created_at DESC, id DESC
+ LIMIT 50
+ """
+ ) or []
+ documents = []
+ for row in rows:
+ item = dict(row)
+ item["segment_count"] = _ensure_document_segments(int(item["id"]), item.get("extracted_text"))
+ item.pop("extracted_text", None)
+ documents.append(item)
+ segment_rows = execute_query(
+ """
+ SELECT
+ seg.id AS segment_id,
+ seg.document_id,
+ seg.block_index,
+ seg.block_title AS title,
+ seg.content,
+ seg.ip_addresses,
+ seg.cidr_blocks,
+ seg.references_json AS references,
+ seg.socket_numbers
+ FROM internet_connections_customer_document_segments seg
+ JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id
+ WHERE doc.deleted_at IS NULL
+ ORDER BY doc.created_at DESC, seg.block_index ASC
+ LIMIT 30
+ """
+ ) or []
+ segment_hits = []
+ for row in segment_rows:
+ snippet = re.sub(r"\s+", " ", str(row.get("content") or "")[:240]).strip()
+ segment_hits.append(
+ {
+ "segment_id": int(row["segment_id"]),
+ "document_id": int(row["document_id"]),
+ "block_index": int(row.get("block_index") or 0),
+ "title": row.get("title") or f"Blok {int(row.get('block_index') or 0) + 1}",
+ "score": 0,
+ "snippet": snippet,
+ "ip_addresses": [str(item) for item in (row.get("ip_addresses") or [])],
+ "cidr_blocks": [str(item) for item in (row.get("cidr_blocks") or [])],
+ "references": [str(item) for item in (row.get("references") or [])],
+ "socket_numbers": [str(item) for item in (row.get("socket_numbers") or [])],
+ }
+ )
+ document_payload = {"documents": documents, "snippets": [], "segments": segment_hits}
+ invoice_hits = []
+
+ summary_source_parts = []
+ if document_payload["snippets"]:
+ summary_source_parts.append("Kundefiler:\n" + "\n".join(document_payload["snippets"][:8]))
+ if invoice_hits:
+ invoice_lines = []
+ for item in invoice_hits[:5]:
+ base = f"{item['invoice_date']} {item['vendor_name']} {item['invoice_number']}"
+ if item["snippets"]:
+ invoice_lines.append(base + ": " + " | ".join(item["snippets"]))
+ else:
+ invoice_lines.append(base)
+ summary_source_parts.append("Fakturaer:\n" + "\n".join(invoice_lines))
+
+ ai_summary = ""
+ if summary_source_parts:
+ summary_input = (
+ f"Kunde: {customer_name}\n"
+ f"Sporgsmaal: {query or 'Vis relevant historik om internetforbindelser, adresser og gamle noter'}\n\n"
+ + "\n\n".join(summary_source_parts)
+ )
+ ai_summary = await ollama_service.generate_summary(summary_input)
+
+ return {
+ "customer": {"id": int(customer["id"]), "name": customer_name} if customer else None,
+ "query": query,
+ "documents": document_payload["documents"],
+ "document_snippets": document_payload["snippets"],
+ "segment_hits": document_payload["segments"],
+ "invoice_hits": invoice_hits,
+ "ai_summary": ai_summary,
+ }
diff --git a/app/modules/internet_connections/frontend/views.py b/app/modules/internet_connections/frontend/views.py
new file mode 100644
index 0000000..b3ad5a5
--- /dev/null
+++ b/app/modules/internet_connections/frontend/views.py
@@ -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"},
+ )
diff --git a/app/modules/internet_connections/module.json b/app/modules/internet_connections/module.json
new file mode 100644
index 0000000..929435e
--- /dev/null
+++ b/app/modules/internet_connections/module.json
@@ -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"]
+}
diff --git a/app/modules/internet_connections/templates/detail.html b/app/modules/internet_connections/templates/detail.html
new file mode 100644
index 0000000..bdc8cd7
--- /dev/null
+++ b/app/modules/internet_connections/templates/detail.html
@@ -0,0 +1,2104 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Forbindelsesdetaljer{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Internetforbindelse
+
Indlæser...
+
Henter forbindelsesdata...
+
+
+
+
+ Tilbage
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dækningsbidrag
+
0 kr.
+
+
+
+
+
+
Båndbredde i brug
+
0 / 0 Mbps
+
0%
+
+
+
+
+
+
+
+
+
Grunddata
+ -
+
+
+
+
+
+
+
+
+
+
+
IP-adresser
+
Indlæser...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | IP |
+ Status |
+ Abonnement |
+ Firma |
+ |
+
+
+
+ | Indlæser... |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/modules/internet_connections/templates/index.html b/app/modules/internet_connections/templates/index.html
new file mode 100644
index 0000000..d966dbd
--- /dev/null
+++ b/app/modules/internet_connections/templates/index.html
@@ -0,0 +1,633 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Internetforbindelser{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Økonomi / Drift / Support
+
Internetforbindelser
+
Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Dækningsbidrag
+
0 kr.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Indlæser forbindelser...
+
+
+
Klik på en række for at åbne forbindelsesdetaljer.
+
+
+
+
+
+
+
+ | Forbindelse |
+ Kunde |
+ Leverandør / kredsløb |
+ Hastighed / IP |
+ Økonomi |
+ Status |
+ |
+
+
+
+
+ | Indlæser... |
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/modules/internet_connections/templates/migration_wizard_v2.html b/app/modules/internet_connections/templates/migration_wizard_v2.html
new file mode 100644
index 0000000..65d6beb
--- /dev/null
+++ b/app/modules/internet_connections/templates/migration_wizard_v2.html
@@ -0,0 +1,557 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Internet Wizard v2{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
Data migration / Internet
+
Internet Wizard v2
+
Midlertidig research-wizard til gamle kundetekster, internetnoter og leverandørfakturaer fra de sidste 18 måneder.
+
+
+
+ Temp version
+
+
+
+
+
+
+
+
+
+
+
+
Ingen kunde valgt endnu.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Internetfakturaer
+
0
+
+
+
+
+
+
+
+
+
+
AI-overblik
+
Kort opsummering fra kundefiler og relevante internetfakturaer.
+
+
+
Vælg en kunde for at hente kontekst.
+
+
+
+
+
+
Upload tekstfiler
+
Upload delte batch-filer med flere kunder. Systemet splitter dem i søgbare blokke med fx IP, CIDR, stiknr og referencer.
+
+
+
+
+
+
+
+
+
+
+
+
+
Ingen upload kørt endnu.
+
+
+
+
+
+
+
+
+
+
Kundetekster
+
Uploadede filer og de bedste fund fra dem.
+
+
+
+
Ingen dokumenter endnu.
+
+
+
+
+
+
+
+
Præcise blokfund
+
Her finder du den konkrete tekstblok med fx IP, stiknr eller reference.
+
+
+
+
Ingen blokfund endnu.
+
+
+
+
+
+
+
+
Relaterede fakturaer
+
De sidste 18 måneder med internet/bredbånd-relateret tekst.
+
+
+
+
Ingen fakturafund endnu.
+
+
+
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index 5a1a752..7b8964f 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -7544,6 +7544,7 @@
+