import ipaddress import hashlib import io import logging import re import zipfile import xml.etree.ElementTree as ET from datetime import date, datetime, timedelta from decimal import Decimal, InvalidOperation 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.modules.internet_connections.backend.change_case_service import ensure_external_change_case 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", ) IP_NORDIC_IMPORT_HEADERS = { "Company", "Name", "Startdate", "Salgspris", "Kostpris", "InstallationAddress" } def _excel_column_name(cell_reference: str) -> str: match = re.match(r"[A-Z]+", str(cell_reference or "").upper()) return match.group(0) if match else "" def _parse_ip_nordic_xlsx(content: bytes) -> List[Dict[str, Any]]: if len(content) > 10 * 1024 * 1024: raise HTTPException(status_code=413, detail="Excel-filen må højst fylde 10 MB") namespace = "{http://schemas.openxmlformats.org/spreadsheetml/2006/main}" try: with zipfile.ZipFile(io.BytesIO(content)) as archive: shared_strings: List[str] = [] if "xl/sharedStrings.xml" in archive.namelist(): shared_root = ET.fromstring(archive.read("xl/sharedStrings.xml")) shared_strings = [ "".join(node.text or "" for node in item.iter(f"{namespace}t")) for item in shared_root.findall(f"{namespace}si") ] sheet_root = ET.fromstring(archive.read("xl/worksheets/sheet1.xml")) except (KeyError, zipfile.BadZipFile, ET.ParseError) as exc: raise HTTPException(status_code=400, detail="Filen er ikke en gyldig IP Nordic Excel-fil") from exc raw_rows: List[Dict[str, str]] = [] for row in sheet_root.findall(f".//{namespace}sheetData/{namespace}row"): values: Dict[str, str] = {} for cell in row.findall(f"{namespace}c"): column = _excel_column_name(cell.attrib.get("r", "")) value_node = cell.find(f"{namespace}v") value = value_node.text if value_node is not None and value_node.text is not None else "" if cell.attrib.get("t") == "s" and value: value = shared_strings[int(value)] elif cell.attrib.get("t") == "inlineStr": value = "".join(node.text or "" for node in cell.iter(f"{namespace}t")) values[column] = value raw_rows.append(values) if not raw_rows: raise HTTPException(status_code=400, detail="Excel-filen er tom") headers = {column: str(value).strip() for column, value in raw_rows[0].items()} if not IP_NORDIC_IMPORT_HEADERS.issubset(set(headers.values())): raise HTTPException(status_code=400, detail="Excel-filen mangler de forventede IP Nordic-kolonner") columns = {header: column for column, header in headers.items()} grouped: Dict[tuple[str, str], Dict[str, Any]] = {} for row_number, raw in enumerate(raw_rows[1:], start=2): address = re.sub(r"\s+", " ", str(raw.get(columns["InstallationAddress"], "")).strip()) company_number = str(raw.get(columns["Company"], "")).strip() reported_company = str(raw.get(columns["Name"], "")).strip() if not address: continue key = (company_number, _normalize_service_location(address)) item = grouped.setdefault(key, { "company_number": company_number, "reported_company": reported_company, "address": address, "start_date": None, "sales_price": Decimal("0"), "monthly_cost": Decimal("0"), "line_count": 0, }) item["line_count"] += 1 date_value = str(raw.get(columns["Startdate"], "")).strip() if date_value: try: parsed_date = (datetime(1899, 12, 30) + timedelta(days=float(date_value))).date() if item["start_date"] is None or parsed_date < item["start_date"]: item["start_date"] = parsed_date except ValueError: raise HTTPException(status_code=400, detail=f"Ugyldig startdato på række {row_number}") for header, target in (("Salgspris", "sales_price"), ("Kostpris", "monthly_cost")): raw_amount = str(raw.get(columns[header], "")).strip() if raw_amount and raw_amount.upper() != "NULL": try: item[target] += Decimal(raw_amount) except InvalidOperation as exc: raise HTTPException(status_code=400, detail=f"Ugyldigt beløb på række {row_number}") from exc return list(grouped.values()) def _normalize_service_location(value: Optional[str]) -> str: normalized = str(value or "").lower() normalized = normalized.replace("æ", "ae").replace("ø", "oe").replace("å", "aa") return re.sub(r"[^a-z0-9]+", "", normalized) def _address_match_components(value: Optional[str]) -> Dict[str, Any]: text = str(value or "").strip().lower() text = text.replace("boulevard", "blv").replace("allé", "alle") postal_match = re.search(r"\b(\d{4})\b", text) postal_code = postal_match.group(1) if postal_match else "" street_part = text.split(postal_code, 1)[0] if postal_code else text house_numbers = [int(number) for number in re.findall(r"\b(\d{1,4})\b", street_part)] street_name = re.sub(r"\b\d{1,4}\b", " ", street_part) street_name = re.sub(r"\b(st|sal|th|tv|mf)\b", " ", street_name) return { "postal_code": postal_code, "street_name": _normalize_service_location(street_name), "house_numbers": house_numbers, } class InvoiceSyncReviewRequest(BaseModel): line_number: int action: str connection_id: Optional[int] = None note: Optional[str] = None 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 = [] raw_cidr_values = re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\s*/\s*\d{1,2}\b", block) cidr_literal_ips = {re.sub(r"\s+", "", raw).split("/", 1)[0] for raw in raw_cidr_values} for raw in raw_cidr_values: try: normalized = str(ipaddress.ip_network(re.sub(r"\s+", "", raw), strict=False)) except ValueError: continue 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 normalized in cidr_literal_ips: 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: # A block is only relevant when it contains every word from the user's # search. A query such as "sales management" must not return a block # containing just one of the two words. all_query_terms_match = all( term in _normalize_text_for_match(searchable) for term in terms["query_terms"] ) if not all_query_terms_match: 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 _sync_bmcnet_parent_classification(parent_connection_id: Optional[int]) -> bool: """Derive a head connection's classification from its non-deleted BMCnet children.""" if not parent_connection_id: return False parent = execute_query_single( """ SELECT id, allocation_model, value_type, value_label, is_manual_shared FROM internet_connections_connections WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL LIMIT 1 """, (parent_connection_id,), ) if not parent: return False child_stats = execute_query_single( """ SELECT COUNT(*) AS child_count FROM internet_connections_connections WHERE parent_id = %s AND deleted_at IS NULL AND ( value_type = 'subscription' OR LOWER(COALESCE(value_label, '')) IN ('bmcnet', 'bmc networks') ) """, (parent_connection_id,), ) or {} child_count = int(child_stats.get("child_count") or 0) should_be_shared = child_count > 0 or bool(parent.get("is_manual_shared")) allocation_model = "shared" if should_be_shared else "dedicated" value_type = "delefiber" if should_be_shared else "other" value_label = None if should_be_shared else "Internetforbindelse" if ( parent.get("allocation_model") == allocation_model and parent.get("value_type") == value_type and parent.get("value_label") == value_label ): return False execute_query( """ UPDATE internet_connections_connections SET allocation_model = %s, value_type = %s, value_label = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s AND parent_id IS NULL AND deleted_at IS NULL """, (allocation_model, value_type, value_label, parent_connection_id), ) _create_history_entry( int(parent_connection_id), "bmcnet_classification_changed", "Hovedforbindelsen blev klassificeret som delefiber" if should_be_shared else "Hovedforbindelsen blev klassificeret som dedikeret", { "bmcnet_child_count": child_count, "allocation_model": allocation_model, "value_type": value_type, }, ) return True 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: raise HTTPException(status_code=400, detail="value_label is required when value_type is other") 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 normalized["is_manual_shared"] = bool( normalized.get("is_manual_shared") and not normalized.get("parent_id") and allocation_model == "shared" and value_type == "delefiber" ) return normalized def _connection_select_sql(where_sql: str = "") -> str: return f""" SELECT ic.id, ic.parent_id, ic.name, ic.provider, ic.vendor_id, vendor.name AS vendor_name, 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.is_manual_shared, ic.subscription_id, ic.sla_subscription_id, sub.subscription_number, sub.product_name AS subscription_product_name, subc.name AS subscription_customer_name, sla.subscription_number AS sla_subscription_number, sla.product_name AS sla_product_name, sla.price AS sla_price, sla.status AS sla_status, 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 vendors vendor ON vendor.id = ic.vendor_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 sag_subscriptions sla ON sla.id = ic.sla_subscription_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 vendor_id: Optional[int] = 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 sla_subscription_id: Optional[int] = None is_manual_shared: bool = False class ConnectionUpdatePayload(BaseModel): name: Optional[str] = None provider: Optional[str] = None vendor_id: Optional[int] = 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 sla_subscription_id: Optional[int] = None is_manual_shared: Optional[bool] = None class PricingCreatePayload(BaseModel): effective_from: date purchase_price: float = 0 sales_price: float = 0 notes: Optional[str] = None def _apply_internet_vendor(normalized: dict) -> dict: vendor_id = normalized.get("vendor_id") if not vendor_id: normalized["vendor_id"] = None return normalized vendor = execute_query_single( "SELECT id, name FROM vendors WHERE id = %s AND is_active = TRUE AND is_internet_provider = TRUE", (vendor_id,), ) if not vendor: raise HTTPException(status_code=409, detail="Den valgte leverandør er ikke markeret som internetleverandør") normalized["vendor_id"] = int(vendor["id"]) normalized["provider"] = vendor["name"] return normalized 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 billing_schedule_type: str = "fixed_day" billing_direction: str = "forward" advance_months: int = 1 billing_lead_months: int = 0 first_invoice_policy: str = "start_date" start_date: date period_start: Optional[date] = None first_full_period_start: Optional[date] = None end_date: Optional[date] = None notice_period_days: int = 30 binding_months: int = 0 binding_start_date: Optional[date] = None 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 class QuickInternetSetupPayload(BaseModel): customer_id: Optional[int] = None product_id: Optional[int] = None start_date: Optional[date] = None shared_fiber: bool = False connection_id: Optional[int] = None connection_name: Optional[str] = None vendor_id: Optional[int] = None circuit_number: Optional[str] = None address: Optional[str] = None technology: Optional[str] = None download_mbps: Optional[int] = None upload_mbps: Optional[int] = None monthly_cost: float = 0 unit_price: Optional[float] = None billing_interval: str = "monthly" billing_day: int = 1 notes: Optional[str] = None class DelefiberProductPricePayload(BaseModel): product_id: int monthly_price: float notes: Optional[str] = None 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/invoice-sync-runs") async def list_internet_invoice_sync_runs( status: Optional[str] = Query(None), limit: int = Query(100, ge=1, le=500), ): """Show GlobalConnect invoices and their latest internet-sync outcome.""" rows = execute_query( """ WITH raw_invoice_keys AS ( SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id, si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name, si.invoice_date, si.total_amount, si.currency, si.created_at FROM supplier_invoices si LEFT JOIN extractions e ON e.extraction_id = si.extraction_id WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%' UNION ALL SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number, run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at FROM internet_connections_invoice_sync_runs run WHERE NOT EXISTS ( SELECT 1 FROM supplier_invoices si WHERE si.id = run.supplier_invoice_id OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, '')) ) ), invoice_keys AS ( SELECT DISTINCT ON ( TRIM(COALESCE(invoice_number, '')), LOWER(TRIM(COALESCE(vendor_name, ''))) ) * FROM raw_invoice_keys ORDER BY TRIM(COALESCE(invoice_number, '')), LOWER(TRIM(COALESCE(vendor_name, ''))), supplier_invoice_id NULLS LAST, created_at DESC ) SELECT key.*, COALESCE( latest.status, CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END, CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END ) AS processing_status, latest.id AS run_id, latest.connections_synced, latest.connections_created, latest.connections_updated, latest.ip_ranges_synced, latest.total_lines, latest.actionable_lines, latest.skipped_lines, COALESCE(latest.error_message, file.error_message) AS error_message, COALESCE(latest.processed_at, file.processed_at, key.created_at) AS processed_at, legacy.connection_count AS legacy_connection_count, COALESCE(review.resolved_lines, 0) AS resolved_lines, GREATEST(COALESCE(latest.skipped_lines, 0) - COALESCE(review.resolved_lines, 0), 0) AS unresolved_lines, COALESCE(review.decisions, '[]'::jsonb) AS review_decisions FROM invoice_keys key LEFT JOIN incoming_files file ON file.file_id = key.file_id LEFT JOIN LATERAL ( SELECT run.* FROM internet_connections_invoice_sync_runs run WHERE (key.supplier_invoice_id IS NOT NULL AND run.supplier_invoice_id = key.supplier_invoice_id) OR (key.extraction_id IS NOT NULL AND run.extraction_id = key.extraction_id) OR (run.invoice_number = key.invoice_number AND COALESCE(run.vendor_name, '') = COALESCE(key.vendor_name, '')) ORDER BY run.processed_at DESC, run.id DESC LIMIT 1 ) latest ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(DISTINCT history.connection_id)::integer AS connection_count FROM internet_connections_history history WHERE key.invoice_number IS NOT NULL AND history.summary ILIKE ('%%' || key.invoice_number || '%%') AND history.event_type IN ( 'connection_created_from_supplier_invoice', 'supplier_invoice_sync_changed', 'supplier_invoice_ip_range_changed', 'supplier_invoice_ip_range_created' ) ) legacy ON TRUE LEFT JOIN LATERAL ( SELECT COUNT(*)::integer AS resolved_lines, COALESCE( jsonb_agg(jsonb_build_object( 'line_number', decision.line_number, 'action', decision.action, 'connection_id', decision.connection_id, 'note', decision.note, 'resolved_at', decision.resolved_at ) ORDER BY decision.line_number), '[]'::jsonb ) AS decisions FROM internet_connections_invoice_review_decisions decision WHERE decision.run_id = latest.id ) review ON TRUE WHERE (%s IS NULL OR COALESCE( latest.status, CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END, CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END ) = %s) ORDER BY COALESCE(latest.processed_at, file.processed_at, key.created_at) DESC LIMIT %s """, (status, status, limit), ) or [] items = [dict(row) for row in rows] return { "items": items, "summary": { "total": len(items), "success": sum(item.get("processing_status") in {"success", "legacy_success"} for item in items), "warnings": sum(item.get("processing_status") in {"warning", "skipped", "not_logged"} for item in items), "errors": sum(item.get("processing_status") == "error" for item in items), }, } @router.get("/internet-connections/invoice-sync-runs/{run_id:int}") async def get_internet_invoice_sync_run(run_id: int): run = execute_query_single( """ SELECT id, invoice_number, status, skipped_lines, result_json FROM internet_connections_invoice_sync_runs WHERE id = %s """, (run_id,), ) if not run: raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet") decisions = execute_query( """ SELECT line_number, action, connection_id, note, resolved_at FROM internet_connections_invoice_review_decisions WHERE run_id = %s ORDER BY line_number """, (run_id,), ) or [] payload = dict(run) payload["review_decisions"] = [dict(item) for item in decisions] payload["resolved_lines"] = len(decisions) payload["unresolved_lines"] = max(int(run.get("skipped_lines") or 0) - len(decisions), 0) return payload @router.post("/internet-connections/invoice-sync-runs/reconcile") async def reconcile_internet_invoice_reviews(): """Resolve stale IP review lines when the exact range is already allocated.""" runs = execute_query( """ SELECT id, result_json FROM internet_connections_invoice_sync_runs WHERE status IN ('warning', 'skipped') ORDER BY processed_at DESC, id DESC """ ) or [] resolved = 0 completed_runs = 0 for run in runs: result = run.get("result_json") or {} skipped_items = result.get("skipped_items") or [] for item in skipped_items: if item.get("classification") != "ip_range": continue cidr = str(item.get("ip_address") or "").strip() line_number = int(item.get("line_number") or 0) if not cidr or not line_number: continue try: cidr = str(ipaddress.ip_network(cidr, strict=False)) except ValueError: continue existing = execute_query_single( """ SELECT ir.connection_id FROM internet_connections_ip_ranges ir JOIN internet_connections_connections ic ON ic.id = ir.connection_id AND ic.deleted_at IS NULL WHERE ir.deleted_at IS NULL AND HOST(ir.cidr::cidr) = HOST(%s::cidr) AND MASKLEN(ir.cidr::cidr) = MASKLEN(%s::cidr) ORDER BY ir.id DESC LIMIT 1 """, (cidr, cidr), ) if not existing: continue prior = execute_query_single( """ SELECT 1 FROM internet_connections_invoice_review_decisions WHERE run_id = %s AND line_number = %s """, (run["id"], line_number), ) if prior: continue execute_query( """ INSERT INTO internet_connections_invoice_review_decisions (run_id, line_number, action, connection_id, note) VALUES (%s, %s, 'link_existing', %s, %s) ON CONFLICT (run_id, line_number) DO NOTHING """, (run["id"], line_number, existing["connection_id"], "Automatisk løst: IP-rangen er allerede allokeret."), fetch=False, ) resolved += 1 decision_count = execute_query_single( "SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s", (run["id"],), ) or {"count": 0} if skipped_items and int(decision_count.get("count") or 0) >= len(skipped_items): execute_query( "UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s", (run["id"],), fetch=False, ) completed_runs += 1 return {"resolved_lines": resolved, "completed_runs": completed_runs} @router.post("/internet-connections/invoice-sync-runs/{run_id}/review") async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest): if data.action not in {"ignore", "link_existing", "create_separate"}: raise HTTPException(status_code=400, detail="Ugyldig kontrolhandling") if data.action == "ignore" and not str(data.note or "").strip(): raise HTTPException(status_code=400, detail="Angiv en årsag, når en linje ignoreres") run = execute_query_single( "SELECT * FROM internet_connections_invoice_sync_runs WHERE id = %s", (run_id,), ) if not run: raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet") result = run.get("result_json") or {} skipped_items = result.get("skipped_items") or [] audit_item = next( (item for item in skipped_items if int(item.get("line_number") or -1) == data.line_number), None, ) if not audit_item: raise HTTPException(status_code=404, detail="Kontrollinjen blev ikke fundet") target_connection_id = data.connection_id if data.action in {"link_existing", "create_separate"}: if data.action == "link_existing": if not target_connection_id: raise HTTPException(status_code=400, detail="Vælg en eksisterende forbindelse") target = execute_query_single( "SELECT id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL", (target_connection_id,), ) if not target: raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet") else: reference = str(audit_item.get("provider_reference") or "").strip() service_address = str(audit_item.get("service_address") or "").strip() target_connection_id = execute_insert( """ INSERT INTO internet_connections_connections ( name, provider, address, status, circuit_number, notes, allocation_model, value_type ) VALUES (%s, 'GlobalConnect A/S', %s, 'pending', %s, %s, 'dedicated', 'other') RETURNING id """, ( f"Afventer kontrol · {reference or 'ukendt reference'}", service_address or None, reference or None, f"Oprettet manuelt under kontrol af faktura {run.get('invoice_number')}.", ), ) if audit_item.get("classification") == "ip_range": extraction_line = execute_query_single( """ SELECT * FROM extraction_lines WHERE extraction_id = %s AND line_number = %s ORDER BY line_id DESC LIMIT 1 """, (run.get("extraction_id"), data.line_number), ) if not extraction_line: raise HTTPException(status_code=404, detail="Den oprindelige fakturalinje blev ikke fundet") from app.billing.backend.supplier_invoices import _upsert_globalconnect_ip_range range_id = _upsert_globalconnect_ip_range( int(target_connection_id), dict(extraction_line), str(run.get("invoice_number") or "ukendt"), ) if not range_id: raise HTTPException(status_code=400, detail="IP-rangen kunne ikke forbindes") execute_query( """ INSERT INTO internet_connections_invoice_review_decisions ( run_id, line_number, action, connection_id, note ) VALUES (%s, %s, %s, %s, %s) ON CONFLICT (run_id, line_number) DO UPDATE SET action = EXCLUDED.action, connection_id = EXCLUDED.connection_id, note = EXCLUDED.note, resolved_at = CURRENT_TIMESTAMP """, (run_id, data.line_number, data.action, target_connection_id, (data.note or "").strip() or None), fetch=False, ) resolved = execute_query_single( "SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s", (run_id,), ) or {"count": 0} skipped_count = len(skipped_items) remaining = max(skipped_count - int(resolved.get("count") or 0), 0) if remaining == 0: execute_query( "UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s", (run_id,), fetch=False, ) return { "status": "resolved", "run_id": run_id, "line_number": data.line_number, "action": data.action, "connection_id": target_connection_id, "remaining": remaining, } @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), unallocated_only: bool = Query(False), allocated_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') ) """ if unallocated_only: query += " AND ic.customer_id IS NULL" if allocated_only: query += " AND ic.customer_id IS NOT NULL" 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.exception("Failed to load internet connections") raise HTTPException(status_code=500, detail="Kunne ikke hente internetforbindelser") from exc return [_decorate_connection_row(dict(row)) for row in rows] @router.post("/internet-connections/import/ip-nordic") async def import_ip_nordic_connections(file: UploadFile = File(...), commit: bool = Form(False)): filename = str(file.filename or "") if not filename.lower().endswith(".xlsx"): raise HTTPException(status_code=400, detail="Vælg en .xlsx-fil fra IP Nordic") content = await file.read() import_key = hashlib.sha256(content).hexdigest() items = _parse_ip_nordic_xlsx(content) created_count = 0 updated_count = 0 skipped_count = 0 change_case_ids: set[int] = set() case_errors: List[Dict[str, Any]] = [] preview_items: List[Dict[str, Any]] = [] for item in items: existing = execute_query_single( """ SELECT id, name, address, customer_id, monthly_cost, sales_price, provider, status, technology, connection_type, circuit_number FROM internet_connections_connections WHERE deleted_at IS NULL AND LOWER(COALESCE(provider, '')) = LOWER(%s) AND regexp_replace( replace(replace(replace(LOWER(COALESCE(address, '')), 'æ', 'ae'), 'ø', 'oe'), 'å', 'aa'), '[^a-z0-9]', '', 'g' ) = %s LIMIT 1 """, ("IP Nordic", _normalize_service_location(item["address"])), ) changes: Dict[str, Dict[str, Any]] = {} if existing: desired = { "monthly_cost": item["monthly_cost"], "sales_price": item["sales_price"], } for field, after in desired.items(): before = existing.get(field) if Decimal(str(before or 0)) != Decimal(str(after or 0)): changes[field] = {"from": before, "to": after} action = "update" if changes else ("skip" if existing else "create") connection_id = int(existing["id"]) if existing else None if commit and existing and changes: execute_query( """UPDATE internet_connections_connections SET monthly_cost=%s, sales_price=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s""", (item["monthly_cost"], item["sales_price"], connection_id), fetch=False, ) _create_history_entry( connection_id, "ip_nordic_import_changed", f"Opdateret fra IP Nordic-filen {filename}", {"source_file": filename, "import_key": import_key, "changes": changes}, ) outcome = ensure_external_change_case( connection_id=connection_id, source_type="ip_nordic_spreadsheet", source_key=import_key, source_label=f"IP Nordic import {filename}", source_url="/economy/internet-connections", changes=changes, connection_name=str(existing.get("name") or f"IP Nordic · {item['address']}"), reference=str(existing.get("circuit_number") or item["address"]), provider="IP Nordic", owner_customer_id=existing.get("customer_id"), ) if outcome.get("case_id"): change_case_ids.add(int(outcome["case_id"])) if outcome.get("error"): case_errors.append({"connection_id": connection_id, "address": item["address"], "error": outcome["error"]}) updated_count += 1 elif commit and not existing: notes = ( f"Importeret fra {filename}. Leverandørens firmanr.: {item['company_number']}. " f"Rapporteret firma: {item['reported_company']}. {item['line_count']} regnearkslinje(r) samlet. " "Kunde tildeles aldrig automatisk. Kredsløbsnummer og teknologi kræver manuel kontrol." ) rows = execute_query( """ INSERT INTO internet_connections_connections ( name, provider, customer_id, address, status, monthly_cost, sales_price, technology, connection_type, contract_start, notes, allocation_model, value_type, value_label ) VALUES (%s, %s, NULL, %s, 'pending', %s, %s, 'Ukendt', 'other', %s, %s, 'dedicated', 'other', 'Internetforbindelse') RETURNING id """, ( f"IP Nordic · {item['address']}", "IP Nordic", item["address"], item["monthly_cost"], item["sales_price"], item["start_date"], notes, ), ) or [] if not rows: raise HTTPException(status_code=500, detail=f"Kunne ikke importere {item['address']}") connection_id = int(rows[0]["id"]) _create_history_entry( connection_id, "spreadsheet_connection_created", "IP Nordic-forbindelse oprettet fra leverandørliste", {"source_file": filename, "address": item["address"], "customer_auto_assigned": False}, ) created_count += 1 else: skipped_count += 1 if existing else 0 preview_items.append({ "action": action, "existing_connection_id": connection_id if existing else None, "connection_id": connection_id, "company_number": item["company_number"], "reported_company": item["reported_company"], "address": item["address"], "start_date": item["start_date"].isoformat() if item["start_date"] else None, "sales_price": float(item["sales_price"]), "monthly_cost": float(item["monthly_cost"]), "line_count": item["line_count"], "changes": changes, }) return { "committed": commit, "provider": "IP Nordic", "items": preview_items, "total": len(preview_items), "create_count": sum(1 for item in preview_items if item["action"] == "create"), "existing_count": sum(1 for item in preview_items if item["action"] in {"skip", "update"}), "created_count": created_count, "updated_count": updated_count, "skipped_count": skipped_count, "change_case_ids": sorted(change_case_ids), "case_errors": case_errors, "requires_manual_review": bool(case_errors), "import_key": import_key, "customer_auto_assignment": False, } @router.post("/internet-connections", response_model=dict) async def create_connection(payload: ConnectionCreatePayload): normalized = _normalize_connection_payload(payload.model_dump()) normalized = _apply_internet_vendor(normalized) if normalized.get("allocation_model") == "shared" and normalized.get("value_type") == "delefiber" and not normalized.get("parent_id"): normalized["is_manual_shared"] = True try: rows = execute_query( """ INSERT INTO internet_connections_connections ( parent_id, name, provider, vendor_id, 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, sla_subscription_id, is_manual_shared ) VALUES (%s, %s, %s, %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("vendor_id"), 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"), normalized.get("sla_subscription_id"), normalized.get("is_manual_shared", False), ), ) 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"), }, ) _sync_bmcnet_parent_classification(normalized.get("parent_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) normalized = _apply_internet_vendor(normalized) if normalized.get("sla_subscription_id"): sla = execute_query_single( """ SELECT id, customer_id, product_name, status FROM sag_subscriptions WHERE id = %s """, (normalized["sla_subscription_id"],), ) if not sla: raise HTTPException(status_code=404, detail="SLA-aftalen blev ikke fundet") if "sla" not in str(sla.get("product_name") or "").lower(): raise HTTPException(status_code=409, detail="Det valgte abonnement er ikke en SLA-aftale") if normalized.get("customer_id") and int(sla["customer_id"]) != int(normalized["customer_id"]): raise HTTPException(status_code=409, detail="SLA-aftalen tilhører en anden kunde") if normalized.get("value_type") == "subscription": subscription = execute_query_single( "SELECT id, customer_id FROM sag_subscriptions WHERE id = %s", (normalized.get("subscription_id"),), ) if not subscription: raise HTTPException(status_code=404, detail="Abonnementet blev ikke fundet") if normalized.get("customer_id") and subscription.get("customer_id") and int(normalized["customer_id"]) != int(subscription["customer_id"]): raise HTTPException(status_code=409, detail="Abonnementet tilhører en anden kunde") already_linked = execute_query_single( """ SELECT id FROM internet_connections_connections WHERE subscription_id = %s AND id <> %s AND deleted_at IS NULL LIMIT 1 """, (normalized.get("subscription_id"), connection_id), ) if already_linked: raise HTTPException(status_code=409, detail=f"Abonnementet er allerede koblet til forbindelse #{already_linked['id']}") set_parts = [] params: list[object] = [] changed: dict[str, object] = {} allowed_fields = set(update_values.keys()) | {"allocation_model", "value_type", "value_label", "subscription_id", "sla_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, ) previous_parent_id = existing.get("parent_id") current_parent_id = normalized.get("parent_id") _sync_bmcnet_parent_classification(previous_parent_id) if current_parent_id != previous_parent_id: _sync_bmcnet_parent_classification(current_parent_id) _sync_bmcnet_parent_classification(connection_id) 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}/cases", response_model=List[dict]) async def list_connection_cases(connection_id: int): """Direct case history only; this never infers or changes the connection's customer.""" if not execute_query_single( "SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,), ): raise HTTPException(status_code=404, detail="Connection not found") return execute_query( """SELECT s.id,s.titel,s.status,s.template_key,s.customer_id,s.updated_at,s.created_at, c.name AS customer_name, COALESCE(NULLIF(TRIM(u.full_name),''),u.username) AS responsible_name, link.created_at AS linked_at FROM sag_internet_connections link JOIN sag_sager s ON s.id=link.sag_id AND s.deleted_at IS NULL LEFT JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id WHERE link.connection_id=%s ORDER BY s.updated_at DESC NULLS LAST,s.id DESC""", (connection_id,), ) or [] @router.get("/internet-connections/{connection_id:int}/cross-field-ports") async def get_connection_cross_field_ports(connection_id: int): """Find cross-field ports related by customer or service-location address.""" connection = execute_query_single( """ SELECT ic.id, ic.customer_id, ic.address, parent.address AS parent_address FROM internet_connections_connections ic LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id WHERE ic.id = %s AND ic.deleted_at IS NULL """, (connection_id,), ) if not connection: raise HTTPException(status_code=404, detail="Connection not found") rows = execute_query( """ SELECT o.id AS outlet_id, o.outlet_number, o.category, o.patch_panel, o.patch_port, o.switch_name, o.switch_port, o.status, o.is_active, o.is_wan, o.customer_id, p.id AS cross_field_port_id, p.port_number, p.port_order, cf.id AS cross_field_id, cf.name AS cross_field_name, l.id AS location_id, l.name AS location_name, l.customer_id AS location_customer_id, l.address_street, l.address_postal_code, l.address_city, h.id AS switch_hardware_id, h.model AS switch_hardware_model FROM locations_wall_outlets o JOIN locations_locations l ON l.id = o.location_id AND l.deleted_at IS NULL LEFT JOIN locations_cross_field_ports p ON p.id = o.cross_field_port_id LEFT JOIN locations_cross_fields cf ON cf.id = p.cross_field_id AND cf.deleted_at IS NULL LEFT JOIN hardware h ON h.id = o.switch_hardware_id AND h.deleted_at IS NULL WHERE o.deleted_at IS NULL AND o.is_active = TRUE AND o.cross_field_port_id IS NOT NULL ORDER BY l.name, cf.display_order, p.port_order, o.id """ ) or [] customer_id = int(connection.get("customer_id") or 0) target_addresses = { _normalize_service_location(value) for value in (connection.get("address"), connection.get("parent_address")) if value } target_addresses.discard("") ports = [] for row in rows: item = dict(row) def clean_location_part(value: object) -> str: text = str(value or "").strip() return "" if text.lower() in {"none", "null", "-"} else text location_address = ", ".join(filter(None, [ clean_location_part(item.get("address_street")), " ".join(filter(None, [ clean_location_part(item.get("address_postal_code")), clean_location_part(item.get("address_city")), ])).strip(), ])) normalized_location = _normalize_service_location(location_address) customer_match = bool( customer_id and customer_id in { int(item.get("customer_id") or 0), int(item.get("location_customer_id") or 0), } ) address_match = bool( normalized_location and any( target == normalized_location or (len(normalized_location) >= 5 and target.startswith(normalized_location)) for target in target_addresses ) ) if not customer_match and not address_match: continue item["network_role"] = "wan" if item.get("is_wan") else "lan" item["is_faulty"] = str(item.get("status") or "").lower() == "faulty" item["location_address"] = location_address or None ports.append(item) return { "items": ports, "summary": { "total": len(ports), "lan": sum(item["network_role"] == "lan" for item in ports), "wan": sum(item["network_role"] == "wan" for item in ports), "faulty": sum(bool(item["is_faulty"]) for item in ports), }, } @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"), customer_id: Optional[int] = Query(None), ): 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]) if customer_id: where.append("s.customer_id = %s") params.append(customer_id) rows = execute_query( f""" SELECT s.id, s.subscription_number, s.product_name, s.customer_id, c.name AS customer_name, s.status, s.price 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/{connection_id:int}/allocation-suggestions") async def connection_allocation_suggestions(connection_id: int): connection = execute_query_single( """ SELECT id, address, 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 connection.get("customer_id"): return {"connection_id": connection_id, "address": connection.get("address"), "items": []} target_address = str(connection.get("address") or "").strip() target_normalized = _normalize_service_location(target_address) target_components = _address_match_components(target_address) rows = execute_query( """ SELECT c.id AS customer_id, c.name AS customer_name, CONCAT_WS(', ', NULLIF(TRIM(c.address), ''), NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address, 'customer' AS address_source, NULL::text AS location_name FROM customers c WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE UNION ALL SELECT c.id AS customer_id, c.name AS customer_name, CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''), NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address, 'location' AS address_source, l.name AS location_name FROM locations_locations l JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE """, (), ) or [] suggestions: Dict[int, Dict[str, Any]] = {} for row in rows: candidate_address = str(row.get("candidate_address") or "").strip() candidate_normalized = _normalize_service_location(candidate_address) if not candidate_normalized: continue candidate_components = _address_match_components(candidate_address) score = 100 if candidate_normalized == target_normalized else 0 if ( not score and target_components["postal_code"] and candidate_components["postal_code"] == target_components["postal_code"] and candidate_components["street_name"] == target_components["street_name"] ): score = 90 target_numbers = target_components["house_numbers"] candidate_numbers = candidate_components["house_numbers"] if target_numbers and candidate_numbers: target_number = target_numbers[0] if target_number not in candidate_numbers and not ( len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_number <= max(candidate_numbers) ): score = 0 if score < 90: continue customer_id = int(row["customer_id"]) existing = suggestions.get(customer_id) candidate = { "customer_id": customer_id, "customer_name": row.get("customer_name"), "address": candidate_address, "address_source": row.get("address_source"), "location_name": row.get("location_name"), "match_score": score, } if not existing or score > int(existing.get("match_score") or 0): suggestions[customer_id] = candidate items = sorted(suggestions.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower())) return {"connection_id": connection_id, "address": target_address, "items": items} @router.get("/internet-connections/allocation-overview") async def internet_connection_allocation_overview(): """Return compact address suggestions for the unallocated work queue.""" connections = execute_query( """ SELECT id, address FROM internet_connections_connections WHERE deleted_at IS NULL AND customer_id IS NULL AND NOT (parent_id IS NULL AND allocation_model = 'shared' AND value_type = 'delefiber') ORDER BY address, id """ ) or [] candidate_rows = execute_query( """ SELECT c.id AS customer_id, c.name AS customer_name, CONCAT_WS(', ', NULLIF(TRIM(c.address), ''), NULLIF(TRIM(CONCAT_WS(' ', c.postal_code, c.city)), '')) AS candidate_address, 'customer' AS address_source, NULL::text AS location_name FROM customers c WHERE c.deleted_at IS NULL AND COALESCE(c.is_active, TRUE) = TRUE UNION ALL SELECT c.id AS customer_id, c.name AS customer_name, CONCAT_WS(', ', NULLIF(TRIM(l.address_street), ''), NULLIF(TRIM(CONCAT_WS(' ', l.address_postal_code, l.address_city)), '')) AS candidate_address, 'location' AS address_source, l.name AS location_name FROM locations_locations l JOIN customers c ON c.id = l.customer_id AND c.deleted_at IS NULL WHERE l.deleted_at IS NULL AND COALESCE(l.is_active, TRUE) = TRUE """ ) or [] items = [] for connection in connections: target_address = str(connection.get("address") or "").strip() target_normalized = _normalize_service_location(target_address) target_components = _address_match_components(target_address) matches: Dict[int, Dict[str, Any]] = {} for row in candidate_rows: candidate_address = str(row.get("candidate_address") or "").strip() candidate_normalized = _normalize_service_location(candidate_address) if not target_normalized or not candidate_normalized: continue candidate_components = _address_match_components(candidate_address) score = 100 if candidate_normalized == target_normalized else 0 if ( not score and target_components["postal_code"] and candidate_components["postal_code"] == target_components["postal_code"] and candidate_components["street_name"] == target_components["street_name"] ): score = 90 target_numbers = target_components["house_numbers"] candidate_numbers = candidate_components["house_numbers"] if target_numbers and candidate_numbers and target_numbers[0] not in candidate_numbers and not ( len(candidate_numbers) >= 2 and min(candidate_numbers) <= target_numbers[0] <= max(candidate_numbers) ): score = 0 if score < 90: continue customer_id = int(row["customer_id"]) candidate = { "customer_id": customer_id, "customer_name": row.get("customer_name"), "address": candidate_address, "address_source": row.get("address_source"), "location_name": row.get("location_name"), "match_score": score, } if customer_id not in matches or score > int(matches[customer_id].get("match_score") or 0): matches[customer_id] = candidate candidates = sorted(matches.values(), key=lambda item: (-item["match_score"], str(item["customer_name"] or "").lower())) items.append({ "connection_id": int(connection["id"]), "address": connection.get("address"), "suggestions": candidates[:5], "unique_suggestion": candidates[0] if len(candidates) == 1 else None, }) return {"items": items} @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.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() _sync_bmcnet_parent_classification(previous_parent_id) if int(previous_parent_id) != int(payload.shared_connection_id): _sync_bmcnet_parent_classification(payload.shared_connection_id) _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.get("/internet-connections/{connection_id}/product-prices", response_model=List[dict]) async def list_delefiber_product_prices(connection_id: int): head = execute_query_single( "SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL", (connection_id,), ) if not head: raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet") return execute_query( """SELECT price.id, price.product_id, price.monthly_price, price.notes, price.is_active, price.updated_at, product.name AS product_name, product.sales_price AS product_sales_price FROM internet_connections_delefiber_product_prices price JOIN products product ON product.id=price.product_id AND product.deleted_at IS NULL WHERE price.connection_id=%s AND price.is_active=true ORDER BY product.name""", (connection_id,), ) or [] @router.post("/internet-connections/{connection_id}/product-prices", response_model=dict) async def upsert_delefiber_product_price(connection_id: int, payload: DelefiberProductPricePayload): head = execute_query_single( "SELECT id FROM internet_connections_connections WHERE id=%s AND parent_id IS NULL AND deleted_at IS NULL", (connection_id,), ) if not head: raise HTTPException(status_code=404, detail="Delefiberen blev ikke fundet") product = execute_query_single("SELECT id FROM products WHERE id=%s AND deleted_at IS NULL", (payload.product_id,)) if not product: raise HTTPException(status_code=404, detail="Produktet blev ikke fundet") if payload.monthly_price < 0: raise HTTPException(status_code=400, detail="Prisen må ikke være negativ") row = execute_query_single( """INSERT INTO internet_connections_delefiber_product_prices (connection_id,product_id,monthly_price,notes,is_active) VALUES (%s,%s,%s,%s,true) ON CONFLICT (connection_id,product_id) DO UPDATE SET monthly_price=EXCLUDED.monthly_price, notes=EXCLUDED.notes, is_active=true, updated_at=NOW() RETURNING id,connection_id,product_id,monthly_price,notes,is_active,updated_at""", (connection_id, payload.product_id, payload.monthly_price, (payload.notes or '').strip() or None), ) return dict(row) @router.delete("/internet-connections/{connection_id}/product-prices/{product_id}", response_model=dict) async def remove_delefiber_product_price(connection_id: int, product_id: int): updated = execute_query( """UPDATE internet_connections_delefiber_product_prices SET is_active=false, updated_at=NOW() WHERE connection_id=%s AND product_id=%s AND is_active=true""", (connection_id, product_id), fetch=False, ) if not updated: raise HTTPException(status_code=404, detail="Prislinjen blev ikke fundet") return {"deleted": True} @router.post("/internet-connections/quick-setup") async def create_quick_internet_setup(payload: QuickInternetSetupPayload): """Create a connection, and for customer connections also create a draft subscription.""" customer_id = payload.customer_id if payload.shared_fiber: own_customer = execute_query_single( """ SELECT c.id, c.name FROM customers c WHERE c.deleted_at IS NULL AND c.is_active = true AND LOWER(TRIM(c.name)) IN ('bmc networks', 'bmc networks aps') ORDER BY (SELECT COUNT(*) FROM sag_sager s WHERE s.customer_id = c.id) DESC, (SELECT COUNT(*) FROM internet_connections_connections ic WHERE ic.customer_id = c.id AND ic.deleted_at IS NULL) DESC, c.id ASC LIMIT 1 """ ) if not own_customer: raise HTTPException(status_code=409, detail="BMC Networks-kunden blev ikke fundet") customer_id = int(own_customer["id"]) if not customer_id: raise HTTPException(status_code=400, detail="Vælg en kunde") 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="Kunden blev ikke fundet") if payload.shared_fiber: name = str(payload.connection_name or "").strip() if not name: raise HTTPException(status_code=400, detail="Skriv et navn til hovedforbindelsen") connection = await create_connection(ConnectionCreatePayload( name=name, vendor_id=payload.vendor_id, customer_id=customer_id, address=str(payload.address or "").strip() or None, status="planned", monthly_cost=float(payload.monthly_cost or 0), sales_price=0, technology=str(payload.technology or "").strip() or None, circuit_number=str(payload.circuit_number or "").strip() or None, download_mbps=payload.download_mbps, upload_mbps=payload.upload_mbps, contract_start=payload.start_date, notes=str(payload.notes or "").strip() or None, allocation_model="shared", value_type="delefiber", value_label="Delefiber", is_manual_shared=True, )) return {"success": True, "sag": None, "subscription": None, "connection": connection} if not payload.product_id or not payload.start_date: raise HTTPException(status_code=400, detail="Vælg internetprodukt og startdato") product = execute_query_single( """ SELECT id, name, short_description, sales_price, attributes_json, type FROM products WHERE id = %s AND deleted_at IS NULL """, (payload.product_id,), ) if not product: raise HTTPException(status_code=404, detail="Internetproduktet blev ikke fundet") profile = build_network_product_profile(product, fallback_text=product.get("short_description")) if profile.get("kind") != "internet_access": raise HTTPException(status_code=400, detail="Det valgte produkt er ikke markeret som internetprodukt") existing_connection = None if payload.connection_id: existing_connection = execute_query_single( """ SELECT id, name, customer_id, subscription_id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL """, (payload.connection_id,), ) if not existing_connection: raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet") if existing_connection.get("subscription_id"): raise HTTPException(status_code=409, detail="Forbindelsen har allerede et abonnement") if existing_connection.get("customer_id") not in (None, customer_id): raise HTTPException(status_code=409, detail="Forbindelsen er tilknyttet en anden kunde") elif not str(payload.connection_name or "").strip(): raise HTTPException(status_code=400, detail="Skriv et navn til den nye forbindelse") customer_name = str(customer.get("name") or f"Kunde #{payload.customer_id}") product_name = str(product.get("name") or "Internet") case_title = str(payload.connection_name or "").strip() or f"Internet - {customer_name}" description = "\n".join(filter(None, [ f"Internetabonnement: {product_name}", f"Forbindelse: {(existing_connection or {}).get('name') or case_title}", f"Adresse: {str(payload.address or '').strip()}" if payload.address else None, str(payload.notes or "").strip() or None, ])) 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, 'abonnement', 'åben', %s, %s, 1) RETURNING * """, (case_title, description, customer_id, _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economy"])), ) if not created_case: raise HTTPException(status_code=500, detail="Abonnementssagen kunne ikke oprettes") resolved_price = float(payload.unit_price) if payload.unit_price is not None else float(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(), "period_start": payload.start_date.isoformat(), "notice_period_days": 30, "line_items": [{ "product_id": int(product["id"]), "description": str(product.get("short_description") or product_name), "quantity": 1, "unit_price": resolved_price, }], "notes": str(payload.notes or "").strip() or None, }) connection_update = ConnectionUpdatePayload( customer_id=customer_id, subscription_id=int(subscription["id"]), value_type="subscription", value_label=None, ) if existing_connection: connection = await update_connection(int(existing_connection["id"]), connection_update) else: connection = await create_connection(ConnectionCreatePayload( name=case_title, vendor_id=payload.vendor_id, customer_id=customer_id, address=str(payload.address or "").strip() or None, status="planned", monthly_cost=float(payload.monthly_cost or 0), sales_price=resolved_price, technology=str(payload.technology or "").strip() or None, circuit_number=str(payload.circuit_number or "").strip() or None, download_mbps=payload.download_mbps, upload_mbps=payload.upload_mbps, contract_start=payload.start_date, notes=str(payload.notes or "").strip() or None, allocation_model="shared" if payload.shared_fiber else "dedicated", value_type="delefiber" if payload.shared_fiber else "subscription", subscription_id=int(subscription["id"]), is_manual_shared=payload.shared_fiber, )) return {"success": True, "sag": dict(created_case), "subscription": subscription, "connection": connection} @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.parent_id IS NULL "), (connection_id,), ) if not head_row: raise HTTPException(status_code=404, detail="Hovedforbindelsen 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") # A local delefiber price is the default offer at this address. An explicit # value in the wizard remains a negotiated customer-specific override. local_price_row = execute_query_single( """SELECT monthly_price FROM internet_connections_delefiber_product_prices WHERE connection_id=%s AND product_id=%s AND is_active=true""", (connection_id, int(payload.internet_product_id)), ) resolved_internet_unit_price = ( float(payload.internet_unit_price) if payload.internet_unit_price is not None else float(local_price_row["monthly_price"]) if local_price_row and local_price_row.get("monthly_price") is not None else float(internet_product.get("sales_price") or 0) ) ip_product = None ip_profile = {} selected_ip_address = None resolved_ip_unit_price = 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") if ip_product: local_ip_price = execute_query_single( """SELECT monthly_price FROM internet_connections_delefiber_product_prices WHERE connection_id=%s AND product_id=%s AND is_active=true""", (connection_id, int(ip_product["id"])), ) resolved_ip_unit_price = ( float(payload.ip_unit_price) if payload.ip_unit_price is not None else float(local_ip_price["monthly_price"]) if local_ip_price and local_ip_price.get("monthly_price") is not None else float(ip_product.get("sales_price") or 0) ) 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": resolved_internet_unit_price, }] 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": resolved_ip_unit_price, }) 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), "billing_schedule_type": payload.billing_schedule_type, "billing_direction": payload.billing_direction, "advance_months": int(payload.advance_months), "billing_lead_months": int(payload.billing_lead_months), "first_invoice_policy": payload.first_invoice_policy, "start_date": payload.start_date.isoformat(), "period_start": (payload.period_start or payload.start_date).isoformat(), "first_full_period_start": (payload.first_full_period_start.isoformat() if payload.first_full_period_start else None), "end_date": (payload.end_date.isoformat() if payload.end_date else None), "notice_period_days": int(payload.notice_period_days), "binding_months": int(payload.binding_months), "binding_start_date": (payload.binding_start_date or payload.period_start or 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.exception("Failed to load IP ranges for connection %s", connection_id) raise HTTPException(status_code=500, detail="Kunne ikke hente IP-ranges") from exc 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" "VIGTIGT: Find altid WAN IP-adressen. Skriv den tydeligt i overblikket, " "eller skriv eksplicit at ingen WAN IP-adresse blev fundet.\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, } @router.get("/internet-connections/customer-documents/segments/{segment_id}") async def get_customer_document_segment(segment_id: int): """Return the complete, indexed text block for the migration wizard.""" row = execute_query_single( """ SELECT seg.id AS segment_id, seg.document_id, seg.block_index, seg.block_title AS title, seg.content, doc.original_filename FROM internet_connections_customer_document_segments seg JOIN internet_connections_customer_documents doc ON doc.id = seg.document_id WHERE seg.id = %s AND doc.deleted_at IS NULL """, (segment_id,), ) if not row: raise HTTPException(status_code=404, detail="Text block not found") return { "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}", "original_filename": row.get("original_filename") or "Tekstfil", "content": str(row.get("content") or ""), }