From adc4fb58761e9ffed704272a0aa0351f112aa6fd Mon Sep 17 00:00:00 2001 From: Christian Date: Sun, 30 Aug 2026 14:34:43 +0200 Subject: [PATCH] Implement tests for sag module, add knowledge base templates, and enhance internet connection migrations - Added multiple test cases for the sag module to ensure proper functionality and data handling. - Created new templates for knowledge detail and knowledge index pages to display articles and solutions. - Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions. - Added a script to reconcile known internet connections with verified data. - Planned the implementation of a new website content administration module for managing customer references and operational status. --- app/billing/backend/supplier_invoices.py | 235 +++++-- app/models/schemas.py | 29 + .../bottom_bar/backend/public_router.py | 15 +- .../internet_connections/backend/router.py | 662 +++++++++++++++++- .../templates/detail.html | 457 ++++++++++-- .../internet_connections/templates/index.html | 276 +++++++- app/modules/sag/backend/router.py | 128 +++- app/modules/sag/backend/solutions.py | 325 ++++++--- app/modules/sag/frontend/views.py | 40 +- app/modules/sag/templates/create.html | 81 +-- app/modules/sag/templates/detail_v3.html | 588 ++++++++++------ app/modules/sag/templates/edit.html | 14 +- app/modules/sag/templates/index.html | 595 ++++++++++++++-- .../sag/templates/knowledge_detail.html | 18 + .../sag/templates/knowledge_index.html | 36 + app/modules/sag/templates/varekob_salg.html | 21 +- app/settings/backend/router.py | 15 +- app/shared/frontend/base.html | 8 +- app/subscriptions/backend/router.py | 6 +- app/timetracking/backend/router.py | 8 +- app/vendors/backend/router.py | 63 +- app/vendors/frontend/vendor_detail.html | 51 +- app/vendors/frontend/vendors.html | 251 +++++-- migrations/1023_user_sag_list_columns.sql | 3 + ..._internet_connections_manual_delefiber.sql | 19 + ..._internet_connections_sla_subscription.sql | 6 + migrations/1026_internet_provider_vendors.sql | 59 ++ .../233_case_solution_knowledge_base.sql | 73 ++ plan-websiteContentAdministration.prompt.md | 34 + .../reconcile_known_internet_connections.py | 100 +++ .../reset_and_rebuild_internet_connections.py | 84 ++- static/js/bottom-bar.js | 60 +- tests/test_globalconnect_internet_sync.py | 99 ++- tests/test_internet_connections_module.py | 300 +++++++- tests/test_sag_module.py | 109 +++ tests/test_timetracking_manual_time.py | 26 +- ...lan-websiteContentAdministration.prompt.md | 0 37 files changed, 4147 insertions(+), 747 deletions(-) create mode 100644 app/modules/sag/templates/knowledge_detail.html create mode 100644 app/modules/sag/templates/knowledge_index.html create mode 100644 migrations/1023_user_sag_list_columns.sql create mode 100644 migrations/1024_internet_connections_manual_delefiber.sql create mode 100644 migrations/1025_internet_connections_sla_subscription.sql create mode 100644 migrations/1026_internet_provider_vendors.sql create mode 100644 migrations/233_case_solution_knowledge_base.sql create mode 100644 plan-websiteContentAdministration.prompt.md create mode 100644 scripts/reconcile_known_internet_connections.py create mode 100644 untitled:plan-websiteContentAdministration.prompt.md diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index 501e739..ebd7232 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -705,6 +705,82 @@ def _normalize_provider_reference(value: Optional[str]) -> str: return re.sub(r"[^A-Z0-9]", "", raw) +def _provider_reference_match_keys(value: Optional[str]) -> set[str]: + normalized = _normalize_provider_reference(value) + if not normalized: + return set() + keys = {normalized} + if normalized.startswith("DSLEB"): + keys.add(normalized[3:]) + elif normalized.startswith("EB"): + keys.add(f"DSL{normalized}") + return keys + + +def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]: + target_keys = _provider_reference_match_keys(reference) + if not target_keys: + return None + rows = execute_query( + """ + SELECT id, circuit_number + FROM internet_connections_connections + WHERE deleted_at IS NULL + AND provider ILIKE 'GlobalConnect%%' + AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL + ORDER BY id + """ + ) or [] + matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))] + return int(matches[0]["id"]) if len(matches) == 1 else None + + +def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]: + display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip() + normalized_reference = _normalize_provider_reference(display_reference) + if not normalized_reference: + return None + existing_id = _find_unique_globalconnect_connection_by_reference(display_reference) + if existing_id: + return existing_id + service_address = _build_service_address(line) + connection_id = execute_insert( + """ + INSERT INTO internet_connections_connections ( + name, provider, customer_id, address, status, monthly_cost, sales_price, + technology, connection_type, circuit_number, notes, allocation_model, + value_type, value_label + ) + VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + f"Afventer mapping · {display_reference}", + "GlobalConnect A/S", + service_address, + "Internet", + "Internet", + display_reference, + f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.", + "dedicated", + "other", + "Afventer manuel klassifikation", + ), + ) + return int(connection_id) if connection_id else None + + +def _canonical_ip_network(value: Optional[str]) -> str: + """Canonicalize invoice IP/CIDR values before matching or persistence.""" + raw = re.sub(r"\s+", "", str(value or "").strip()) + if not raw: + return "" + try: + return str(ipaddress.ip_network(raw, strict=False)) + except ValueError: + return "" + + def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str: parts = [f"Afventer mapping for {reference}."] if end_customer_name: @@ -757,11 +833,9 @@ def _should_assign_internal_bmc_owner( return False if any(str(line.get("end_customer_name") or "").strip() for line in lines): return False - if any(_looks_like_ip_range_line(line) for line in lines): - return True - if len(lines) > 1: - return True - return bool(service_address) + description = " ".join(str(line.get("description") or "").lower() for line in lines) + explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit") + return any(marker in description for marker in explicit_shared_markers) def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str: @@ -1068,17 +1142,21 @@ def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canoni def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]: - matches = execute_query( + canonical_cidr = _canonical_ip_network(cidr) + candidates = execute_query( """ - SELECT id + SELECT id, cidr FROM internet_connections_ip_ranges WHERE connection_id = %s - AND cidr = %s AND deleted_at IS NULL ORDER BY id """, - (connection_id, cidr), - ) + (connection_id,), + ) or [] + matches = [ + row for row in candidates + if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr + ] if not matches: return None @@ -1116,6 +1194,7 @@ def _normalize_service_address_for_match(value: Optional[str]) -> str: def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]: if not reference: return [] + match_keys = sorted(_provider_reference_match_keys(reference)) rows = execute_query( """ SELECT id, customer_id, address, monthly_cost, technology, connection_type, @@ -1124,10 +1203,10 @@ def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]: FROM internet_connections_connections WHERE deleted_at IS NULL AND provider ILIKE 'GlobalConnect%%' - AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s + AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s) ORDER BY id """, - (reference,), + (match_keys,), ) or [] return [dict(row) for row in rows] @@ -1216,44 +1295,30 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ if existing and merge_ids: _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) - matched_customer = _match_customer_for_globalconnect_line(primary_line, customers) - if not matched_customer and existing and existing.get("customer_id"): - # Preserve a previously reviewed owner when the new invoice has no - # unambiguous customer name/address instead of replacing it with BMC. - matched_customer = next( - (customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])), - None, - ) + # Supplier data may suggest a customer name, but customer ownership is + # always a manual CRM decision. Existing manually selected owners survive + # because update SQL uses COALESCE(NULL, customer_id); new records stay NULL. + matched_customer = None description = str(primary_line.get("description") or reference) end_customer_name = str(primary_line.get("end_customer_name") or "").strip() # Internal BMC ownership is only a default for a newly discovered # connection. An existing connection with no customer may deliberately be # unassigned and must not gain an owner merely because a later invoice is # ambiguous. - internal_owner = ( - _resolve_internal_bmc_customer() - if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address) - else None - ) - owner_customer = matched_customer or internal_owner - is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address) - connection_name = ( - end_customer_name or service_address or f"GlobalConnect {reference}" - if is_confident - else (f"{internal_owner['name']} · {display_reference}" if internal_owner else f"Afventer mapping · {display_reference}") - ) + is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address) + internal_owner = None + owner_customer = None + is_confident = False + connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}" monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0")) note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description"))) base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}" mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference) - if internal_owner and not matched_customer: - note_text = f"{base_note} Ejer sat til intern BMC-kunde, da forbindelsen bruges som delt hovedforbindelse eller ikke kan bindes sikkert til én slutkunde." - else: - note_text = base_note if is_confident else f"{base_note} {mapping_note}" + note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk." download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description) - target_status = "active" if (is_confident or internal_owner) else "pending" - shared_value_type = _shared_connection_value_type(internal_owner, matched_customer) + target_status = "pending" + shared_value_type = "delefiber" if is_shared_candidate else "other" payload = ( connection_name, "GlobalConnect A/S", @@ -1267,14 +1332,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ upload_mbps, download_mbps, note_text, - "shared" if internal_owner and not matched_customer else "dedicated", + "shared" if is_shared_candidate else "dedicated", shared_value_type, None, ) if existing: updated_snapshot = { - "customer_id": owner_customer["id"] if owner_customer else None, + "customer_id": existing.get("customer_id"), "address": service_address, "monthly_cost": monthly_cost, "technology": _infer_technology(description), @@ -1284,7 +1349,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ "download_mbps": download_mbps, "upload_mbps": upload_mbps, "status": target_status, - "allocation_model": "shared" if internal_owner and not matched_customer else "dedicated", + "allocation_model": "shared" if is_shared_candidate else "dedicated", "value_type": shared_value_type, "value_label": None, } @@ -1301,7 +1366,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ download_mbps, upload_mbps, note_text, - "shared" if internal_owner and not matched_customer else "dedicated", + "shared" if is_shared_candidate else "dedicated", shared_value_type, None, existing["id"], @@ -1333,7 +1398,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ update_payload[1], update_payload[2], update_payload[3], - "active" if (is_confident or internal_owner) else "pending", + target_status, update_payload[4], update_payload[5], update_payload[6], @@ -1398,7 +1463,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ payload[1], payload[2], payload[3], - "active" if (is_confident or internal_owner) else "pending", + target_status, payload[4], payload[5], payload[6], @@ -1429,13 +1494,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str): - cidr = str(line.get("ip_address") or "").strip() + cidr = _canonical_ip_network(line.get("ip_address")) if not connection_id or not cidr: return None display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip() service_address = _build_service_address(line) - matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching()) + # Never infer range ownership from invoice text. A user must select it. + matched_customer = None canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr) existing = execute_query_single( """ @@ -1593,25 +1659,26 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address: def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]: """Use an existing CIDR as the strongest key, but never cross service addresses.""" - cidr = str(line.get("ip_address") or "").strip() + cidr = _canonical_ip_network(line.get("ip_address")) if not cidr: return {"connection_id": None, "conflict_reason": None} reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) service_address = _build_service_address(line) rows = execute_query( """ - SELECT range.connection_id, range.service_address, range.provider_reference, + SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference, connection.address AS connection_address, connection.circuit_number AS connection_reference FROM internet_connections_ip_ranges range JOIN internet_connections_connections connection ON connection.id = range.connection_id - WHERE range.cidr = %s - AND range.deleted_at IS NULL + WHERE range.deleted_at IS NULL AND connection.deleted_at IS NULL + AND connection.provider ILIKE 'GlobalConnect%%' ORDER BY range.id """, - (cidr,), + (), ) or [] + rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr] if reference: matching_reference = [ row for row in rows @@ -1829,25 +1896,52 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula for audit_index, line in ip_range_lines: reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) service_address = _build_service_address(line) + reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference) existing_range_resolution = _resolve_existing_ip_range_connection(line) - if existing_range_resolution.get("conflict_reason"): + if existing_range_resolution.get("conflict_reason") and not reference_connection_id: skipped_orphan_ip_ranges += 1 line_audit[audit_index]["status"] = "skipped" line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"] continue - connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference) + mapped_reference_connection_id = connection_map.get(reference) + # An existing CIDR on the same service address is stronger evidence than + # a supplier reference. OCR/extraction can accidentally carry a circuit + # number from a neighbouring invoice line. + address_range_connection_id = existing_range_resolution.get("connection_id") + connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id resolved_from_existing = False - if existing_range_resolution.get("connection_id"): + matched_by_reference = bool( + not address_range_connection_id + and (reference_connection_id or mapped_reference_connection_id) + ) + if reference_connection_id or address_range_connection_id: resolved_from_existing = True + corrected_service_address = None if connection_id and not _connection_can_host_ip_range(connection_id, service_address): - connection_id = None - if not connection_id and reference: - if not service_address: - line_audit[audit_index]["status"] = "skipped" - line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range" + if matched_by_reference: + authoritative_connection = execute_query_single( + """ + SELECT address + FROM internet_connections_connections + WHERE id = %s AND deleted_at IS NULL + """, + (connection_id,), + ) or {} + corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None + if not corrected_service_address: + connection_id = None + else: + connection_id = None + if not connection_id: + matched_by_reference = False skipped_orphan_ip_ranges += 1 + line_audit[audit_index]["status"] = "skipped" + line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse" continue - connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address) + if not connection_id and reference: + connection_id, connection_conflict_reason = (None, None) + if service_address: + connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address) if connection_id: connection_map[reference] = connection_id resolved_from_existing = True @@ -1856,8 +1950,29 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula line_audit[audit_index]["status"] = "skipped" line_audit[audit_index]["reason"] = connection_conflict_reason continue + elif not simulate and not reference.startswith("EB"): + connection_id = _create_pending_connection_for_ip_reference(line, invoice_number) + if connection_id: + connection_map[reference] = connection_id + created_or_updated_connections += 1 + created_connections += 1 + line_audit[audit_index]["created_pending_connection"] = True + elif simulate and not reference.startswith("EB"): + connection_id = -(len(connection_map) + 1) + + if matched_by_reference: + line_audit[audit_index]["matched_by"] = "unique_circuit_reference" + if service_address and not _connection_can_host_ip_range(connection_id, service_address): + line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt" sync_line = dict(line) + if corrected_service_address: + sync_line["service_address"] = corrected_service_address + line_audit[audit_index]["address_warning"] = ( + f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse " + f"'{corrected_service_address}'" + ) + line_audit[audit_index]["service_address_corrected"] = True if existing_range_resolution.get("canonical_reference"): sync_line["provider_reference"] = existing_range_resolution["canonical_reference"] sync_line["circuit_id"] = existing_range_resolution["canonical_reference"] diff --git a/app/models/schemas.py b/app/models/schemas.py index 9170762..662b72d 100644 --- a/app/models/schemas.py +++ b/app/models/schemas.py @@ -89,6 +89,7 @@ class VendorBase(BaseModel): priority: Optional[int] = 100 notes: Optional[str] = None is_active: bool = True + is_internet_provider: bool = False class VendorCreate(VendorBase): @@ -103,10 +104,16 @@ class VendorUpdate(BaseModel): domain: Optional[str] = None email: Optional[str] = None phone: Optional[str] = None + address: Optional[str] = None + postal_code: Optional[str] = None + city: Optional[str] = None + website: Optional[str] = None + economic_supplier_number: Optional[int] = None contact_person: Optional[str] = None category: Optional[str] = None notes: Optional[str] = None is_active: Optional[bool] = None + is_internet_provider: Optional[bool] = None class Vendor(VendorBase): @@ -159,6 +166,15 @@ class SolutionBase(BaseModel): description: Optional[str] = None solution_type: Optional[str] = None # Support, Drift, Konsulent, etc. result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst + problem: Optional[str] = None + root_cause: Optional[str] = None + investigation: Optional[str] = None + workaround: Optional[str] = None + visibility: str = "internal" + approval_status: str = "draft" + is_final: bool = True + tags: list[str] = Field(default_factory=list) + products: list[str] = Field(default_factory=list) class SolutionCreate(SolutionBase): """Schema for creating a solution""" @@ -171,6 +187,16 @@ class SolutionUpdate(BaseModel): description: Optional[str] = None solution_type: Optional[str] = None result: Optional[str] = None + problem: Optional[str] = None + root_cause: Optional[str] = None + investigation: Optional[str] = None + workaround: Optional[str] = None + visibility: Optional[str] = None + approval_status: Optional[str] = None + is_final: Optional[bool] = None + tags: Optional[list[str]] = None + products: Optional[list[str]] = None + change_note: Optional[str] = None class Solution(SolutionBase): """Full solution schema""" @@ -179,6 +205,9 @@ class Solution(SolutionBase): created_by_user_id: Optional[int] = None created_at: datetime updated_at: Optional[datetime] = None + updated_by_user_id: Optional[int] = None + approved_by_user_id: Optional[int] = None + approved_at: Optional[datetime] = None model_config = ConfigDict(from_attributes=True) diff --git a/app/modules/bottom_bar/backend/public_router.py b/app/modules/bottom_bar/backend/public_router.py index 493f75e..8a3b71b 100644 --- a/app/modules/bottom_bar/backend/public_router.py +++ b/app/modules/bottom_bar/backend/public_router.py @@ -4,6 +4,7 @@ import logging from typing import Optional from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect +from fastapi.encoders import jsonable_encoder from app.core.auth_service import AuthService from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary @@ -79,14 +80,14 @@ async def bottom_bar_ws(websocket: WebSocket): initial_status = get_dashboard_status() initial_notifications = get_notifications(user_id, limit=20) initial_messages = get_user_messages_summary(user_id, limit=20) - await websocket.send_json({"event": "status_delta", "data": initial_status}) - await websocket.send_json({ + await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status})) + await websocket.send_json(jsonable_encoder({ "event": "notification_delta", "data": { "notifications": initial_notifications, "messages": initial_messages, }, - }) + })) last_status_json = json.dumps(initial_status, sort_keys=True, default=str) last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str) @@ -99,7 +100,7 @@ async def bottom_bar_ws(websocket: WebSocket): timer = get_active_timer(user_id) elapsed = int(timer.get("elapsed") or 0) if elapsed != last_timer_elapsed: - await websocket.send_json({"event": "timer_tick", "data": timer}) + await websocket.send_json(jsonable_encoder({"event": "timer_tick", "data": timer})) last_timer_elapsed = elapsed status_tick += 1 @@ -110,19 +111,19 @@ async def bottom_bar_ws(websocket: WebSocket): status_json = json.dumps(status, sort_keys=True, default=str) if status_json != last_status_json: - await websocket.send_json({"event": "status_delta", "data": status}) + await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": status})) last_status_json = status_json notifications_json = json.dumps(notifications, sort_keys=True, default=str) messages_json = json.dumps(messages, sort_keys=True, default=str) if notifications_json != last_notifications_json or messages_json != last_messages_json: - await websocket.send_json({ + await websocket.send_json(jsonable_encoder({ "event": "notification_delta", "data": { "notifications": notifications, "messages": messages, }, - }) + })) last_notifications_json = notifications_json last_messages_json = messages_json diff --git a/app/modules/internet_connections/backend/router.py b/app/modules/internet_connections/backend/router.py index e860fc0..cad15ae 100644 --- a/app/modules/internet_connections/backend/router.py +++ b/app/modules/internet_connections/backend/router.py @@ -1,7 +1,11 @@ import ipaddress +import io import logging import re -from datetime import date +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 @@ -67,6 +71,88 @@ GENERIC_SEGMENT_TITLE_PATTERNS = ( "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: @@ -75,6 +161,22 @@ def _normalize_service_location(value: Optional[str]) -> str: 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 @@ -209,14 +311,19 @@ def _extract_segment_entities(block: str) -> Dict[str, List[str]]: references = [] socket_numbers = [] - for raw in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}/\d{1,2}\b", block): - normalized = raw.strip() + 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 any(normalized == cidr.split("/")[0] for cidr in cidr_blocks): + if normalized in cidr_literal_ips: continue try: normalized_ip = _normalize_ip_address(normalized) @@ -652,6 +759,72 @@ def _create_history_entry(connection_id: int, event_type: str, summary: str, det ) +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 = [] @@ -743,7 +916,7 @@ def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]: if value_type == "other": if not value_label: - value_label = "Mangler klassifikation" + raise HTTPException(status_code=400, detail="value_label is required when value_type is other") else: value_label = None @@ -752,6 +925,12 @@ def _normalize_connection_payload(payload: Dict[str, Any]) -> Dict[str, Any]: 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 @@ -762,6 +941,8 @@ def _connection_select_sql(where_sql: str = "") -> str: 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, @@ -784,10 +965,16 @@ def _connection_select_sql(where_sql: str = "") -> str: 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, @@ -801,9 +988,11 @@ def _connection_select_sql(where_sql: str = "") -> str: 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, @@ -919,6 +1108,7 @@ def _build_bmcnet_summary(children: List[Dict[str, Any]]) -> Dict[str, Any]: 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 @@ -939,11 +1129,14 @@ class ConnectionCreatePayload(BaseModel): 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 @@ -964,6 +1157,8 @@ class ConnectionUpdatePayload(BaseModel): 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): @@ -973,6 +1168,22 @@ class PricingCreatePayload(BaseModel): 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 @@ -1308,7 +1519,6 @@ async def list_internet_invoice_sync_runs( 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, - latest.result_json, 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, @@ -1373,6 +1583,110 @@ async def list_internet_invoice_sync_runs( } +@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"}: @@ -1495,6 +1809,8 @@ async def list_connections( 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] = [] @@ -1528,33 +1844,130 @@ async def list_connections( 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.warning("Failed to load internet connections: %s", exc) - return [] + 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") + items = _parse_ip_nordic_xlsx(await file.read()) + created_count = 0 + skipped_count = 0 + preview_items: List[Dict[str, Any]] = [] + + for item in items: + existing = execute_query_single( + """ + SELECT id, name, address + 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"])), + ) + action = "skip" if existing else "create" + connection_id = int(existing["id"]) if existing else None + + if 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"], + }) + + 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"] == "skip"), + "created_count": created_count, + "skipped_count": skipped_count, + "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, customer_id, address, status, monthly_cost, sales_price, technology, + 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 + 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) + 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"), @@ -1574,6 +1987,8 @@ async def create_connection(payload: ConnectionCreatePayload): 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: @@ -1598,6 +2013,7 @@ async def create_connection(payload: ConnectionCreatePayload): "subscription_id": normalized.get("subscription_id"), }, ) + _sync_bmcnet_parent_classification(normalized.get("parent_id")) return connection @@ -1623,11 +2039,47 @@ async def update_connection(connection_id: int, payload: ConnectionUpdatePayload ): 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"} + 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") @@ -1661,6 +2113,12 @@ async def update_connection(connection_id: int, payload: ConnectionUpdatePayload 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]) @@ -1860,7 +2318,11 @@ async def pricing_summary(): @router.get("/internet-connections/subscription-options", response_model=List[dict]) -async def subscription_options(q: Optional[str] = Query(None), status: str = Query("active")): +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": @@ -1870,6 +2332,9 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que 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 @@ -1878,7 +2343,8 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que s.product_name, s.customer_id, c.name AS customer_name, - s.status + s.status, + s.price FROM sag_subscriptions s LEFT JOIN customers c ON c.id = s.customer_id WHERE {" AND ".join(where)} @@ -1890,6 +2356,160 @@ async def subscription_options(q: Optional[str] = Query(None), status: str = Que 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) @@ -1942,7 +2562,7 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc raise HTTPException(status_code=409, detail="Du skal vaelge et ledigt IP-range til hver IP-produktlinje") shared_connection = execute_query_single( - _connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "), + _connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "), (payload.shared_connection_id,), ) if not shared_connection: @@ -2289,6 +2909,10 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc 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", @@ -2326,11 +2950,11 @@ async def provision_subscription_connection(subscription_id: int, payload: Subsc @router.post("/internet-connections/{connection_id}/bmcnet-connections") async def create_quick_bmcnet_connection(connection_id: int, payload: QuickBmcnetCreatePayload): head_row = execute_query_single( - _connection_select_sql(" AND ic.id = %s AND ic.allocation_model = 'shared' AND ic.parent_id IS NULL "), + _connection_select_sql(" AND ic.id = %s AND ic.parent_id IS NULL "), (connection_id,), ) if not head_row: - raise HTTPException(status_code=404, detail="Delt hovedforbindelse blev ikke fundet") + raise HTTPException(status_code=404, detail="Hovedforbindelsen blev ikke fundet") head = _decorate_connection_row(dict(head_row)) customer = execute_query_single( @@ -2627,8 +3251,8 @@ async def list_ip_ranges(connection_id: int): (connection_id,), ) or [] except Exception as exc: - logger.warning("Failed to load IP ranges: %s", exc) - return [] + 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: diff --git a/app/modules/internet_connections/templates/detail.html b/app/modules/internet_connections/templates/detail.html index a45be74..286da7a 100644 --- a/app/modules/internet_connections/templates/detail.html +++ b/app/modules/internet_connections/templates/detail.html @@ -12,14 +12,30 @@ border-radius: 24px; padding: 1.5rem; box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08); + position: relative; + overflow: hidden; } + .detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; } + .detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; } + .detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; } + .detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; } + .detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; } + .detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; } + .detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); } + + .detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; } + .detail-metrics-grid > [class*="col-"] { width:auto; padding:0; } + .detail-panel { background: var(--bg-card); border: 1px solid rgba(15, 76, 117, 0.12); border-radius: 20px; box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06); } + .allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); } + .allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; } + .allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; } .detail-metric { background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01)); @@ -27,7 +43,10 @@ border-radius: 16px; padding: 1rem; height: 100%; + position:relative; + overflow:hidden; } + .detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); } .detail-metric-label { color: var(--text-secondary); @@ -55,6 +74,31 @@ padding: 0.85rem; } + #ipRangesList { grid-template-columns: 1fr; } + .ip-range-card { + border-left:4px solid #3282b8; + background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45)); + display:grid; + grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto; + align-items:center; + gap:1rem; + padding:1rem 1.1rem; + } + .ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; } + .ip-range-main,.ip-range-meta { min-width:0; } + .ip-range-meta .label { margin-bottom:.15rem; } + .ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; } + .ip-range-actions { justify-self:end; } + .ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; } + .ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; } + @media (max-width: 1000px) { + .ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); } + .ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; } + .ip-range-actions { justify-self:start; } + } + .ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); } + .ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; } + .detail-grid-card.editing { border-color: rgba(15, 76, 117, 0.28); background: rgba(15, 76, 117, 0.06); @@ -235,6 +279,7 @@ } @media (max-width: 991.98px) { + .detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); } .detail-read-grid { grid-template-columns: 1fr; } @@ -245,6 +290,8 @@ gap: 0.2rem; } } + + @media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } } {% endblock %} @@ -252,11 +299,15 @@
-
+
+
+
Internetforbindelse

Indlæser...

Henter forbindelsesdata...
+
Henter kredsløb...
+
+
-
+
+
+
+
Forbindelsen er ikke tildelt en kunde
+
Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.
+
+
+
+ + +
+ + Åbn kunde +
+
+
+
+
+ +
+
+
+
+ + +
+
+
+
+ +
Salgspris
@@ -314,7 +401,7 @@
-
+
Grunddata
- @@ -327,7 +414,7 @@
- +
@@ -383,6 +470,13 @@
+
+
+ + +
BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.
+
+
-
-
-
+
+
+
+
@@ -580,7 +674,7 @@
-
+
Historik
@@ -832,6 +926,13 @@ } } + function showDetailMessage(message, isError = false) { + const feedback = document.getElementById('detailSaveFeedback'); + feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`; + feedback.textContent = message; + feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + } + async function extractErrorMessage(response, fallback) { try { const payload = await response.clone().json(); @@ -1000,11 +1101,12 @@ } async function loadLookups() { - const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse] = await Promise.all([ + const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([ fetch('/api/v1/customers?limit=1000&is_active=true'), fetch('/api/v1/internet-connections'), fetch('/api/v1/internet-connections/subscription-options?status=active'), fetch('/api/v1/products'), + fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'), ]); const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] }; @@ -1012,6 +1114,9 @@ connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : []; subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : []; bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : []; + const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : []; + document.getElementById('fieldVendorId').innerHTML = '' + internetVendors + .map((vendor) => ``).join(''); populateDatalist('customerLookupList', customerOptions); populateDatalist( @@ -1058,7 +1163,7 @@ } function getSharedHeadOptions() { - return (connectionOptions || []).filter((item) => item?.is_shared_head); + return (connectionOptions || []).filter((item) => !item?.parent_id); } async function loadRangesForSharedHead(sharedHeadId) { @@ -1169,8 +1274,9 @@ } function populateBmcnetWizard(connection) { - document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head); - if (!connection?.is_shared_head) return; + const canCreateBmcnet = Boolean(connection && !connection.parent_id); + document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet); + if (!canCreateBmcnet) return; document.getElementById('bmcnetWizardCustomerLookup').value = ''; document.getElementById('bmcnetWizardCustomerId').value = ''; @@ -1197,7 +1303,7 @@ } function openBmcnetWizard() { - if (!currentConnection?.is_shared_head) return; + if (!currentConnection || currentConnection.parent_id) return; populateBmcnetWizard(currentConnection); if (!bmcnetWizardModal) { bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal')); @@ -1308,6 +1414,111 @@ } } + async function renderAllocationBanner(connection) { + const banner = document.getElementById('allocationBanner'); + const customerSelect = document.getElementById('allocationCustomerSelect'); + const suggestionsWrap = document.getElementById('allocationSuggestions'); + const isBmcSharedFiber = Boolean( + connection.is_manual_shared + || (connection.parent_id == null + && connection.allocation_model === 'shared' + && connection.value_type === 'delefiber') + ); + const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber; + banner.classList.toggle('d-none', !shouldSuggestCustomer); + if (!shouldSuggestCustomer) return; + + const payload = await safeJson( + await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`), + { items: [] } + ); + const suggestions = Array.isArray(payload.items) ? payload.items : []; + const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id))); + const suggestedOptions = suggestions.map((item) => ( + `` + )).join(''); + const otherOptions = customerOptions + .filter((item) => !suggestedIds.has(Number(item.id))) + .map((item) => ``) + .join(''); + customerSelect.innerHTML = '' + + (suggestedOptions ? `${suggestedOptions}` : '') + + `${otherOptions}`; + suggestionsWrap.innerHTML = suggestions.length + ? suggestions.map((item) => ` + `).join('') + : 'Ingen sikre kundematch på adressen. Vælg manuelt i listen.'; + document.getElementById('allocationFeedback').textContent = suggestions.length > 1 + ? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.` + : suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : ''; + } + + async function selectAllocationCustomer(customerId) { + document.getElementById('allocationCustomerSelect').value = String(customerId); + await loadAllocationSubscriptions(); + } + + async function loadAllocationSubscriptions() { + const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null; + const select = document.getElementById('allocationSubscriptionSelect'); + const customerLink = document.getElementById('allocationCustomerLink'); + customerLink.classList.toggle('d-none', !customerId); + customerLink.href = customerId ? `/customers/${customerId}` : '#'; + if (!customerId) { + select.disabled = true; + select.innerHTML = ''; + return; + } + select.disabled = true; + select.innerHTML = ''; + const subscriptions = await safeJson( + await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}&status=active`), + [] + ); + select.innerHTML = '' + + subscriptions.map((item) => ``).join(''); + select.disabled = false; + document.getElementById('saveAllocationBtn').textContent = subscriptions.length + ? 'Tildel kunde / abonnement' + : 'Tildel kunde'; + } + + async function saveConnectionAllocation() { + const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null; + const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null; + const feedback = document.getElementById('allocationFeedback'); + const button = document.getElementById('saveAllocationBtn'); + if (!customerId) { + feedback.className = 'small text-danger'; + feedback.textContent = 'Vælg en kunde først.'; + return; + } + const payload = { customer_id: customerId }; + if (subscriptionId) { + payload.subscription_id = subscriptionId; + payload.value_type = 'subscription'; + payload.value_label = null; + } + button.disabled = true; + feedback.className = 'small text-muted'; + feedback.textContent = 'Gemmer tildelingen…'; + try { + const response = await fetch(`/api/v1/internet-connections/${connectionId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + }); + if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.')); + await loadConnection(); + } catch (error) { + feedback.className = 'small text-danger'; + feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.'; + } finally { + button.disabled = false; + } + } + async function loadConnection() { await loadLookups(); @@ -1340,15 +1551,26 @@ } const connection = await connectionResponse.json(); - const ranges = await rangesResponse.json(); - const addresses = await addressesResponse.json(); - const summary = await summaryResponse.json(); - const pricing = await pricingResponse.json(); - const pricingHistory = await pricingHistoryResponse.json(); - const contracts = await contractsResponse.json(); - const history = await historyResponse.json(); + const ranges = await safeJson(rangesResponse, []); + const addresses = await safeJson(addressesResponse, []); + const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 }); + const pricing = await safeJson(pricingResponse, {}); + const pricingHistory = await safeJson(pricingHistoryResponse, []); + const contracts = await safeJson(contractsResponse, []); + const history = await safeJson(historyResponse, []); const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} }); + const failedSections = [ + [rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'], + [pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'], + [historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'], + ].filter(([response]) => !response.ok).map(([, label]) => label); + if (failedSections.length) { + const feedback = document.getElementById('detailSaveFeedback'); + feedback.className = 'small text-danger'; + feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`; + } + currentConnection = connection; currentAddresses = Array.isArray(addresses) ? addresses : []; currentRanges = Array.isArray(ranges) ? ranges : []; @@ -1356,6 +1578,8 @@ ? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), []) : []; + await renderAllocationBanner(connection); + renderCore(connection, pricing, summary); renderRanges(currentRanges); renderAddresses(currentAddresses); @@ -1406,6 +1630,7 @@ function renderCore(connection, pricing, summary) { document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse'; + document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer'; const ownerText = connection.customer_name || 'Ingen kunde koblet på'; const businessText = connection.allocation_model === 'shared' ? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er` @@ -1420,19 +1645,23 @@ const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : []; const totalDown = Number(connection.download_mbps || 0); const totalUp = Number(connection.upload_mbps || 0); - const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0); - const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0); + // Only explicit child allocations count as consumed capacity. A nominal + // connection speed is not proof that bandwidth was allocated to a customer. + const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0); + const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0); const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0; const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0; document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`; document.getElementById('metricSales').textContent = formatDKK(effectiveSales); document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase); document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin); - document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0)); - document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`; - document.getElementById('metricBandwidthSub').textContent = totalUp - ? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op` - : `${downPct}%`; + document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0)); + document.getElementById('metricBandwidth').textContent = childConnections.length + ? `${usedDown} Mbps allokeret` + : '0 Mbps allokeret'; + document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp + ? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}` + : 'Kapacitet ikke registreret'; document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: ${summary.available || 0} · Reserverede: ${summary.reserved || 0} · I brug: ${summary.in_use || 0}`; const contractText = connection.contract_start || connection.contract_end @@ -1445,32 +1674,35 @@ ? `${escapeHtml(connection.monitoring_url)}` : '-'; + renderSla(connection); + document.getElementById('coreReadView').innerHTML = `
-
Leverandør${connection.provider || '-'}
-
Kredsløb${connection.circuit_number || '-'}
-
Binding${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}
-
Type${connection.connection_type || '-'}
-
Teknologi${connection.technology || '-'}
+
Leverandør${escapeHtml(connection.provider || '-')}
+
Kredsløb${escapeHtml(connection.circuit_number || '-')}
+ ${connection.is_shared_head ? `
NetværksmodelDelt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}
` : ''} +
Type${escapeHtml(connection.connection_type || '-')}
+
Teknologi${escapeHtml(connection.technology || '-')}
Hastighed${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps
-
Navn
${connection.name || '-'}
-
Adresse
${connection.address || '-'}
-
Kunde
${connection.customer_name || '-'}
-
Parent
${connection.parent_name || '-'}
-
Abonnement
${subscriptionText}
+
Navn
${escapeHtml(connection.name || '-')}
+
Adresse
${escapeHtml(connection.address || '-')}
+
Kunde
${escapeHtml(connection.customer_name || '-')}
+
Parent
${escapeHtml(connection.parent_name || '-')}
+
${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}
${escapeHtml(subscriptionText)}
Kontrakt
${contractText}
-
Fallback
${connection.speed_mbps || 0} Mbps
+
Nominel hastighed
${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}
Overvågning
${monitoringText}
-
Noter
${connection.notes || '-'}
+
SLA
${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : 'Ingen SLA-aftale'}
+
Noter
${connection.notes ? escapeHtml(connection.notes) : '-'}
`; setStatusBadge(connection.status); document.getElementById('fieldName').value = connection.name || ''; - document.getElementById('fieldProvider').value = connection.provider || ''; + document.getElementById('fieldVendorId').value = connection.vendor_id || ''; document.getElementById('fieldCircuit').value = connection.circuit_number || ''; document.getElementById('fieldAddress').value = connection.address || ''; setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id); @@ -1484,6 +1716,7 @@ document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || ''; document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated'; document.getElementById('fieldValueType').value = connection.value_type || 'other'; + document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared); document.getElementById('fieldValueLabel').value = connection.value_label || ''; setSubscriptionValue(connection.subscription_id); document.getElementById('fieldContractStart').value = connection.contract_start || ''; @@ -1493,6 +1726,66 @@ toggleCoreEdit(false, true); } + function renderSla(connection) { + const banner = document.getElementById('slaBanner'); + const content = document.getElementById('slaBannerContent'); + const select = document.getElementById('slaSubscriptionSelect'); + const customerId = Number(connection.customer_id || 0); + const isBmcSharedFiber = Boolean( + connection.is_manual_shared + || (connection.parent_id == null && connection.allocation_model === 'shared' && connection.value_type === 'delefiber') + ); + if (isBmcSharedFiber) { + banner.className = 'd-none'; + return; + } + const options = subscriptionOptions.filter((item) => + Number(item.customer_id) === customerId && /\bsla\b/i.test(String(item.product_name || '')) + ); + if (connection.sla_subscription_id && !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) { + options.unshift({ + id: connection.sla_subscription_id, + product_name: connection.sla_product_name, + subscription_number: connection.sla_subscription_number, + price: connection.sla_price, + status: connection.sla_status, + }); + } + select.innerHTML = '' + options.map((item) => + `` + ).join(''); + select.disabled = !customerId; + const hasSla = Boolean(connection.sla_subscription_id); + const priceOk = Number(connection.sla_price || 0) > 0; + const statusOk = connection.sla_status === 'active'; + banner.className = `alert mb-4 ${hasSla && priceOk && statusOk ? 'alert-success' : 'alert-warning'}`; + content.innerHTML = hasSla + ? `
${escapeHtml(connection.sla_product_name || 'SLA-aftale')}
${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : 'Prisen skal kontrolleres'}${statusOk ? '' : ' · SLA-aftalen er ikke aktiv'}
` + : `
Ingen SLA-aftale
${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}
`; + } + + async function saveSlaAllocation() { + const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null; + const feedback = document.getElementById('slaFeedback'); + const button = document.getElementById('saveSlaBtn'); + button.disabled = true; + feedback.textContent = 'Gemmer SLA-allokering…'; + try { + const response = await fetch(`/api/v1/internet-connections/${connectionId}`, { + method: 'PUT', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sla_subscription_id: value }), + }); + if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.')); + feedback.textContent = 'SLA-allokeringen er gemt.'; + await loadConnection(); + } catch (error) { + feedback.className = 'small mt-2 text-danger'; + feedback.textContent = error.message; + } finally { + button.disabled = false; + } + } + function toggleCoreEdit(force, silent = false) { coreEditMode = Boolean(force); document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode); @@ -1512,27 +1805,38 @@ const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false); if (!visibleRanges.length && !mismatchedRanges.length) { - list.innerHTML = '
Ingen IP-ranges registreret endnu.
'; + list.innerHTML = '
Ingen IP-ranges registreret
Tilføj et CIDR-range eller kontrollér kredsløbsreferencen.
'; rangeSelect.innerHTML = ''; return; } const visibleMarkup = visibleRanges.map((range) => ` -
- ${range.name || 'Range'} -
${range.cidr || '-'}
-
Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}
- ${range.customer_name ? `
Kunde: ${range.customer_name}
` : ''} - ${range.provider_reference ? `
Ref: ${range.provider_reference}
` : ''} - ${range.contract_number ? `
Kontrakt: ${range.contract_number}
` : ''} - ${range.service_address ? `
${range.service_address}
` : ''} - ${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `
Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}
` : ''} - ${range.description ? `
${range.description}
` : ''} -
+
+
+ ${range.name || 'Range'} +
${range.cidr || '-'}
+
${range.service_address || 'Ingen serviceadresse'}
+
+
+ Adresser +
${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige
+
${range.usable_hosts || 0} brugbare i alt
+
+
+ Reference +
${range.provider_reference || '-'}
+
Kontrakt ${range.contract_number || '-'}
+
+
+ Økonomi +
${formatDKK(range.monthly_cost || 0)} kost
+
${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}
+
+
-
-
+
+
@@ -1581,15 +1885,12 @@ `).join(''); const mismatchMarkup = mismatchedRanges.length ? ` -
- Afvigelser -
Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.
- ${mismatchedRanges.map((range) => ` -
- ${range.cidr} · ${range.customer_name || 'Ingen kunde'}
- ${range.alignment_warning || 'Matcher ikke forbindelsen'} -
- `).join('')} +
+ +
+
${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol
+ ${mismatchedRanges.map((range) => `
${range.cidr} · ${range.alignment_warning || 'Matcher ikke forbindelsen'}
`).join('')} +
` : ''; @@ -1661,7 +1962,7 @@ }); if (!filtered.length) { - body.innerHTML = 'Ingen IP-adresser matcher filtrene.'; + body.innerHTML = '
Ingen IP-adresser matcher
Prøv at rydde søgning eller statusfilter.
'; return; } @@ -1913,7 +2214,7 @@ document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || ''; const payload = { name: document.getElementById('fieldName').value.trim(), - provider: document.getElementById('fieldProvider').value.trim() || null, + vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null, circuit_number: document.getElementById('fieldCircuit').value.trim() || null, address: document.getElementById('fieldAddress').value.trim() || null, customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null, @@ -1929,6 +2230,7 @@ value_type: document.getElementById('fieldValueType').value || 'other', value_label: document.getElementById('fieldValueLabel').value.trim() || null, subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null, + is_manual_shared: document.getElementById('fieldManualShared').checked, contract_start: document.getElementById('fieldContractStart').value || null, contract_end: document.getElementById('fieldContractEnd').value || null, notes: document.getElementById('fieldNotes').value.trim() || null, @@ -1976,6 +2278,19 @@ document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription'); } + function toggleManualDelefiber() { + const enabled = document.getElementById('fieldManualShared').checked; + if (enabled) { + document.getElementById('fieldAllocationModel').value = 'shared'; + document.getElementById('fieldValueType').value = 'delefiber'; + } else if (document.getElementById('fieldValueType').value === 'delefiber') { + document.getElementById('fieldAllocationModel').value = 'dedicated'; + document.getElementById('fieldValueType').value = 'other'; + document.getElementById('fieldValueLabel').value = 'Internetforbindelse'; + } + toggleValueFields(); + } + async function createRange() { const feedback = document.getElementById('rangeCreateFeedback'); const button = document.getElementById('createRangeButton'); @@ -2039,7 +2354,7 @@ assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null, }; if (!payload.range_id || !payload.ip_address) { - alert('Range og IP-adresse er påkrævet'); + showDetailMessage('Range og IP-adresse er påkrævet.', true); return; } @@ -2049,7 +2364,7 @@ body: JSON.stringify(payload), }); if (!response.ok) { - alert('Kunne ikke oprette IP-adresse'); + showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true); return; } document.getElementById('addressIpInput').value = ''; @@ -2090,7 +2405,7 @@ body: JSON.stringify(payload), }); if (!response.ok) { - alert('Kunne ikke opdatere IP-adressen'); + showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true); return; } editingAddressId = null; diff --git a/app/modules/internet_connections/templates/index.html b/app/modules/internet_connections/templates/index.html index fd2421b..1afdfbe 100644 --- a/app/modules/internet_connections/templates/index.html +++ b/app/modules/internet_connections/templates/index.html @@ -173,13 +173,13 @@
Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.
- - Fakturabehandling - - - +
@@ -188,10 +188,13 @@ + + +
-
+
Forbindelser
@@ -200,7 +203,7 @@
-
Aktive
+
Aktive / afventer
0
@@ -232,7 +235,7 @@
- +
@@ -296,13 +299,14 @@
-
+
@@ -364,6 +369,7 @@ +
@@ -382,6 +388,36 @@
+ +