feat(ticket): update email integration to use new priority constants for ticket classification feat(procurement): add procurement overview page with dynamic data loading and display test(subscriptions): add tests for billing calendar to ensure correct invoice dates feat(reminder): implement automated task lists with user-defined rules for reminders feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
204 lines
9.7 KiB
Python
204 lines
9.7 KiB
Python
"""Create deduplicated procurement cases for externally detected internet changes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Mapping, Optional
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import execute_query, execute_query_single
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
RELEVANT_FIELDS = {
|
|
"address", "service_address", "monthly_cost", "sales_price", "technology",
|
|
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
|
|
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
|
|
"sla_price", "sla_status", "ip_range", "cidr", "contract_number", "range_added",
|
|
"range_removed", "range_monthly_cost", "range_sales_price",
|
|
}
|
|
|
|
FIELD_LABELS = {
|
|
"address": "Adresse", "service_address": "Serviceadresse", "monthly_cost": "Indkøbspris",
|
|
"sales_price": "Salgspris", "technology": "Teknologi", "connection_type": "Forbindelsestype",
|
|
"circuit_number": "Kredsløbsnummer", "provider_reference": "Leverandørreference",
|
|
"provider": "Leverandør", "vendor_id": "Leverandør", "speed_mbps": "Hastighed",
|
|
"download_mbps": "Download", "upload_mbps": "Upload", "status": "Status",
|
|
"sla_subscription_id": "SLA-aftale", "sla_price": "SLA-pris", "sla_status": "SLA-status",
|
|
"ip_range": "IP-range", "cidr": "IP-range", "contract_number": "Kontraktnummer",
|
|
"range_added": "Nyt IP-range", "range_removed": "Fjernet IP-range",
|
|
"range_monthly_cost": "IP-range indkøbspris", "range_sales_price": "IP-range salgspris",
|
|
}
|
|
|
|
|
|
def filter_relevant_changes(changes: Mapping[str, Any] | None) -> dict[str, dict[str, Any]]:
|
|
filtered: dict[str, dict[str, Any]] = {}
|
|
for field, raw in (changes or {}).items():
|
|
if field not in RELEVANT_FIELDS:
|
|
continue
|
|
change = raw if isinstance(raw, Mapping) else {"from": None, "to": raw}
|
|
before, after = change.get("from"), change.get("to")
|
|
if str(before or "").strip() == str(after or "").strip():
|
|
continue
|
|
filtered[field] = {"from": before, "to": after}
|
|
return filtered
|
|
|
|
|
|
def _procurement_customer_id() -> int:
|
|
configured = getattr(settings, "PROCUREMENT_CASE_CUSTOMER_ID", None)
|
|
if configured:
|
|
row = execute_query_single("SELECT id FROM customers WHERE id=%s AND is_active=true", (configured,))
|
|
if row:
|
|
return int(row["id"])
|
|
row = execute_query_single(
|
|
"""SELECT id FROM customers WHERE is_active=true AND LOWER(name) LIKE %s
|
|
ORDER BY CASE WHEN LOWER(name) LIKE %s THEN 0 ELSE 1 END, id LIMIT 1""",
|
|
("%bmc%", "%bmc networks%"),
|
|
)
|
|
if not row:
|
|
raise ValueError("BMC's interne indkøbskunde blev ikke fundet")
|
|
return int(row["id"])
|
|
|
|
|
|
def _economy_group_id() -> Optional[int]:
|
|
row = execute_query_single(
|
|
"""SELECT id FROM groups WHERE LOWER(name) LIKE ANY(%s)
|
|
ORDER BY id LIMIT 1""", (["%økonomi%", "%okonomi%", "%economic%"],),
|
|
)
|
|
return int(row["id"]) if row else None
|
|
|
|
|
|
def _render_description(
|
|
*, connection_id: int, connection_name: str, reference: str, provider: str,
|
|
source_label: str, source_url: Optional[str], changes: Mapping[str, Mapping[str, Any]],
|
|
) -> str:
|
|
lines = [
|
|
"Automatisk oprettet efter en ekstern ændring af en internetforbindelse.", "",
|
|
f"Forbindelse: {connection_name}", f"Reference: {reference or '-'}",
|
|
f"Leverandør: {provider or '-'}", f"Kilde: {source_label}",
|
|
f"Link til forbindelse: /economy/internet-connections/{connection_id}",
|
|
]
|
|
if source_url:
|
|
lines.append(f"Link til kilde: {source_url}")
|
|
lines.extend(["", "Registrerede ændringer:"])
|
|
for field, change in changes.items():
|
|
lines.append(f"- {FIELD_LABELS.get(field, field)}: {change.get('from')} → {change.get('to')}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def ensure_external_change_case(
|
|
*, connection_id: int, source_type: str, source_key: str, source_label: str,
|
|
changes: Mapping[str, Any], connection_name: str = "Internetforbindelse",
|
|
reference: str = "", provider: str = "", owner_customer_id: Optional[int] = None,
|
|
source_url: Optional[str] = None,
|
|
) -> dict[str, Any]:
|
|
relevant = filter_relevant_changes(changes)
|
|
if not relevant:
|
|
return {"case_id": None, "created": False, "changes": {}, "error": None}
|
|
source_type = str(source_type or "external").strip().lower()
|
|
source_key = str(source_key or "").strip()
|
|
if not source_key:
|
|
return {"case_id": None, "created": False, "changes": relevant, "error": "Kilden mangler en stabil nøgle"}
|
|
|
|
try:
|
|
existing = execute_query_single(
|
|
"""SELECT id, sag_id, changes FROM internet_connection_change_cases
|
|
WHERE connection_id=%s AND source_type=%s AND source_key=%s""",
|
|
(connection_id, source_type, source_key),
|
|
)
|
|
merged = dict((existing or {}).get("changes") or {})
|
|
merged.update(relevant)
|
|
case_customer_id = int(owner_customer_id) if owner_customer_id else _procurement_customer_id()
|
|
title = f"Internetændring {reference or connection_name} · {source_label}"[:255]
|
|
description = _render_description(
|
|
connection_id=connection_id, connection_name=connection_name, reference=reference,
|
|
provider=provider, source_label=source_label, source_url=source_url, changes=merged,
|
|
)
|
|
case_id = int(existing["sag_id"]) if existing and existing.get("sag_id") else None
|
|
created = False
|
|
if case_id:
|
|
execute_query(
|
|
"UPDATE sag_sager SET beskrivelse=%s, updated_at=NOW() WHERE id=%s AND deleted_at IS NULL",
|
|
(description, case_id), fetch=False,
|
|
)
|
|
else:
|
|
row = execute_query_single(
|
|
"""INSERT INTO sag_sager
|
|
(titel, beskrivelse, template_key, status, customer_id, assigned_group_id, created_by_user_id)
|
|
VALUES (%s,%s,'indkøb','åben',%s,%s,1) RETURNING id""",
|
|
(title, description, case_customer_id, _economy_group_id()),
|
|
)
|
|
case_id = int(row["id"])
|
|
created = True
|
|
|
|
if existing:
|
|
execute_query(
|
|
"""UPDATE internet_connection_change_cases SET sag_id=%s, source_label=%s,
|
|
source_url=%s, changes=%s::jsonb, last_error=NULL, updated_at=NOW() WHERE id=%s""",
|
|
(case_id, source_label, source_url, json.dumps(merged, ensure_ascii=False), existing["id"]), fetch=False,
|
|
)
|
|
else:
|
|
execute_query(
|
|
"""INSERT INTO internet_connection_change_cases
|
|
(connection_id,source_type,source_key,source_label,source_url,sag_id,changes)
|
|
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
|
|
(connection_id, source_type, source_key, source_label, source_url, case_id,
|
|
json.dumps(merged, ensure_ascii=False)), fetch=False,
|
|
)
|
|
return {"case_id": case_id, "created": created, "changes": merged, "error": None}
|
|
except Exception as exc:
|
|
logger.warning("Could not create internet change case for connection %s: %s", connection_id, exc)
|
|
# Keep the import operational, but persist a visible control item whenever
|
|
# the audit table itself is available.
|
|
try:
|
|
execute_query(
|
|
"""INSERT INTO internet_connection_change_cases
|
|
(connection_id,source_type,source_key,source_label,source_url,changes,last_error)
|
|
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s)
|
|
ON CONFLICT (connection_id,source_type,source_key) DO UPDATE
|
|
SET changes=internet_connection_change_cases.changes || EXCLUDED.changes,
|
|
last_error=EXCLUDED.last_error, updated_at=NOW()""",
|
|
(connection_id, source_type, source_key, source_label, source_url,
|
|
json.dumps(relevant, ensure_ascii=False), str(exc)),
|
|
fetch=False,
|
|
)
|
|
except Exception:
|
|
logger.exception("Could not persist failed internet change-case audit")
|
|
return {"case_id": None, "created": False, "changes": relevant, "error": str(exc)}
|
|
|
|
|
|
def retry_failed_change_cases(source_type: str, source_key: str) -> dict[str, int]:
|
|
"""Retry audit rows after a transient case-creation failure.
|
|
|
|
Imports are intentionally idempotent, so a repeated invoice normally does
|
|
not apply connection data again. Failed audit rows must nevertheless be
|
|
repairable once the underlying case service has been fixed.
|
|
"""
|
|
rows = execute_query(
|
|
"""SELECT audit.connection_id, audit.source_label, audit.source_url, audit.changes,
|
|
ic.name, ic.circuit_number, ic.provider, ic.customer_id
|
|
FROM internet_connection_change_cases audit
|
|
JOIN internet_connections_connections ic ON ic.id = audit.connection_id
|
|
WHERE audit.source_type=%s AND audit.source_key=%s
|
|
AND audit.sag_id IS NULL AND audit.last_error IS NOT NULL
|
|
AND ic.deleted_at IS NULL""",
|
|
(source_type, source_key),
|
|
) or []
|
|
repaired = 0
|
|
failed = 0
|
|
for row in rows:
|
|
result = ensure_external_change_case(
|
|
connection_id=int(row["connection_id"]), source_type=source_type, source_key=source_key,
|
|
source_label=str(row.get("source_label") or source_key), changes=row.get("changes") or {},
|
|
connection_name=str(row.get("name") or "Internetforbindelse"),
|
|
reference=str(row.get("circuit_number") or ""),
|
|
provider=str(row.get("provider") or ""), owner_customer_id=row.get("customer_id"),
|
|
source_url=row.get("source_url"),
|
|
)
|
|
if result.get("case_id"):
|
|
repaired += 1
|
|
else:
|
|
failed += 1
|
|
return {"repaired": repaired, "failed": failed}
|