From 3311b8e590c6c884082fc6cfd522e21252fda50a Mon Sep 17 00:00:00 2001 From: Christian Date: Thu, 9 Jul 2026 23:44:30 +0200 Subject: [PATCH] Add comprehensive tests for internet connections module, invoice parsing, and subscription provisioning - Implement tests for the internet connections module, covering routes, IP range creation, and connection validation. - Add tests for the Invoice2DataService to validate extraction from GlobalConnect invoices. - Create tests for subscription network provisioning, ensuring proper handling of network items and IP allocations. - Include validation checks for subtotal mismatches and ensure error handling for missing IP selections. --- app/billing/backend/supplier_invoices.py | 1480 +++++++- app/billing/frontend/supplier_invoices.html | 162 +- app/customers/frontend/customer_detail.html | 422 +++ app/modules/internet_connections/__init__.py | 1 + .../backend/provisioning_utils.py | 143 + .../internet_connections/backend/router.py | 3122 +++++++++++++++++ .../internet_connections/frontend/views.py | 33 + app/modules/internet_connections/module.json | 11 + .../templates/detail.html | 2104 +++++++++++ .../internet_connections/templates/index.html | 633 ++++ .../templates/migration_wizard_v2.html | 557 +++ app/modules/sag/templates/detail.html | 324 +- app/modules/sag/templates/detail_v3.html | 642 +++- app/modules/telefoni/backend/router.py | 92 +- app/modules/telefoni/frontend/views.py | 4 +- app/modules/telefoni/templates/log.html | 16 +- app/products/backend/router.py | 1 + app/services/invoice2data_service.py | 353 +- app/shared/frontend/base.html | 5 +- app/subscriptions/backend/router.py | 262 +- main.py | 6 + .../197_internet_connections_module.sql | 75 + .../198_globalconnect_ipam_enhancements.sql | 43 + .../199_internet_connection_value_model.sql | 52 + migrations/200_internet_ip_unique.sql | 31 + ...balconnect_connections_without_address.sql | 53 + ...l_connection_addresses_from_extraction.sql | 27 + ...seed_bmc_network_subscription_products.sql | 159 + migrations/204_backfill_shared_bmc_heads.sql | 26 + .../205_realign_mismatched_ip_ranges.sql | 35 + .../206_normalize_bmc_networks_heads.sql | 15 + .../207_fix_nka020900_ip_range_mapping.sql | 8 + migrations/208_seed_bmcnet_static_wan_ip.sql | 58 + .../209_internet_customer_documents.sql | 68 + scripts/reprocess_supplier_invoice_files.py | 34 + .../reset_and_rebuild_internet_connections.py | 172 + static/js/sms.js | 22 +- tests/test_globalconnect_internet_sync.py | 797 +++++ tests/test_internet_connections_module.py | 661 ++++ tests/test_invoice2data_globalconnect.py | 116 + .../test_subscription_network_provisioning.py | 430 +++ updateto.sh | 14 + 42 files changed, 13050 insertions(+), 219 deletions(-) create mode 100644 app/modules/internet_connections/__init__.py create mode 100644 app/modules/internet_connections/backend/provisioning_utils.py create mode 100644 app/modules/internet_connections/backend/router.py create mode 100644 app/modules/internet_connections/frontend/views.py create mode 100644 app/modules/internet_connections/module.json create mode 100644 app/modules/internet_connections/templates/detail.html create mode 100644 app/modules/internet_connections/templates/index.html create mode 100644 app/modules/internet_connections/templates/migration_wizard_v2.html create mode 100644 migrations/197_internet_connections_module.sql create mode 100644 migrations/198_globalconnect_ipam_enhancements.sql create mode 100644 migrations/199_internet_connection_value_model.sql create mode 100644 migrations/200_internet_ip_unique.sql create mode 100644 migrations/201_cleanup_globalconnect_connections_without_address.sql create mode 100644 migrations/202_backfill_connection_addresses_from_extraction.sql create mode 100644 migrations/203_seed_bmc_network_subscription_products.sql create mode 100644 migrations/204_backfill_shared_bmc_heads.sql create mode 100644 migrations/205_realign_mismatched_ip_ranges.sql create mode 100644 migrations/206_normalize_bmc_networks_heads.sql create mode 100644 migrations/207_fix_nka020900_ip_range_mapping.sql create mode 100644 migrations/208_seed_bmcnet_static_wan_ip.sql create mode 100644 migrations/209_internet_customer_documents.sql create mode 100644 scripts/reprocess_supplier_invoice_files.py create mode 100644 scripts/reset_and_rebuild_internet_connections.py create mode 100644 tests/test_globalconnect_internet_sync.py create mode 100644 tests/test_internet_connections_module.py create mode 100644 tests/test_invoice2data_globalconnect.py create mode 100644 tests/test_subscription_network_provisioning.py diff --git a/app/billing/backend/supplier_invoices.py b/app/billing/backend/supplier_invoices.py index 6fe80f3..0336632 100644 --- a/app/billing/backend/supplier_invoices.py +++ b/app/billing/backend/supplier_invoices.py @@ -8,6 +8,7 @@ from pydantic import BaseModel from typing import List, Dict, Optional from datetime import datetime, date, timedelta from decimal import Decimal +import ipaddress from pathlib import Path from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column from app.core.config import settings @@ -227,6 +228,112 @@ def _resolve_procurement_customer_id() -> int: raise ValueError("No active customer available for procurement case creation") +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 _ensure_internet_change_case( + connection_id: int, + invoice_number: str, + reference: str, + connection_name: str, + owner_customer_id: Optional[int], + changes: Dict[str, Dict[str, object]], +) -> Optional[int]: + if not changes: + return None + + title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}" + existing = execute_query_single( + """ + SELECT id + FROM sag_sager + WHERE deleted_at IS NULL + AND titel = %s + ORDER BY id DESC + LIMIT 1 + """, + (title,), + ) + if existing: + return int(existing["id"]) + + assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economic"]) + try: + case_customer_id = owner_customer_id or _resolve_procurement_customer_id() + except Exception as exc: + logger.warning( + "Skipping automatic internet change case for connection %s on invoice %s: could not resolve case customer (%s)", + connection_id, + invoice_number, + exc, + ) + return None + change_lines = "\n".join( + f"- {field}: {change.get('from')} -> {change.get('to')}" + for field, change in changes.items() + ) + description = ( + "Automatisk oprettet ved import af internetfaktura.\n" + f"Forbindelse: {connection_name}\n" + f"Reference: {reference or '-'}\n" + f"Faktura: {invoice_number}\n" + "Registrerede ændringer:\n" + f"{change_lines}" + ) + + try: + try: + row = execute_query_single( + """ + INSERT INTO sag_sager ( + titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id + ) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + (title, description, _PURCHASE_CASE_TYPE, "åben", case_customer_id, assigned_group_id, 1), + ) + except Exception as insert_error: + if 'column "type"' not in str(insert_error): + raise + row = execute_query_single( + """ + INSERT INTO sag_sager ( + titel, beskrivelse, status, customer_id, assigned_group_id, created_by_user_id + ) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, + (title, description, "åben", case_customer_id, assigned_group_id, 1), + ) + except Exception as exc: + logger.warning( + "Skipping automatic internet change case for connection %s on invoice %s: %s", + connection_id, + invoice_number, + exc, + ) + return None + return int(row["id"]) if row and row.get("id") else None + + def _ensure_case_for_supplier_invoice( invoice_id: int, invoice_number: str, @@ -319,6 +426,79 @@ def _ensure_case_for_supplier_invoice( return sag_id +def _append_amount_validation_case_note( + sag_id: Optional[int], + invoice_id: int, + invoice_number: str, + validation_details: Optional[Dict], + validation_warning: Optional[str], + vat_warning: Optional[str], + file_id: Optional[int] = None, +) -> None: + if not sag_id: + return + if not validation_warning and not vat_warning: + return + + details = validation_details or {} + line_sum = details.get("line_sum") + subtotal = details.get("subtotal") + difference = details.get("difference") + vat_amount = details.get("vat_amount") + vat_expected = details.get("vat_expected") + vat_difference = details.get("vat_difference") + + note_lines = [ + "Automatisk valideringsadvarsel på leverandørfaktura", + f"Faktura: {invoice_number}", + f"Invoice ID: {invoice_id}", + f"Fil ID: {file_id or '-'}", + ] + if validation_warning: + note_lines.append(f"Subtotal-advarsel: {validation_warning}") + if line_sum is not None or subtotal is not None or difference is not None: + note_lines.append( + f"Beregnet linjesum: {line_sum if line_sum is not None else '-'} · Subtotal: {subtotal if subtotal is not None else '-'} · Afvigelse: {difference if difference is not None else '-'}" + ) + if vat_warning: + note_lines.append(f"Moms-advarsel: {vat_warning}") + if vat_amount is not None or vat_expected is not None or vat_difference is not None: + note_lines.append( + f"Registreret moms: {vat_amount if vat_amount is not None else '-'} · Forventet moms: {vat_expected if vat_expected is not None else '-'} · Afvigelse: {vat_difference if vat_difference is not None else '-'}" + ) + + note_body = "\n".join(note_lines) + try: + existing = execute_query_single( + """ + SELECT id + FROM sag_kommentarer + WHERE sag_id = %s + AND indhold = %s + ORDER BY id DESC + LIMIT 1 + """, + (sag_id, note_body), + ) + if existing: + return + + execute_update( + """ + INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) + VALUES (%s, %s, %s, %s) + """, + (sag_id, "Invoice Bot", note_body, True), + ) + except Exception as comment_error: + logger.warning( + "⚠️ Could not persist amount validation case note for supplier invoice %s / SAG-%s: %s", + invoice_id, + sag_id, + comment_error, + ) + + def _to_decimal(value, default: Decimal = Decimal("0")) -> Decimal: if value is None or value == "": return default @@ -328,6 +508,39 @@ def _to_decimal(value, default: Decimal = Decimal("0")) -> Decimal: return default +def _normalize_change_value(value): + if isinstance(value, Decimal): + return str(value.quantize(Decimal("0.01"))) + if isinstance(value, float): + return f"{value:.2f}" + if isinstance(value, (datetime, date)): + return value.isoformat() + if value is None: + return None + text = str(value).strip() + return text or None + + +def _append_connection_history(connection_id: int, event_type: str, summary: str, details: Dict) -> None: + execute_update( + """ + INSERT INTO internet_connections_history (connection_id, event_type, summary, details) + VALUES (%s, %s, %s, %s::jsonb) + """, + (connection_id, event_type, summary, json.dumps(details or {})), + ) + + +def _build_change_dict(existing: Dict, updated: Dict, fields: List[str]) -> Dict[str, Dict[str, object]]: + changes: Dict[str, Dict[str, object]] = {} + for field in fields: + before = _normalize_change_value(existing.get(field)) + after = _normalize_change_value(updated.get(field)) + if before != after: + changes[field] = {"from": before, "to": after} + return changes + + def _extract_lines_from_llm_payload(extraction_row: Optional[Dict]) -> List[Dict]: if not extraction_row: return [] @@ -359,6 +572,18 @@ def _extract_lines_from_llm_payload(extraction_row: Optional[Dict]) -> List[Dict "line_total": line.get("line_total") or line.get("amount") or 0, "vat_rate": line.get("vat_rate") or 25.0, "vat_amount": line.get("vat_amount") or 0, + "ip_address": line.get("ip_address"), + "contract_number": line.get("contract_number"), + "provider_reference": line.get("provider_reference"), + "customer_reference": line.get("customer_reference"), + "circuit_id": line.get("circuit_id"), + "end_customer_name": line.get("end_customer_name"), + "period_start": line.get("period_start"), + "period_end": line.get("period_end"), + "service_address": line.get("service_address"), + "location_street": line.get("location_street"), + "location_zip": line.get("location_zip"), + "location_city": line.get("location_city"), } ) return normalized @@ -373,7 +598,11 @@ def _load_extraction_lines(extraction_row: Optional[Dict]) -> List[Dict]: db_lines = execute_query( """ - SELECT line_number, sku, description, quantity, unit_price, line_total, vat_rate, vat_amount + SELECT + line_number, sku, description, quantity, unit_price, line_total, vat_rate, vat_amount, + ip_address, contract_number, provider_reference, customer_reference, circuit_id, + end_customer_name, period_start, period_end, service_address, + location_street, location_zip, location_city FROM extraction_lines WHERE extraction_id = %s ORDER BY line_number @@ -387,6 +616,1127 @@ def _load_extraction_lines(extraction_row: Optional[Dict]) -> List[Dict]: return _extract_lines_from_llm_payload(extraction_row) +def _compose_supplier_line_description(line: Dict) -> str: + description = str(line.get("description") or "").strip() + extras = [] + for key in ("provider_reference", "circuit_id", "contract_number"): + value = str(line.get(key) or "").strip() + if value and value not in extras: + extras.append(value) + + if line.get("service_address"): + extras.append(str(line.get("service_address")).strip()) + elif line.get("location_street") and line.get("location_zip") and line.get("location_city"): + extras.append(f"{line.get('location_street')}, {line.get('location_zip')} {line.get('location_city')}") + + if line.get("period_start") and line.get("period_end"): + extras.append(f"{line.get('period_start')} til {line.get('period_end')}") + + if not extras: + return description + return f"{description} ({' · '.join(extras)})" + + +def _is_globalconnect_extraction(extraction_row: Optional[Dict]) -> bool: + if not extraction_row: + return False + vendor_name = str(extraction_row.get("vendor_name") or "").lower() + if "globalconnect" in vendor_name: + return True + + try: + payload = extraction_row.get("llm_response_json") + if isinstance(payload, str): + payload = json.loads(payload) + issuer = str((payload or {}).get("issuer") or "").lower() + template = str((payload or {}).get("template") or "").lower() + return "globalconnect" in issuer or template == "dk.globalconnect" + except Exception: + return False + + +def _normalize_company_name(value: Optional[str]) -> str: + normalized = str(value or "").upper() + replacements = { + "Æ": "AE", + "Ø": "OE", + "Å": "AA", + "&": " OG ", + "/": " ", + } + for source, target in replacements.items(): + normalized = normalized.replace(source, target) + normalized = re.sub(r"[^A-Z0-9 ]+", " ", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + +def _build_service_address(line: Dict) -> Optional[str]: + if line.get("service_address"): + return str(line.get("service_address")).strip() + if line.get("location_street") and line.get("location_zip") and line.get("location_city"): + return f"{line.get('location_street')}, {line.get('location_zip')} {line.get('location_city')}" + return None + + +def _normalize_provider_reference(value: Optional[str]) -> str: + raw = str(value or "").strip().upper() + if not raw: + return "" + return re.sub(r"[^A-Z0-9]", "", raw) + + +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: + parts.append(f"Udtrukket kunde: {end_customer_name}.") + if service_address: + parts.append(f"Udtrukket adresse: {service_address}.") + parts.append("Kræver manuel kontrol før forbindelsen kan anses som korrekt.") + return " ".join(parts) + + +def _has_confident_globalconnect_mapping(matched_customer: Optional[Dict], service_address: Optional[str]) -> bool: + return bool(matched_customer and service_address) + + +def _resolve_internal_bmc_customer() -> Optional[Dict]: + preferred = execute_query_single( + """ + SELECT id, name, address, postal_code, city + FROM customers + WHERE is_active = true + AND lower(name) = %s + """, + ("bmc networks",), + ) + if preferred: + return dict(preferred) + + try: + customer_id = _resolve_procurement_customer_id() + except Exception: + return None + + fallback = execute_query_single( + """ + SELECT id, name, address, postal_code, city + FROM customers + WHERE id = %s AND is_active = true + """, + (customer_id,), + ) + return dict(fallback) if fallback else None + + +def _should_assign_internal_bmc_owner( + lines: List[Dict], + matched_customer: Optional[Dict], + service_address: Optional[str], +) -> bool: + if matched_customer: + 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) + + +def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str: + if internal_owner and not matched_customer: + return "delefiber" + return "other" + + +def _load_active_customers_for_matching() -> List[Dict]: + rows = execute_query( + """ + SELECT id, name, address, postal_code, city + FROM customers + WHERE is_active = true + ORDER BY id + """ + ) or [] + return [dict(row) for row in rows] + + +def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) -> Optional[Dict]: + end_customer_name = str(line.get("end_customer_name") or "").strip() + service_address = _build_service_address(line) or "" + normalized_target = _normalize_company_name(end_customer_name) + address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()] + + best_match = None + best_score = 0 + for customer in customers: + customer_name = str(customer.get("name") or "").strip() + normalized_customer = _normalize_company_name(customer_name) + score = 0 + + if normalized_target and normalized_customer: + if normalized_target == normalized_customer: + score = 100 + elif normalized_target in normalized_customer or normalized_customer in normalized_target: + score = 80 + else: + target_tokens = set(normalized_target.split()) + customer_tokens = set(normalized_customer.split()) + overlap = target_tokens & customer_tokens + if overlap: + score = max(score, min(len(overlap) * 15, 60)) + + if service_address: + customer_address = str(customer.get("address") or "").upper() + customer_city = str(customer.get("city") or "").upper() + customer_postal = str(customer.get("postal_code") or "").upper() + address_score = 0 + if customer_address and customer_address in service_address.upper(): + address_score += 35 + if customer_city and customer_city in service_address.upper(): + address_score += 20 + if customer_postal and customer_postal in service_address.upper(): + address_score += 20 + if address_parts and customer_address: + overlap = sum(1 for part in address_parts if len(part) > 2 and part in customer_address) + address_score += min(overlap * 5, 15) + score = max(score, address_score) + + if score > best_score: + best_score = score + best_match = customer + + return best_match if best_score >= 50 else None + + +def _looks_like_ip_range_line(line: Dict) -> bool: + description = str(line.get("description") or "").lower() + return bool(line.get("ip_address")) and ( + "ipv4" in description + or "ipv6" in description + or "ip-adress" in description + or "ip adress" in description + ) + + +def _looks_like_connection_component(line: Dict) -> bool: + if _looks_like_ip_range_line(line): + return False + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + if not reference: + return False + description = str(line.get("description") or "").lower() + if not description: + return False + return any( + token in description + for token in ( + "fiber", + "adsl", + "vdsl", + "dsl", + "internet", + "mpls", + "rackskab", + "datacenter", + "sla", + "forbindelse", + ) + ) + + +def _can_create_connection_from_line(line: Dict) -> bool: + if not _looks_like_connection_component(line): + return False + service_address = _build_service_address(line) + if not service_address: + return False + description = str(line.get("description") or "") + speed_mbps, upload_mbps, download_mbps = _infer_speed_profile(description) + return bool(speed_mbps or upload_mbps or download_mbps) + + +def _infer_connection_type(description: str) -> str: + desc = description.lower() + if "mpls" in desc: + return "mpls" + if "rackskab" in desc or "datacenter" in desc: + return "datacenter" + if "adsl" in desc or "vdsl" in desc or "dsl" in desc: + return "xdsl" + return "fiber" + + +def _infer_technology(description: str) -> str: + desc = description.lower() + if "adsl" in desc: + return "ADSL" + if "vdsl" in desc: + return "VDSL" + if "mpls" in desc: + return "MPLS" + if "fiber" in desc: + return "Fiber" + if "datacenter" in desc: + return "Datacenter" + return "Internet" + + +def _infer_speed_profile(description: str) -> tuple[Optional[int], Optional[int], Optional[int]]: + slash_match = re.search( + r"(\d+(?:[.,]\d+)?)\s*/\s*(\d+(?:[.,]\d+)?)\s*(K|M|G)bps", + description, + re.IGNORECASE, + ) + if slash_match: + down_value = float(slash_match.group(1).replace(",", ".")) + up_value = float(slash_match.group(2).replace(",", ".")) + unit = slash_match.group(3).upper() + multiplier = 1 + if unit == "G": + multiplier = 1000 + elif unit == "K": + multiplier = 1 / 1000 + + download_mbps = max(int(round(down_value * multiplier)), 1) + upload_mbps = max(int(round(up_value * multiplier)), 1) + return download_mbps, upload_mbps, download_mbps + + match = re.search(r"(\d+(?:[.,]\d+)?)\s*(G|M)bps", description, re.IGNORECASE) + if not match: + return None, None, None + value = float(match.group(1).replace(",", ".")) + unit = match.group(2).upper() + speed_mbps = int(value * 1000) if unit == "G" else int(value) + return speed_mbps, speed_mbps, speed_mbps + + +def _line_monthly_cost(line: Dict) -> Decimal: + unit_price = _to_decimal(line.get("unit_price")) + if unit_price > 0: + return unit_price + + line_total = _to_decimal(line.get("line_total")) + quantity = _to_decimal(line.get("quantity"), Decimal("1")) + if quantity <= 0: + return line_total + return line_total / quantity + + +def _ensure_connection_pricing_entry(connection_id: int, effective_from, purchase_price: Decimal, notes: str): + if not effective_from: + return + existing = execute_query_single( + """ + SELECT id + FROM internet_connections_pricing + WHERE connection_id = %s AND effective_from = %s + ORDER BY id DESC + LIMIT 1 + """, + (connection_id, effective_from), + ) + if existing: + execute_update( + """ + UPDATE internet_connections_pricing + SET purchase_price = %s, + notes = %s + WHERE id = %s + """, + (purchase_price, notes, existing["id"]), + ) + return + + execute_update( + """ + INSERT INTO internet_connections_pricing ( + connection_id, effective_from, purchase_price, sales_price, notes + ) + VALUES (%s, %s, %s, %s, %s) + """, + (connection_id, effective_from, purchase_price, Decimal("0"), notes), + ) + + +def _ensure_ip_addresses_for_range(range_id: int, cidr: str) -> int: + existing = execute_query_single( + """ + SELECT COUNT(*) AS total + FROM internet_connections_ip_addresses + WHERE range_id = %s AND deleted_at IS NULL + """, + (range_id,), + ) + if existing and int(existing.get("total") or 0) > 0: + return int(existing.get("total") or 0) + + try: + network = ipaddress.ip_network(cidr, strict=False) + except ValueError: + return 0 + + created = 0 + for host in network.hosts(): + existing_ip = execute_query_single( + """ + SELECT id + FROM internet_connections_ip_addresses + WHERE ip_address = %s + AND deleted_at IS NULL + LIMIT 1 + """, + (str(host),), + ) + if existing_ip: + continue + execute_update( + """ + INSERT INTO internet_connections_ip_addresses ( + range_id, ip_address, status, assigned_to, assigned_type, comment + ) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (range_id, str(host), "available", None, None, "Auto-generated from synced CIDR"), + ) + created += 1 + return created + + +def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canonical_id: int): + duplicate_ids = [connection_id for connection_id in connection_ids if connection_id != canonical_id] + if not duplicate_ids: + return + + for duplicate_id in duplicate_ids: + execute_update( + """ + UPDATE internet_connections_ip_ranges + SET connection_id = %s, + updated_at = CURRENT_TIMESTAMP + WHERE connection_id = %s AND deleted_at IS NULL + """, + (canonical_id, duplicate_id), + ) + execute_update( + """ + UPDATE internet_connections_ip_addresses + SET assigned_connection_id = %s + WHERE assigned_connection_id = %s + """, + (canonical_id, duplicate_id), + ) + execute_update( + """ + UPDATE internet_connections_connections + SET deleted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + notes = CONCAT(COALESCE(notes, ''), CASE WHEN COALESCE(notes, '') = '' THEN '' ELSE E'\n' END, %s) + WHERE id = %s + """, + (f"Dublet samlet under forbindelse #{canonical_id} via GlobalConnect reference-normalisering.", duplicate_id), + ) + + +def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]: + matches = execute_query( + """ + SELECT id + FROM internet_connections_ip_ranges + WHERE connection_id = %s + AND cidr = %s + AND deleted_at IS NULL + ORDER BY id + """, + (connection_id, cidr), + ) + if not matches: + return None + + canonical_id = int(matches[0]["id"]) + duplicate_ids = [int(row["id"]) for row in matches[1:]] + for duplicate_id in duplicate_ids: + execute_update( + """ + UPDATE internet_connections_ip_addresses + SET range_id = %s + WHERE range_id = %s + """, + (canonical_id, duplicate_id), + ) + execute_update( + """ + UPDATE internet_connections_ip_ranges + SET deleted_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + description = CONCAT(COALESCE(description, ''), CASE WHEN COALESCE(description, '') = '' THEN '' ELSE E'\n' END, %s) + WHERE id = %s + """, + (f"Dublet samlet under range #{canonical_id} via GlobalConnect reference-normalisering.", duplicate_id), + ) + return canonical_id + + +def _normalize_service_address_for_match(value: Optional[str]) -> str: + normalized = str(value or "").strip().upper() + normalized = normalized.replace("Æ", "AE").replace("Ø", "OE").replace("Å", "AA") + normalized = re.sub(r"[^A-Z0-9]", "", normalized) + return normalized + + +def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]: + if not reference: + return [] + rows = execute_query( + """ + SELECT id, customer_id, address, monthly_cost, technology, connection_type, + circuit_number, speed_mbps, download_mbps, upload_mbps, status, + allocation_model, value_type, value_label + 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 + ORDER BY id + """, + (reference,), + ) or [] + return [dict(row) for row in rows] + + +def _resolve_existing_globalconnect_connection(reference: str, service_address: Optional[str]) -> Dict[str, object]: + matches = _get_globalconnect_connections_by_reference(reference) + if not matches: + return {"row": None, "merge_ids": [], "conflict_reason": None, "matched_on": None} + + normalized_target = _normalize_service_address_for_match(service_address) + exact_matches = [] + blank_address_matches = [] + other_address_matches = [] + + for row in matches: + normalized_existing = _normalize_service_address_for_match(row.get("address")) + if not normalized_existing: + blank_address_matches.append(row) + elif normalized_existing == normalized_target: + exact_matches.append(row) + else: + other_address_matches.append(row) + + if exact_matches: + canonical = exact_matches[0] + merge_ids = [int(row["id"]) for row in exact_matches[1:] + blank_address_matches] + return { + "row": canonical, + "merge_ids": merge_ids, + "conflict_reason": None, + "matched_on": "address", + } + + if blank_address_matches and not other_address_matches: + canonical = blank_address_matches[0] + merge_ids = [int(row["id"]) for row in blank_address_matches[1:]] + return { + "row": canonical, + "merge_ids": merge_ids, + "conflict_reason": None, + "matched_on": "blank_address", + } + + conflicting_addresses = ", ".join( + sorted({str(row.get("address") or "").strip() for row in other_address_matches if str(row.get("address") or "").strip()}) + ) + conflict_reason = "Reference findes allerede på anden adresse" + if conflicting_addresses: + conflict_reason = f"{conflict_reason}: {conflicting_addresses}" + return {"row": None, "merge_ids": [], "conflict_reason": conflict_reason, "matched_on": None} + + +def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_date, invoice_number: str, customers: List[Dict]) -> Optional[int]: + if not reference or not lines: + return None + + sorted_lines = sorted( + lines, + key=lambda item: ( + "administrationsgebyr" in str(item.get("description") or "").lower(), + "sla" in str(item.get("description") or "").lower(), + ), + ) + primary_line = sorted_lines[0] + display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip() + matched_customer = _match_customer_for_globalconnect_line(primary_line, customers) + service_address = _build_service_address(primary_line) + if not service_address: + logger.warning( + "Skipping GlobalConnect connection %s from invoice %s because service address is missing", + display_reference or reference, + invoice_number, + ) + return None + description = str(primary_line.get("description") or reference) + end_customer_name = str(primary_line.get("end_customer_name") or "").strip() + internal_owner = _resolve_internal_bmc_customer() if _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}") + ) + 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}" + + existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address) + existing = existing_resolution.get("row") + conflict_reason = existing_resolution.get("conflict_reason") + if conflict_reason: + logger.warning( + "Skipping GlobalConnect connection %s from invoice %s because %s", + display_reference or reference, + invoice_number, + conflict_reason, + ) + return None + merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])] + if existing and merge_ids: + _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) + + 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) + payload = ( + connection_name, + "GlobalConnect A/S", + owner_customer["id"] if owner_customer else None, + service_address, + monthly_cost, + _infer_technology(description), + _infer_connection_type(description), + display_reference, + speed_mbps, + upload_mbps, + download_mbps, + note_text, + "shared" if internal_owner and not matched_customer else "dedicated", + shared_value_type, + None, + ) + + if existing: + updated_snapshot = { + "customer_id": owner_customer["id"] if owner_customer else None, + "address": service_address, + "monthly_cost": monthly_cost, + "technology": _infer_technology(description), + "connection_type": _infer_connection_type(description), + "circuit_number": display_reference, + "speed_mbps": speed_mbps, + "download_mbps": download_mbps, + "upload_mbps": upload_mbps, + "status": target_status, + "allocation_model": "shared" if internal_owner and not matched_customer else "dedicated", + "value_type": shared_value_type, + "value_label": None, + } + update_payload = ( + connection_name, + "GlobalConnect A/S", + owner_customer["id"] if owner_customer else None, + service_address, + monthly_cost, + _infer_technology(description), + _infer_connection_type(description), + display_reference, + speed_mbps, + download_mbps, + upload_mbps, + note_text, + "shared" if internal_owner and not matched_customer else "dedicated", + shared_value_type, + None, + existing["id"], + ) + execute_update( + """ + UPDATE internet_connections_connections + SET name = %s, + provider = %s, + customer_id = COALESCE(%s, customer_id), + address = COALESCE(%s, address), + status = %s, + monthly_cost = %s, + technology = %s, + connection_type = %s, + circuit_number = %s, + speed_mbps = COALESCE(%s, speed_mbps), + download_mbps = COALESCE(%s, download_mbps), + upload_mbps = COALESCE(%s, upload_mbps), + allocation_model = %s, + value_type = %s, + value_label = %s, + notes = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + ( + update_payload[0], + update_payload[1], + update_payload[2], + update_payload[3], + "active" if (is_confident or internal_owner) else "pending", + update_payload[4], + update_payload[5], + update_payload[6], + update_payload[7], + update_payload[8], + update_payload[9], + update_payload[10], + update_payload[12], + update_payload[13], + update_payload[14], + update_payload[11], + update_payload[15], + ), + ) + connection_id = int(existing["id"]) + changes = _build_change_dict( + existing, + updated_snapshot, + [ + "customer_id", + "address", + "monthly_cost", + "technology", + "connection_type", + "circuit_number", + "speed_mbps", + "download_mbps", + "upload_mbps", + "status", + "allocation_model", + "value_type", + ], + ) + if changes: + _append_connection_history( + connection_id, + "supplier_invoice_sync_changed", + f"Opdateret fra GlobalConnect faktura {invoice_number}", + {"reference": display_reference, "changes": changes}, + ) + _ensure_internet_change_case( + connection_id=connection_id, + invoice_number=invoice_number, + reference=display_reference, + connection_name=connection_name, + owner_customer_id=owner_customer["id"] if owner_customer else None, + changes=changes, + ) + else: + connection_id = execute_insert( + """ + INSERT INTO internet_connections_connections ( + name, provider, customer_id, address, status, monthly_cost, sales_price, + technology, connection_type, circuit_number, speed_mbps, upload_mbps, + download_mbps, notes, allocation_model, value_type, value_label + ) + VALUES (%s, %s, %s, %s, %s, %s, 0, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, + ( + payload[0], + payload[1], + payload[2], + payload[3], + "active" if (is_confident or internal_owner) else "pending", + payload[4], + payload[5], + payload[6], + payload[7], + payload[8], + payload[9], + payload[10], + payload[11], + payload[12], + payload[13], + payload[14], + ), + ) + _append_connection_history( + connection_id, + "connection_created_from_supplier_invoice", + f"Oprettet fra GlobalConnect faktura {invoice_number}", + {"reference": display_reference, "normalized_reference": reference, "invoice_number": invoice_number}, + ) + + _ensure_connection_pricing_entry( + connection_id=connection_id, + effective_from=invoice_date, + purchase_price=monthly_cost, + notes=f"GlobalConnect sync fra faktura {invoice_number}", + ) + return int(connection_id) + + +def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str): + cidr = str(line.get("ip_address") or "").strip() + 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()) + canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr) + existing = execute_query_single( + """ + SELECT id, provider_reference, contract_number, customer_id, service_address, monthly_cost + FROM internet_connections_ip_ranges + WHERE connection_id = %s AND cidr = %s AND deleted_at IS NULL + ORDER BY id + LIMIT 1 + """, + (connection_id, cidr), + ) + if not existing and canonical_range_id: + existing = {"id": canonical_range_id} + monthly_cost = _line_monthly_cost(line) + params = ( + str(line.get("description") or "IP-range").strip(), + display_reference or None, + str(line.get("contract_number") or "").strip() or None, + matched_customer["id"] if matched_customer else None, + service_address, + monthly_cost, + Decimal("0"), + ) + + if existing: + changes = _build_change_dict( + existing, + { + "provider_reference": params[1], + "contract_number": params[2], + "customer_id": params[3], + "service_address": params[4], + "monthly_cost": params[5], + }, + ["provider_reference", "contract_number", "customer_id", "service_address", "monthly_cost"], + ) + execute_update( + """ + UPDATE internet_connections_ip_ranges + SET name = %s, + provider_reference = %s, + contract_number = %s, + customer_id = COALESCE(%s, customer_id), + service_address = COALESCE(%s, service_address), + monthly_cost = %s, + sales_price = %s, + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + params + (existing["id"],), + ) + range_id = int(existing["id"]) + if service_address: + execute_update( + """ + UPDATE internet_connections_connections + SET address = COALESCE(NULLIF(TRIM(address), ''), %s), + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (service_address, connection_id), + ) + _ensure_ip_addresses_for_range(range_id, cidr) + if changes: + _append_connection_history( + connection_id, + "supplier_invoice_ip_range_changed", + f"IP-range {cidr} opdateret fra faktura {invoice_number}", + {"cidr": cidr, "changes": changes}, + ) + reference = display_reference or cidr + _ensure_internet_change_case( + connection_id=connection_id, + invoice_number=invoice_number, + reference=reference, + connection_name=f"IP-range {cidr}", + owner_customer_id=matched_customer["id"] if matched_customer else None, + changes=changes, + ) + return range_id + + range_id = execute_insert( + """ + 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 id + """, + ( + connection_id, + params[0], + cidr, + f"Synced fra GlobalConnect faktura {invoice_number}", + params[1], + params[2], + params[3], + params[4], + params[5], + params[6], + ), + ) + range_id = int(range_id) + if service_address: + execute_update( + """ + UPDATE internet_connections_connections + SET address = COALESCE(NULLIF(TRIM(address), ''), %s), + updated_at = CURRENT_TIMESTAMP + WHERE id = %s + """, + (service_address, connection_id), + ) + _ensure_ip_addresses_for_range(range_id, cidr) + _append_connection_history( + connection_id, + "supplier_invoice_ip_range_created", + f"IP-range {cidr} oprettet fra faktura {invoice_number}", + {"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]}, + ) + return range_id + + +def _find_existing_globalconnect_connection_id(reference: str, service_address: Optional[str]) -> tuple[Optional[int], Optional[str]]: + resolution = _resolve_existing_globalconnect_connection(reference, service_address) + existing = resolution.get("row") + if existing and existing.get("id"): + return int(existing["id"]), None + return None, resolution.get("conflict_reason") + + +def _connection_can_host_ip_range(connection_id: Optional[int], service_address: Optional[str]) -> bool: + if not connection_id: + return False + row = execute_query_single( + """ + SELECT address, allocation_model + FROM internet_connections_connections + WHERE id = %s + AND deleted_at IS NULL + """, + (connection_id,), + ) + if not row: + return False + if str(row.get("allocation_model") or "").lower() == "shared": + return True + if not service_address: + return True + return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(service_address) + + +def _connection_skip_reason(line: Dict) -> str: + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + if not reference: + return "Mangler provider-reference/kredsløb" + if not _build_service_address(line): + return "Mangler serviceadresse" + description = str(line.get("description") or "") + speed_mbps, upload_mbps, download_mbps = _infer_speed_profile(description) + if not any((speed_mbps, upload_mbps, download_mbps)): + return "Mangler hastighed på forbindelseslinjen" + return "Forbindelseslinje kunne ikke valideres" + + +def _build_sync_audit_entry(line: Dict, classification: str, status: str, reason: Optional[str] = None) -> Dict: + reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip() + return { + "line_number": line.get("line_number"), + "description": str(line.get("description") or "").strip() or "-", + "classification": classification, + "status": status, + "reason": reason, + "provider_reference": reference or None, + "ip_address": str(line.get("ip_address") or "").strip() or None, + "service_address": _build_service_address(line), + "end_customer_name": str(line.get("end_customer_name") or "").strip() or None, + } + + +def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_range_candidates: int) -> Dict: + actionable_lines = [entry for entry in line_audit if entry["classification"] in {"connection", "ip_range"}] + synced_lines = [entry for entry in actionable_lines if entry["status"] == "synced"] + skipped_lines = [entry for entry in actionable_lines if entry["status"] == "skipped"] + ignored_lines = [entry for entry in line_audit if entry["status"] == "ignored"] + return { + "total_lines": len(line_audit), + "actionable_lines": len(actionable_lines), + "synced_actionable_lines": len(synced_lines), + "skipped_actionable_lines": len(skipped_lines), + "ignored_lines": len(ignored_lines), + "connection_groups": connection_groups, + "ip_range_candidates": ip_range_candidates, + "all_actionable_accounted_for": len(actionable_lines) == (len(synced_lines) + len(skipped_lines)), + "fully_synced": len(skipped_lines) == 0 and len(actionable_lines) > 0, + "requires_manual_review": len(skipped_lines) > 0, + } + + +def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: bool = False) -> Dict: + if not _is_globalconnect_extraction(extraction_row): + return {"skipped": True, "reason": "not_globalconnect"} + + lines = _load_extraction_lines(extraction_row) + if not lines: + return {"skipped": True, "reason": "no_lines"} + + invoice_number = str(extraction_row.get("document_id") or extraction_row.get("invoice_number") or extraction_row.get("file_id") or "ukendt") + invoice_date = extraction_row.get("document_date") + customers = _load_active_customers_for_matching() + + grouped_connections: Dict[str, List[Dict]] = {} + connection_entry_indexes: Dict[str, List[int]] = {} + ip_range_lines: List[tuple[int, Dict]] = [] + skipped_connection_lines = 0 + line_audit: List[Dict] = [] + for line in lines: + if _looks_like_ip_range_line(line): + line_audit.append(_build_sync_audit_entry(line, "ip_range", "pending")) + ip_range_lines.append((len(line_audit) - 1, line)) + continue + if _looks_like_connection_component(line): + if not _can_create_connection_from_line(line): + skipped_connection_lines += 1 + line_audit.append(_build_sync_audit_entry(line, "connection", "skipped", _connection_skip_reason(line))) + continue + reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) + if reference: + grouped_connections.setdefault(reference, []).append(line) + line_audit.append(_build_sync_audit_entry(line, "connection", "pending")) + connection_entry_indexes.setdefault(reference, []).append(len(line_audit) - 1) + else: + skipped_connection_lines += 1 + line_audit.append(_build_sync_audit_entry(line, "connection", "skipped", "Mangler provider-reference/kredsløb")) + continue + line_audit.append(_build_sync_audit_entry(line, "other", "ignored", "Ikke en forbindelses- eller IP-range-linje")) + + connection_map: Dict[str, int] = {} + created_or_updated_connections = 0 + created_connections = 0 + updated_connections = 0 + for reference, reference_lines in grouped_connections.items(): + primary_line = reference_lines[0] + service_address = _build_service_address(primary_line) + existing_connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address) + if connection_conflict_reason: + for index in connection_entry_indexes.get(reference, []): + line_audit[index]["status"] = "skipped" + line_audit[index]["reason"] = connection_conflict_reason + skipped_connection_lines += len(connection_entry_indexes.get(reference, [])) + continue + if simulate: + connection_id = existing_connection_id or -(len(connection_map) + 1) + else: + connection_id = _upsert_globalconnect_connection( + reference=reference, + lines=reference_lines, + invoice_date=invoice_date, + invoice_number=invoice_number, + customers=customers, + ) + if connection_id: + connection_map[reference] = connection_id + created_or_updated_connections += 1 + if existing_connection_id: + updated_connections += 1 + outcome = "updated" + else: + created_connections += 1 + outcome = "created" + for index in connection_entry_indexes.get(reference, []): + line_audit[index]["status"] = "synced" + line_audit[index]["result"] = outcome + line_audit[index]["connection_id"] = None if simulate else connection_id + else: + for index in connection_entry_indexes.get(reference, []): + line_audit[index]["status"] = "skipped" + line_audit[index]["reason"] = "Forbindelsen kunne ikke oprettes/opdateres" + + created_or_updated_ranges = 0 + synced_ip_ranges_existing_connection = 0 + skipped_orphan_ip_ranges = 0 + 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) + connection_id = connection_map.get(reference) + resolved_from_existing = False + 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" + skipped_orphan_ip_ranges += 1 + continue + 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 + elif connection_conflict_reason: + skipped_orphan_ip_ranges += 1 + line_audit[audit_index]["status"] = "skipped" + line_audit[audit_index]["reason"] = connection_conflict_reason + continue + + if connection_id and ( + simulate + or _upsert_globalconnect_ip_range(connection_id, line, invoice_number) + ): + created_or_updated_ranges += 1 + if resolved_from_existing: + synced_ip_ranges_existing_connection += 1 + line_audit[audit_index]["status"] = "synced" + line_audit[audit_index]["result"] = "linked_existing_connection" if resolved_from_existing else "linked_synced_connection" + line_audit[audit_index]["connection_id"] = None if simulate else connection_id + elif not connection_id: + skipped_orphan_ip_ranges += 1 + line_audit[audit_index]["status"] = "skipped" + line_audit[audit_index]["reason"] = "Ingen forbindelse fundet til IP-range" + + verification = _summarize_sync_audit( + line_audit=line_audit, + connection_groups=len(grouped_connections), + ip_range_candidates=len(ip_range_lines), + ) + + return { + "skipped": False, + "simulate": simulate, + "connections_synced": created_or_updated_connections, + "connections_created": created_connections, + "connections_updated": updated_connections, + "ip_ranges_synced": created_or_updated_ranges, + "ip_ranges_linked_existing_connections": synced_ip_ranges_existing_connection, + "skipped_connection_lines": skipped_connection_lines, + "skipped_orphan_ip_ranges": skipped_orphan_ip_ranges, + "line_audit": line_audit, + "skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"], + "verification": verification, + } + + def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]: sku_value = str(sku or "").strip() desc_value = str(description or "").strip() @@ -1035,7 +2385,13 @@ async def get_file_extracted_data(file_id: int): "unit_price": float(line.get('unit_price')) if line.get('unit_price') else None, "vat_rate": float(line.get('vat_rate')) if line.get('vat_rate') else None, "line_total": float(line.get('line_total')) if line.get('line_total') else None, - "vat_note": line.get('vat_note') + "vat_note": line.get('vat_note'), + "provider_reference": line.get('provider_reference'), + "contract_number": line.get('contract_number'), + "circuit_id": line.get('circuit_id'), + "ip_address": line.get('ip_address'), + "end_customer_name": line.get('end_customer_name'), + "service_address": line.get('service_address'), }) elif llm_json_data and llm_json_data.get('lines'): # Use lines from LLM JSON response @@ -1046,7 +2402,13 @@ async def get_file_extracted_data(file_id: int): "unit_price": float(line.get('unit_price')) if line.get('unit_price') else None, "vat_rate": float(line.get('vat_rate')) if line.get('vat_rate') else None, "line_total": float(line.get('line_total')) if line.get('line_total') else None, - "vat_note": line.get('vat_note') + "vat_note": line.get('vat_note'), + "provider_reference": line.get('provider_reference'), + "contract_number": line.get('contract_number'), + "circuit_id": line.get('circuit_id'), + "ip_address": line.get('ip_address'), + "end_customer_name": line.get('end_customer_name'), + "service_address": line.get('service_address'), }) # Build llm_data response @@ -1099,7 +2461,10 @@ async def get_file_extracted_data(file_id: int): "vendor_city": vendor_city_val, "vendor_postal_code": vendor_postal_val, "vendor_email": vendor_email_val, - "lines": formatted_lines + "lines": formatted_lines, + "_validation_warning": llm_json_data.get('_validation_warning'), + "_vat_warning": llm_json_data.get('_vat_warning'), + "_validation_details": llm_json_data.get('_validation_details'), } elif extraction: # Fallback to extraction table columns if no LLM JSON @@ -1121,6 +2486,12 @@ async def get_file_extracted_data(file_id: int): # Get vendor from extraction vendor_matched_id = extraction.get('vendor_matched_id') if extraction else None + internet_sync_preview = None + if extraction: + try: + internet_sync_preview = _sync_globalconnect_extraction_to_internet(extraction, simulate=True) + except Exception as preview_error: + logger.warning("⚠️ Could not build internet sync preview for file %s: %s", file_id, preview_error) return { "file_id": file_id, @@ -1131,6 +2502,7 @@ async def get_file_extracted_data(file_id: int): "llm_data": llm_data, "extraction": extraction, "extraction_lines": extraction_lines if extraction_lines else [], + "internet_sync_preview": internet_sync_preview, "pdf_text_preview": pdf_text[:5000] if pdf_text else None } @@ -1321,13 +2693,22 @@ async def link_vendor_to_extraction(file_id: int, data: dict): "UPDATE incoming_files SET detected_vendor_id = %s, status = 'processed' WHERE file_id = %s", (vendor_id, file_id) ) + + sync_result = None + extraction_row = execute_query_single( + "SELECT * FROM extractions WHERE extraction_id = %s", + (extraction_id,), + ) + if extraction_row: + sync_result = _sync_globalconnect_extraction_to_internet(extraction_row) logger.info(f"✅ Linked vendor {vendor['name']} (ID: {vendor_id}) to file {file_id}") return { "status": "success", "vendor_id": vendor_id, - "vendor_name": vendor['name'] + "vendor_name": vendor['name'], + "internet_sync": sync_result, } except HTTPException: @@ -1459,6 +2840,14 @@ async def link_vendor_to_extraction(file_id: int, data: dict): "UPDATE incoming_files SET detected_vendor_id = %s, status = 'processed' WHERE file_id = %s", (vendor_id, file_id) ) + + sync_result = None + extraction_row = execute_query_single( + "SELECT * FROM extractions WHERE extraction_id = %s", + (extraction_id,), + ) + if extraction_row: + sync_result = _sync_globalconnect_extraction_to_internet(extraction_row) logger.info(f"✅ Linked vendor {vendor['name']} (ID: {vendor_id}) to extraction {extraction_id}") @@ -1466,7 +2855,8 @@ async def link_vendor_to_extraction(file_id: int, data: dict): "status": "success", "vendor_id": vendor_id, "vendor_name": vendor['name'], - "extraction_id": extraction_id + "extraction_id": extraction_id, + "internet_sync": sync_result, } except HTTPException: @@ -1523,6 +2913,10 @@ async def create_invoice_from_extraction(file_id: int): llm_data = {} elif not llm_data: llm_data = {} + + validation_warning = llm_data.get('_validation_warning') if isinstance(llm_data, dict) else None + vat_warning = llm_data.get('_vat_warning') if isinstance(llm_data, dict) else None + validation_details = llm_data.get('_validation_details') if isinstance(llm_data, dict) else None # Get invoice number and type from LLM data or generate one invoice_number = llm_data.get('invoice_number') if llm_data else None @@ -1585,6 +2979,15 @@ async def create_invoice_from_extraction(file_id: int): currency=extraction_data.get("currency"), file_id=file_id, ) + _append_amount_validation_case_note( + sag_id=sag_id, + invoice_id=invoice_id, + invoice_number=invoice_number, + validation_details=validation_details, + validation_warning=validation_warning, + vat_warning=vat_warning, + file_id=file_id, + ) # Create invoice lines if lines: @@ -1600,7 +3003,7 @@ async def create_invoice_from_extraction(file_id: int): product_id = _ensure_product_for_supplier_line( vendor_id=extraction_data.get('vendor_matched_id'), vendor_name=extraction.get('vendor_name'), - description=line.get('description'), + description=_compose_supplier_line_description(line), sku=sku, unit_price=unit_price, currency=extraction_data.get('currency'), @@ -1615,7 +3018,7 @@ async def create_invoice_from_extraction(file_id: int): ( invoice_id, line.get('line_number'), - line['description'], + _compose_supplier_line_description(line), quantity, unit_price, line_total, @@ -1631,6 +3034,8 @@ async def create_invoice_from_extraction(file_id: int): "UPDATE incoming_files SET status = 'completed' WHERE file_id = %s", (file_id,) ) + + internet_sync_result = _sync_globalconnect_extraction_to_internet(extraction_data) logger.info(f"✅ Created supplier invoice {invoice_id} from extraction {extraction['extraction_id']}") @@ -1641,7 +3046,13 @@ async def create_invoice_from_extraction(file_id: int): "invoice_number": invoice_number, "vendor_name": extraction['vendor_name'], "total_amount": extraction['total_amount'], - "currency": extraction['currency'] + "currency": extraction['currency'], + "internet_sync": internet_sync_result, + "amount_validation": { + "warning": validation_warning, + "vat_warning": vat_warning, + "details": validation_details, + }, } except HTTPException: @@ -1651,13 +3062,31 @@ async def create_invoice_from_extraction(file_id: int): raise HTTPException(status_code=500, detail=str(e)) +@router.post("/supplier-invoices/files/{file_id}/sync-internet") +async def sync_extraction_to_internet(file_id: int): + """Create or update internet connections/IP ranges from a GlobalConnect extraction.""" + extraction = execute_query_single( + """ + SELECT * + FROM extractions + WHERE file_id = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (file_id,), + ) + if not extraction: + raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") + + result = _sync_globalconnect_extraction_to_internet(extraction) + return { + "status": "success", + "file_id": file_id, + "internet_sync": result, + } + + # Keep existing endpoints below... - - except HTTPException: - raise - except Exception as e: - logger.error(f"❌ Failed to delete file: {e}") - raise HTTPException(status_code=500, detail=str(e)) # ========== TEMPLATE MANAGEMENT (must be before {invoice_id} route) ========== @@ -3197,13 +4626,17 @@ async def reprocess_uploaded_file(file_id: int): """INSERT INTO extraction_lines (extraction_id, line_number, description, quantity, unit_price, line_total, vat_rate, vat_note, confidence, - ip_address, contract_number, location_street, location_zip, location_city) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ip_address, contract_number, provider_reference, customer_reference, circuit_id, + end_customer_name, period_start, period_end, service_address, + location_street, location_zip, location_city) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING line_id""", (extraction_id, idx, line.get('description'), quantity, unit_price, line_total, None, None, 1.0, line.get('ip_address'), line.get('contract_number'), + line.get('provider_reference'), line.get('customer_reference'), line.get('circuit_id'), + line.get('end_customer_name'), line.get('period_start'), line.get('period_end'), line.get('service_address'), line.get('location_street'), line.get('location_zip'), line.get('location_city')) ) logger.info(f"✅ Saved {len(extracted_fields['lines'])} line items") @@ -3325,13 +4758,20 @@ async def reprocess_uploaded_file(file_id: int): execute_insert( """INSERT INTO extraction_lines (extraction_id, line_number, description, quantity, unit_price, - line_total, vat_rate, confidence) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + line_total, vat_rate, confidence, + ip_address, contract_number, provider_reference, customer_reference, circuit_id, + end_customer_name, period_start, period_end, service_address, + location_street, location_zip, location_city) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING line_id""", (extraction_id, idx, line.get('description'), line.get('quantity'), line.get('unit_price'), line.get('line_total'), line.get('vat_rate'), - confidence) + confidence, + line.get('ip_address'), line.get('contract_number'), + line.get('provider_reference'), line.get('customer_reference'), line.get('circuit_id'), + line.get('end_customer_name'), line.get('period_start'), line.get('period_end'), line.get('service_address'), + line.get('location_street'), line.get('location_zip'), line.get('location_city')) ) # Update file status to ai_extracted diff --git a/app/billing/frontend/supplier_invoices.html b/app/billing/frontend/supplier_invoices.html index 52c078e..d7e3d8d 100644 --- a/app/billing/frontend/supplier_invoices.html +++ b/app/billing/frontend/supplier_invoices.html @@ -99,6 +99,51 @@ .status-processing { background-color: #6c757d; color: #fff; } .status-failed { background-color: var(--danger); color: #fff; } .status-completed { background-color: var(--success); color: #fff; } + + .sync-report-card { + border: 1px solid rgba(13, 110, 253, 0.14); + border-radius: 14px; + background: linear-gradient(180deg, rgba(13, 110, 253, 0.04), rgba(13, 110, 253, 0.015)); + } + + .sync-report-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.75rem; + } + + .sync-report-metric { + border: 1px solid rgba(13, 110, 253, 0.12); + border-radius: 12px; + padding: 0.75rem; + background: rgba(255,255,255,0.7); + } + + .sync-report-metric .label { + display: block; + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + margin-bottom: 0.2rem; + } + + .sync-report-metric .value { + font-weight: 700; + font-size: 1.1rem; + } + + .sync-line-list { + display: grid; + gap: 0.5rem; + } + + .sync-line-item { + border: 1px solid rgba(0,0,0,0.08); + border-radius: 10px; + padding: 0.7rem 0.85rem; + background: #fff; + } {% endblock %} @@ -2036,6 +2081,114 @@ function getFileStatusBadge(status) { return badges[status] || `${status}`; } +function renderSyncVerificationBadge(verification) { + if (!verification) return ''; + if (verification.requires_manual_review) { + return 'Kræver manuel kontrol'; + } + if (verification.fully_synced) { + return 'Alt er dækket'; + } + return 'Ingen internet-sync'; +} + +function renderSyncReport(syncReport) { + if (!syncReport || syncReport.skipped) return ''; + + const verification = syncReport.verification || {}; + const skippedItems = Array.isArray(syncReport.skipped_items) ? syncReport.skipped_items : []; + const lineAudit = Array.isArray(syncReport.line_audit) ? syncReport.line_audit : []; + const syncedLines = lineAudit.filter((item) => item.status === 'synced'); + const ignoredLines = lineAudit.filter((item) => item.status === 'ignored'); + + const renderLine = (item, tone) => ` +
+
+
+
Linje ${item.line_number || '-'} · ${escapeHtml(item.description || '-')}
+
+ ${item.provider_reference ? `Ref: ${escapeHtml(item.provider_reference)} · ` : ''} + ${item.ip_address ? `IP/CIDR: ${escapeHtml(item.ip_address)} · ` : ''} + ${item.service_address ? `Adresse: ${escapeHtml(item.service_address)}` : ''} +
+ ${item.reason ? `
${escapeHtml(item.reason)}
` : ''} +
+ ${item.result ? escapeHtml(item.result) : escapeHtml(item.status)} +
+
+ `; + + return ` +
+
+
+
Internet-importkontrol
+
Alle relevante linjer er gennemgået enkeltvis. Alt der springes over vises her med årsag.
+
+
${renderSyncVerificationBadge(verification)}
+
+ +
+
+ Linjer i alt +
${verification.total_lines || 0}
+
+
+ Relevante linjer +
${verification.actionable_lines || 0}
+
+
+ Synkroniseret +
${verification.synced_actionable_lines || 0}
+
+
+ Sprunget over +
${verification.skipped_actionable_lines || 0}
+
+
+ Forbindelser +
${syncReport.connections_created || 0} ny · ${syncReport.connections_updated || 0} opdat.
+
+
+ IP-ranges +
${syncReport.ip_ranges_synced || 0}
+
+
+ + ${skippedItems.length ? ` +
+ ${skippedItems.length} linjer blev sprunget over. De skal gennemgås manuelt før du kan være sikker på, at alt er oprettet. +
+
+ ${skippedItems.map((item) => renderLine(item, 'danger')).join('')} +
+ ` : ` +
+ Ingen relevante linjer blev sprunget over. +
+ `} + + ${syncedLines.length ? ` +
+ Vis synkroniserede linjer (${syncedLines.length}) +
+ ${syncedLines.map((item) => renderLine(item, 'success')).join('')} +
+
+ ` : ''} + + ${ignoredLines.length ? ` +
+ Vis linjer uden internet-handling (${ignoredLines.length}) +
+ ${ignoredLines.map((item) => renderLine(item, 'secondary')).join('')} +
+
+ ` : ''} +
+ `; +} + // NEW: Batch analyze all files async function batchAnalyzeAllFiles() { if (!confirm('Kør automatisk analyse på alle ubehandlede filer?\n\nDette kan tage flere minutter afhængigt af antal filer.\nSiden opdateres automatisk undervejs.')) { @@ -2757,6 +2910,7 @@ async function reviewExtractedData(fileId) { const ext = data.extraction; const lines = data.extraction_lines || []; + const syncPreview = data.internet_sync_preview || null; // Parse JSON if llm_response_json exists let aiData = null; @@ -2840,6 +2994,8 @@ async function reviewExtractedData(fileId) {
${escapeHtml(data.pdf_text_preview)}
` : '
PDF tekst ikke tilgængelig - prøv at genbehandle filen
'} + + ${renderSyncReport(syncPreview)} `; document.getElementById('reviewModalContent').innerHTML = modalContent; @@ -3188,7 +3344,11 @@ async function createInvoiceFromExtraction() { if (response.ok) { const result = await response.json(); - alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}`); + const syncReport = result.internet_sync || null; + const syncWarning = syncReport?.verification?.requires_manual_review + ? `\n\nADVARSEL: ${syncReport.verification.skipped_actionable_lines} internet-linjer blev sprunget over. Åbn review igen og kontroller dem.` + : ''; + alert(`✅ Faktura oprettet!\n\nFakturanummer: ${result.invoice_number}\nLeverandør: ${result.vendor_name}\nBeløb: ${result.total_amount} ${result.currency}${syncWarning}`); // Close modal and refresh const modalInstance = bootstrap.Modal.getInstance(modal); diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index a740816..2517549 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -80,6 +80,112 @@ font-weight: 700; color: var(--text-primary); } + + .internet-stat-card { + background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01)); + border: 1px solid rgba(15, 76, 117, 0.14); + border-radius: 14px; + padding: 1rem; + height: 100%; + } + + .internet-stat-label { + color: var(--text-secondary); + font-size: 0.78rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.35rem; + } + + .internet-stat-value { + color: var(--text-primary); + font-size: 1.4rem; + font-weight: 700; + } + + .internet-shell { + background: linear-gradient(180deg, rgba(15, 76, 117, 0.05), rgba(255, 255, 255, 0)); + border: 1px solid rgba(15, 76, 117, 0.12); + border-radius: 18px; + padding: 1.25rem; + } + + .internet-connection-card { + border: 1px solid rgba(15, 76, 117, 0.12); + border-radius: 16px; + background: var(--bg-card); + padding: 1rem; + box-shadow: 0 10px 24px rgba(15, 76, 117, 0.06); + } + + .internet-connection-card + .internet-connection-card { + margin-top: 0.9rem; + } + + .internet-meta-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + } + + .internet-meta-item { + background: rgba(15, 76, 117, 0.04); + border-radius: 12px; + padding: 0.75rem; + } + + .internet-meta-item .label { + display: block; + color: var(--text-secondary); + font-size: 0.74rem; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + } + + .internet-meta-item .value { + color: var(--text-primary); + font-weight: 600; + } + + .internet-status-pill { + display: inline-flex; + align-items: center; + gap: 0.35rem; + border-radius: 999px; + padding: 0.35rem 0.7rem; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + } + + .internet-status-active { + background: rgba(25, 135, 84, 0.12); + color: #146c43; + } + + .internet-status-inactive { + background: rgba(108, 117, 125, 0.14); + color: #495057; + } + + .internet-status-pending, .internet-status-planned { + background: rgba(255, 193, 7, 0.18); + color: #997404; + } + + .internet-status-terminated, .internet-status-cancelled { + background: rgba(220, 53, 69, 0.12); + color: #b02a37; + } + + .internet-create-panel { + border: 1px dashed rgba(15, 76, 117, 0.22); + border-radius: 16px; + background: rgba(15, 76, 117, 0.03); + padding: 1rem; + } .info-row { display: flex; @@ -566,6 +672,11 @@ Lokationer +
  • Prepaid Cards
  • Fastpris Aftaler
  • Abonnementer
  • +
  • Internetforbindelser
  • @@ -1015,6 +1016,7 @@
  • Medarbejder Log
  • Servicekontrakt Migration
  • Servicekontrakt Rapport
  • +
  • Internet Wizard v2
  • Ordrer
  • Kunder
  • @@ -1427,7 +1429,7 @@ if (bmcOriginalFetch) { - +