feat: Add end-to-end tests for Sag module HTTP API

- Implemented a comprehensive end-to-end testing script for the Sag module's HTTP API, covering various functionalities including case creation, updates, and file uploads.
- Introduced a safe HTML sanitizer utility to ensure safe rendering of HTML content in the BMC Hub UI.
- Added database migrations for new features including WAN connection marking for wall outlets, permanent audit trails for supplier invoices, and dedicated permissions for the Sag module.
- Created a migration center for manual subscription and invoice migrations with relevant tables and indexes.
- Added tests for migration center functionalities, ensuring stability and correctness of the new features.
This commit is contained in:
Christian 2026-07-28 14:18:24 +02:00
parent 428ad21132
commit d81a8f41b4
59 changed files with 7964 additions and 535 deletions

View File

@ -2,6 +2,7 @@
# POSTGRESQL DATABASE - Local Development # POSTGRESQL DATABASE - Local Development
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk
# Database credentials (bruges af docker-compose) # Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub POSTGRES_USER=bmc_hub
@ -171,4 +172,4 @@ EMAIL_PROCESS_INTERVAL_MINUTES=5
EMAIL_WORKFLOWS_ENABLED=true EMAIL_WORKFLOWS_ENABLED=true
EMAIL_WORKFLOW_AUTORUN_ENABLED=false EMAIL_WORKFLOW_AUTORUN_ENABLED=false
EMAIL_MAX_UPLOAD_SIZE_MB=50 EMAIL_MAX_UPLOAD_SIZE_MB=50
ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip ALLOWED_EXTENSIONS=.pdf,.jpg,.jpeg,.png,.gif,.doc,.docx,.xls,.xlsx,.zip

View File

@ -24,6 +24,7 @@ GITHUB_REPO=ct/bmc_hub
# POSTGRESQL DATABASE - Production # POSTGRESQL DATABASE - Production
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub_prod:CHANGE_THIS_PASSWORD@postgres:5432/bmc_hub_prod DATABASE_URL=postgresql://bmc_hub_prod:CHANGE_THIS_PASSWORD@postgres:5432/bmc_hub_prod
HUB_BASE_URL=https://hub.bmcnetworks.dk
# Database credentials (bruges af docker-compose/podman-compose) # Database credentials (bruges af docker-compose/podman-compose)
POSTGRES_USER=bmc_hub_prod POSTGRES_USER=bmc_hub_prod

View File

@ -5,6 +5,7 @@ Sends rich formatted notifications to Mattermost webhook
import logging import logging
import aiohttp import aiohttp
import json
from datetime import datetime from datetime import datetime
from typing import Dict, Optional, List from typing import Dict, Optional, List
from app.core.config import settings from app.core.config import settings
@ -346,7 +347,7 @@ class MattermostNotification:
""" """
if not self.enabled or not self.webhook_url: if not self.enabled or not self.webhook_url:
logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id) logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id)
return return False, "Mattermost is disabled or webhook URL is missing"
try: try:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
@ -355,21 +356,63 @@ class MattermostNotification:
logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id) logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id)
# Log to database # Log to database
execute_insert( if job_id is not None:
"""INSERT INTO backup_notifications execute_insert(
(backup_job_id, event_type, message, mattermost_payload) """INSERT INTO backup_notifications
VALUES (%s, %s, %s, %s)""", (backup_job_id, event_type, message, mattermost_payload)
(job_id, event_type, payload.get('text', ''), str(payload)) VALUES (%s, %s, %s, %s)""",
) (job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
)
return True, "Mattermost notification sent"
else: else:
error_text = await response.text() error_text = await response.text()
logger.error("❌ Notification failed: HTTP %s - %s", should_retry_form = (
response.status, error_text) response.status in (400, 404, 415)
and (
"media type application/json" in error_text.lower()
or "incoming_webhook.general.app_error" in error_text.lower()
)
)
if not should_retry_form:
logger.error("❌ Notification failed: HTTP %s - %s",
response.status, error_text)
return False, f"Mattermost returned HTTP {response.status}: {error_text[:300]}"
form_data = aiohttp.FormData()
form_data.add_field("payload", json.dumps(payload, ensure_ascii=False))
async with session.post(self.webhook_url, data=form_data, timeout=10) as fallback_response:
fallback_text = await fallback_response.text()
if fallback_response.status == 200:
logger.info("📢 Notification sent using Mattermost form compatibility mode: %s", event_type)
if job_id is not None:
execute_insert(
"""INSERT INTO backup_notifications
(backup_job_id, event_type, message, mattermost_payload)
VALUES (%s, %s, %s, %s)""",
(job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
)
return True, "Mattermost notification sent using compatibility mode"
logger.error(
"❌ Mattermost compatibility request failed: HTTP %s - %s",
fallback_response.status,
fallback_text,
)
if "incoming_webhook.general.app_error" in fallback_text:
return False, (
"Mattermost rejected the webhook ID. Create a new Incoming Webhook "
"in Mattermost and use its complete generated /hooks/... URL."
)
return False, (
f"Mattermost returned HTTP {fallback_response.status} "
f"in compatibility mode: {fallback_text[:300]}"
)
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error("❌ Notification connection error: %s", str(e)) logger.error("❌ Notification connection error: %s", str(e))
return False, f"Mattermost connection error: {e}"
except Exception as e: except Exception as e:
logger.error("❌ Notification error: %s", str(e)) logger.error("❌ Notification error: %s", str(e))
return False, f"Mattermost notification error: {e}"
def _should_send_notification(self, event_type: str) -> bool: def _should_send_notification(self, event_type: str) -> bool:
"""Check if notification should be sent based on settings""" """Check if notification should be sent based on settings"""
@ -386,8 +429,7 @@ class MattermostNotification:
def _get_hub_url(self) -> str: def _get_hub_url(self) -> str:
"""Get BMC Hub base URL for action buttons""" """Get BMC Hub base URL for action buttons"""
# TODO: Add HUB_BASE_URL to config return str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
return "http://localhost:8000" # Fallback
# Singleton instance # Singleton instance

View File

@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb" _PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address",
"service_address",
"monthly_cost",
"technology",
"connection_type",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
}
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist") SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -256,7 +267,15 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int], owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]], changes: Dict[str, Dict[str, object]],
) -> Optional[int]: ) -> Optional[int]:
if not changes: # Customer ownership, initial activation and internal classification are
# bookkeeping outcomes of a successful import, not operational incidents.
# Only create cases for changes that can affect delivery or billing.
relevant_changes = {
field: change
for field, change in (changes or {}).items()
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
}
if not relevant_changes:
return None return None
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}" title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
@ -287,7 +306,7 @@ def _ensure_internet_change_case(
return None return None
change_lines = "\n".join( change_lines = "\n".join(
f"- {field}: {change.get('from')} -> {change.get('to')}" f"- {field}: {change.get('from')} -> {change.get('to')}"
for field, change in changes.items() for field, change in relevant_changes.items()
) )
description = ( description = (
"Automatisk oprettet ved import af internetfaktura.\n" "Automatisk oprettet ved import af internetfaktura.\n"
@ -769,7 +788,7 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) ->
normalized_target = _normalize_company_name(end_customer_name) normalized_target = _normalize_company_name(end_customer_name)
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()] address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
best_match = None best_matches = []
best_score = 0 best_score = 0
for customer in customers: for customer in customers:
customer_name = str(customer.get("name") or "").strip() customer_name = str(customer.get("name") or "").strip()
@ -806,9 +825,17 @@ def _match_customer_for_globalconnect_line(line: Dict, customers: List[Dict]) ->
if score > best_score: if score > best_score:
best_score = score best_score = score
best_match = customer best_matches = [customer]
elif score == best_score and score > 0:
best_matches.append(customer)
return best_match if best_score >= 50 else None if best_score < 50 or not best_matches:
return None
# An address shared by several tenants is not enough to select a customer.
# Require a unique winner unless the invoice also supplied a customer name.
if len(best_matches) > 1 and not normalized_target:
return None
return best_matches[0]
def _looks_like_ip_range_line(line: Dict) -> bool: def _looks_like_ip_range_line(line: Dict) -> bool:
@ -1166,7 +1193,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
) )
primary_line = sorted_lines[0] primary_line = sorted_lines[0]
display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip() 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) service_address = _build_service_address(primary_line)
if not service_address: if not service_address:
logger.warning( logger.warning(
@ -1175,9 +1201,40 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
invoice_number, invoice_number,
) )
return None return None
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"]))
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,
)
description = str(primary_line.get("description") or reference) description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip() 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 # 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 owner_customer = matched_customer or internal_owner
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address) is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
connection_name = ( connection_name = (
@ -1194,21 +1251,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
else: else:
note_text = base_note if is_confident else f"{base_note} {mapping_note}" 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) download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
target_status = "active" if (is_confident or internal_owner) else "pending" target_status = "active" if (is_confident or internal_owner) else "pending"
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer) shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
@ -1540,13 +1582,76 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
) )
if not row: if not row:
return False return False
if str(row.get("allocation_model") or "").lower() == "shared":
return True
if not service_address: if not service_address:
return True return True
# A shared connection may serve several customers, but a supplier reference
# and its IP-range must still belong to the same physical service address.
# Bypassing the address check here caused ranges from another site to
# overwrite customer, address and price data on the wrong connection.
return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(service_address) return _normalize_service_address_for_match(row.get("address")) == _normalize_service_address_for_match(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()
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,
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
AND connection.deleted_at IS NULL
ORDER BY range.id
""",
(cidr,),
) or []
if reference:
matching_reference = [
row for row in rows
if _normalize_provider_reference(row.get("provider_reference")) == reference
]
if matching_reference:
rows = matching_reference
if not rows:
return {"connection_id": None, "conflict_reason": None}
normalized_target = _normalize_service_address_for_match(service_address)
exact_address = [
row for row in rows
if normalized_target
and _normalize_service_address_for_match(row.get("service_address") or row.get("connection_address")) == normalized_target
]
if len(exact_address) == 1:
return {
"connection_id": int(exact_address[0]["connection_id"]),
"canonical_reference": exact_address[0].get("connection_reference"),
"conflict_reason": None,
}
if len(rows) == 1 and not normalized_target:
return {
"connection_id": int(rows[0]["connection_id"]),
"canonical_reference": rows[0].get("connection_reference"),
"conflict_reason": None,
}
known_addresses = sorted({
str(row.get("service_address") or row.get("connection_address") or "").strip()
for row in rows
if str(row.get("service_address") or row.get("connection_address") or "").strip()
})
reason = f"CIDR {cidr} findes allerede"
if known_addresses:
reason += f" på anden adresse: {', '.join(known_addresses)}"
return {"connection_id": None, "conflict_reason": reason}
def _connection_skip_reason(line: Dict) -> str: def _connection_skip_reason(line: Dict) -> str:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
if not reference: if not reference:
@ -1575,6 +1680,48 @@ def _build_sync_audit_entry(line: Dict, classification: str, status: str, reason
} }
def _clear_inherited_addresses_for_addressless_eb_ranges(lines: List[Dict]) -> List[Dict]:
"""
GlobalConnect's IP overview does not repeat a service address for legacy
EB references. The extractor can incorrectly carry the preceding NKA
address forward. If the matching existing EB connection is deliberately
addressless, retain that unknown state instead of trusting the inherited
address.
"""
references_by_address: Dict[str, set] = {}
for line in lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
address = _normalize_service_address_for_match(_build_service_address(line))
if _looks_like_ip_range_line(line) and reference.startswith("EB") and address:
references_by_address.setdefault(address, set()).add(reference)
# A repeated address across several unrelated EB circuits is the extractor
# carrying the preceding site's address forward. Explicit EB addresses are
# retained when they occur on a single circuit.
inherited_addresses = {
address for address, references in references_by_address.items()
if len(references) >= 2
}
sanitized = []
for source_line in lines:
line = dict(source_line)
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
normalized_address = _normalize_service_address_for_match(_build_service_address(line))
if (
_looks_like_ip_range_line(line)
and reference.startswith("EB")
and normalized_address in inherited_addresses
):
line["service_address"] = None
line["location_street"] = None
line["location_zip"] = None
line["location_city"] = None
line["address_source"] = "not_stated_on_invoice"
sanitized.append(line)
return sanitized
def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_range_candidates: int) -> Dict: 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"}] 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"] synced_lines = [entry for entry in actionable_lines if entry["status"] == "synced"]
@ -1594,11 +1741,13 @@ def _summarize_sync_audit(line_audit: List[Dict], connection_groups: int, ip_ran
} }
def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: bool = False) -> Dict: def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simulate: bool = False) -> Dict:
if not _is_globalconnect_extraction(extraction_row): if not _is_globalconnect_extraction(extraction_row):
return {"skipped": True, "reason": "not_globalconnect"} return {"skipped": True, "reason": "not_globalconnect"}
lines = _load_extraction_lines(extraction_row) lines = _clear_inherited_addresses_for_addressless_eb_ranges(
_load_extraction_lines(extraction_row)
)
if not lines: if not lines:
return {"skipped": True, "reason": "no_lines"} return {"skipped": True, "reason": "no_lines"}
@ -1680,8 +1829,16 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
for audit_index, line in ip_range_lines: for audit_index, line in ip_range_lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line) service_address = _build_service_address(line)
connection_id = connection_map.get(reference) existing_range_resolution = _resolve_existing_ip_range_connection(line)
if existing_range_resolution.get("conflict_reason"):
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)
resolved_from_existing = False resolved_from_existing = False
if existing_range_resolution.get("connection_id"):
resolved_from_existing = True
if connection_id and not _connection_can_host_ip_range(connection_id, service_address): if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
connection_id = None connection_id = None
if not connection_id and reference: if not connection_id and reference:
@ -1700,9 +1857,15 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
line_audit[audit_index]["reason"] = connection_conflict_reason line_audit[audit_index]["reason"] = connection_conflict_reason
continue continue
sync_line = dict(line)
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"]
line_audit[audit_index]["matched_by"] = "existing_cidr_and_address"
line_audit[audit_index]["canonical_reference"] = existing_range_resolution["canonical_reference"]
if connection_id and ( if connection_id and (
simulate simulate
or _upsert_globalconnect_ip_range(connection_id, line, invoice_number) or _upsert_globalconnect_ip_range(connection_id, sync_line, invoice_number)
): ):
created_or_updated_ranges += 1 created_or_updated_ranges += 1
if resolved_from_existing: if resolved_from_existing:
@ -1737,6 +1900,119 @@ def _sync_globalconnect_extraction_to_internet(extraction_row: Dict, simulate: b
} }
def _record_internet_invoice_sync_run(
extraction_row: Dict,
*,
status: str,
result: Optional[Dict] = None,
error_message: Optional[str] = None,
) -> None:
"""Persist the outcome without allowing audit logging to break invoice processing."""
try:
extraction_id = extraction_row.get("extraction_id")
supplier_invoice = execute_query_single(
"""
SELECT id
FROM supplier_invoices
WHERE extraction_id = %s
ORDER BY id DESC
LIMIT 1
""",
(extraction_id,),
) if extraction_id else None
payload = result or {}
verification = payload.get("verification") or {}
execute_update(
"""
INSERT INTO internet_connections_invoice_sync_runs (
file_id, extraction_id, supplier_invoice_id, invoice_number, vendor_name,
invoice_date, status, connections_synced, connections_created,
connections_updated, ip_ranges_synced, total_lines, actionable_lines,
skipped_lines, error_message, result_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb)
""",
(
extraction_row.get("file_id"),
extraction_id,
supplier_invoice.get("id") if supplier_invoice else None,
extraction_row.get("document_id") or extraction_row.get("invoice_number"),
extraction_row.get("vendor_name"),
extraction_row.get("document_date"),
status,
int(payload.get("connections_synced") or 0),
int(payload.get("connections_created") or 0),
int(payload.get("connections_updated") or 0),
int(payload.get("ip_ranges_synced") or 0),
int(verification.get("total_lines") or 0),
int(verification.get("actionable_lines") or 0),
int(verification.get("skipped_actionable_lines") or 0),
error_message,
json.dumps(payload, default=str),
),
)
except Exception as audit_error:
logger.warning("Could not persist internet invoice sync audit: %s", audit_error)
def _sync_globalconnect_extraction_to_internet(
extraction_row: Dict,
simulate: bool = False,
force: bool = False,
) -> Dict:
"""Run GlobalConnect sync and keep a permanent, queryable result for the overview."""
try:
if not simulate and not force:
invoice_number = str(
extraction_row.get("document_id") or extraction_row.get("invoice_number") or ""
).strip()
extraction_id = extraction_row.get("extraction_id")
previous_run = execute_query_single(
"""
SELECT id, status, processed_at
FROM internet_connections_invoice_sync_runs
WHERE status IN ('success', 'warning')
AND (
(%s IS NOT NULL AND extraction_id = %s)
OR (NULLIF(%s, '') IS NOT NULL AND invoice_number = %s)
)
ORDER BY processed_at DESC, id DESC
LIMIT 1
""",
(extraction_id, extraction_id, invoice_number, invoice_number),
)
if previous_run:
return {
"skipped": True,
"reason": "invoice_already_processed",
"invoice_number": invoice_number,
"previous_run_id": previous_run.get("id"),
"previous_status": previous_run.get("status"),
"previous_processed_at": previous_run.get("processed_at"),
}
result = _sync_globalconnect_extraction_to_internet_impl(extraction_row, simulate=simulate)
if not simulate:
verification = result.get("verification") or {}
if result.get("skipped"):
status = "skipped"
elif verification.get("requires_manual_review"):
status = "warning"
else:
status = "success"
_record_internet_invoice_sync_run(extraction_row, status=status, result=result)
return result
except Exception as exc:
if not simulate:
_record_internet_invoice_sync_run(
extraction_row,
status="error",
error_message=str(exc),
result={"exception_type": type(exc).__name__},
)
raise
def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]: def _find_existing_product_id(vendor_id: Optional[int], description: str, sku: Optional[str]) -> Optional[int]:
sku_value = str(sku or "").strip() sku_value = str(sku or "").strip()
desc_value = str(description or "").strip() desc_value = str(description or "").strip()
@ -3078,7 +3354,7 @@ async def sync_extraction_to_internet(file_id: int):
if not extraction: if not extraction:
raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil") raise HTTPException(status_code=404, detail="Ingen extraction fundet for denne fil")
result = _sync_globalconnect_extraction_to_internet(extraction) result = _sync_globalconnect_extraction_to_internet(extraction, force=True)
return { return {
"status": "success", "status": "success",
"file_id": file_id, "file_id": file_id,
@ -4812,6 +5088,17 @@ async def reprocess_uploaded_file(file_id: int):
(vendor_id,)) (vendor_id,))
if vendor: if vendor:
result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)" result["warning"] = f"⚠️ Ingen template fundet for {vendor['name']} - brugte AI extraction (langsommere)"
# GlobalConnect invoices must update the internet module immediately after
# extraction. Previously this only happened after a separate manual
# conversion to supplier_invoice, leaving valid extracted invoices queued.
if "extraction_id" in locals() and extraction_id:
latest_extraction = execute_query_single(
"SELECT * FROM extractions WHERE extraction_id = %s",
(extraction_id,),
)
if latest_extraction and _is_globalconnect_extraction(latest_extraction):
result["internet_sync"] = _sync_globalconnect_extraction_to_internet(latest_extraction)
return result return result

View File

@ -20,6 +20,7 @@ class Settings(BaseSettings):
API_PORT: int = 8000 API_PORT: int = 8000
API_RELOAD: bool = False API_RELOAD: bool = False
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Elnet supplier lookup # Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
@ -68,6 +69,12 @@ class Settings(BaseSettings):
ECONOMIC_READ_ONLY: bool = True ECONOMIC_READ_ONLY: bool = True
ECONOMIC_DRY_RUN: bool = True ECONOMIC_DRY_RUN: bool = True
# Manual migration centre
MIGRATION_CENTER_READ_ONLY: bool = False
MIGRATION_CENTER_STALE_AFTER_DAYS: int = 7
MIGRATION_CENTER_VTIGER_LOCK_FIELD: str = ""
MIGRATION_CENTER_SIMPLY_LOCK_FIELD: str = ""
# Nextcloud Integration # Nextcloud Integration
NEXTCLOUD_READ_ONLY: bool = True NEXTCLOUD_READ_ONLY: bool = True
NEXTCLOUD_DRY_RUN: bool = True NEXTCLOUD_DRY_RUN: bool = True

View File

@ -121,6 +121,10 @@ class MissionProjectLinkCasePayload(BaseModel):
project_task_type: Optional[str] = None project_task_type: Optional[str] = None
class MissionCallCaseLinkPayload(BaseModel):
sag_id: int = Field(..., gt=0)
def _first_query_param(request: Request, *names: str) -> Optional[str]: def _first_query_param(request: Request, *names: str) -> Optional[str]:
for name in names: for name in names:
value = request.query_params.get(name) value = request.query_params.get(name)
@ -351,6 +355,66 @@ async def get_mission_state():
return MissionService.get_state() return MissionService.get_state()
@router.get("/mission/calls/history")
async def get_mission_call_history(limit: int = Query(100, ge=1, le=500)):
return {"calls": MissionService.get_call_history(limit=limit)}
@router.get("/mission/cases/search")
async def search_mission_cases(q: str = Query("", max_length=120), limit: int = Query(20, ge=1, le=50)):
needle = str(q or "").strip()
like = f"%{needle}%"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.status,
c.name AS customer_name
FROM sag_sager s
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.deleted_at IS NULL
AND (
%s = ''
OR s.id::text = %s
OR s.titel ILIKE %s
OR c.name ILIKE %s
)
ORDER BY
CASE WHEN s.id::text = %s THEN 0 ELSE 1 END,
s.updated_at DESC NULLS LAST,
s.id DESC
LIMIT %s
""",
(needle, needle, like, like, needle, limit),
) or []
return {"cases": rows}
@router.patch("/mission/calls/{call_id}/case")
async def link_mission_call_to_case(call_id: int, request: Request, payload: MissionCallCaseLinkPayload):
_require_authenticated_user(request)
case = execute_query_single(
"SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(payload.sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail="Sag ikke fundet")
rows = execute_query(
"""
UPDATE telefoni_opkald
SET sag_id = %s
WHERE id = %s
RETURNING id, callid, sag_id
""",
(payload.sag_id, call_id),
) or []
if not rows:
raise HTTPException(status_code=404, detail="Opkald ikke fundet eller kan ikke redigeres")
return {**dict(rows[0]), "sag_titel": case.get("titel")}
@router.get("/mission/projects") @router.get("/mission/projects")
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)): async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
return { return {

View File

@ -224,6 +224,52 @@ class MissionService:
) )
return rows or [] return rows or []
@staticmethod
def get_call_history(limit: int = 100) -> list[Dict[str, Any]]:
if not MissionService._table_exists("telefoni_opkald"):
return []
rows = execute_query(
"""
SELECT
t.id,
t.callid,
t.direction,
t.ekstern_nummer AS display_number,
t.started_at,
t.ended_at,
COALESCE(
t.duration_sec,
CASE
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
END
) AS duration_sec,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS employee_name,
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name,
customer.name AS company_name,
t.sag_id,
s.titel AS sag_titel,
s.status AS sag_status
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
LEFT JOIN LATERAL (
SELECT cu.name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) customer ON TRUE
LEFT JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
ORDER BY t.started_at DESC, t.id DESC
LIMIT %s
""",
(limit,),
) or []
return [dict(row) for row in rows]
@staticmethod @staticmethod
def get_active_alerts() -> list[Dict[str, Any]]: def get_active_alerts() -> list[Dict[str, Any]]:
if not MissionService._table_exists("mission_uptime_alerts"): if not MissionService._table_exists("mission_uptime_alerts"):
@ -412,6 +458,13 @@ class MissionService:
0 AS score, 0 AS score,
s.start_date AS started_at, s.start_date AS started_at,
s.deadline AS ended_at, s.deadline AS ended_at,
COALESCE(NULLIF(TRIM(c.name), ''), 'Ingen kunde') AS customer_name,
COALESCE(
NULLIF(TRIM(u.full_name), ''),
NULLIF(TRIM(u.username), ''),
CASE WHEN s.ansvarlig_bruger_id IS NOT NULL THEN CONCAT('Bruger #', s.ansvarlig_bruger_id::text) END
) AS responsible_name,
next_todo.title AS next_step,
s.created_at AS updated_at, s.created_at AS updated_at,
0 AS active_milestones, 0 AS active_milestones,
0 AS overdue_milestones, 0 AS overdue_milestones,
@ -426,6 +479,17 @@ class MissionService:
ELSE 0 ELSE 0
END AS overdue_tasks END AS overdue_tasks
FROM sag_sager s FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN customers c ON c.id = s.customer_id
LEFT JOIN LATERAL (
SELECT t.title
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC
LIMIT 1
) next_todo ON TRUE
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
AND ( AND (
@ -448,8 +512,15 @@ class MissionService:
@staticmethod @staticmethod
def get_projects(limit: int = 120) -> list[Dict[str, Any]]: def get_projects(limit: int = 120) -> list[Dict[str, Any]]:
# Project cases are the canonical project portfolio shown in Mission Control.
# `mission_projects` is retained as a legacy fallback for installations that
# do not yet have case-backed projects.
case_projects = MissionService._get_projects_from_cases(limit)
if case_projects:
return case_projects
if not MissionService._table_exists("mission_projects"): if not MissionService._table_exists("mission_projects"):
return MissionService._get_projects_from_cases(limit) return []
rows = execute_query( rows = execute_query(
""" """
@ -462,6 +533,12 @@ class MissionService:
p.started_at, p.started_at,
p.ended_at, p.ended_at,
p.updated_at, p.updated_at,
COALESCE(
NULLIF(TRIM(owner.full_name), ''),
NULLIF(TRIM(owner.username), ''),
task_owner.responsible_name
) AS responsible_name,
COALESCE(next_milestone.title, next_task.next_step) AS next_step,
COUNT(DISTINCT m.id) FILTER ( COUNT(DISTINCT m.id) FILTER (
WHERE m.status NOT IN ('completed', 'cancelled') WHERE m.status NOT IN ('completed', 'cancelled')
) AS active_milestones, ) AS active_milestones,
@ -490,10 +567,49 @@ class MissionService:
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
) AS overdue_tasks ) AS overdue_tasks
FROM mission_projects p FROM mission_projects p
LEFT JOIN users owner ON owner.user_id = p.created_by
LEFT JOIN LATERAL (
SELECT COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
FROM sag_sager s_owner
JOIN users u ON u.user_id = s_owner.ansvarlig_bruger_id
WHERE s_owner.project_id = p.id
AND s_owner.deleted_at IS NULL
AND LOWER(COALESCE(s_owner.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
ORDER BY s_owner.deadline ASC NULLS LAST, s_owner.id ASC
LIMIT 1
) task_owner ON TRUE
LEFT JOIN LATERAL (
SELECT mm.title
FROM mission_project_milestones mm
WHERE mm.project_id = p.id
AND mm.status NOT IN ('completed', 'cancelled')
ORDER BY mm.target_date ASC NULLS LAST, mm.id ASC
LIMIT 1
) next_milestone ON TRUE
LEFT JOIN LATERAL (
SELECT COALESCE(todo.title, s_next.titel) AS next_step
FROM sag_sager s_next
LEFT JOIN LATERAL (
SELECT t.title
FROM sag_todo_steps t
WHERE t.sag_id = s_next.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY COALESCE(t.is_next, FALSE) DESC, t.due_date ASC NULLS LAST, t.id ASC
LIMIT 1
) todo ON TRUE
WHERE s_next.project_id = p.id
AND s_next.deleted_at IS NULL
AND LOWER(COALESCE(s_next.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
ORDER BY s_next.deadline ASC NULLS LAST, s_next.id ASC
LIMIT 1
) next_task ON TRUE
LEFT JOIN mission_project_milestones m ON m.project_id = p.id LEFT JOIN mission_project_milestones m ON m.project_id = p.id
LEFT JOIN mission_project_blockers b ON b.project_id = p.id LEFT JOIN mission_project_blockers b ON b.project_id = p.id
LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL
GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at GROUP BY
p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at,
owner.full_name, owner.username, task_owner.responsible_name, next_milestone.title, next_task.next_step
ORDER BY p.updated_at DESC, p.id DESC ORDER BY p.updated_at DESC, p.id DESC
LIMIT %s LIMIT %s
""", """,
@ -506,14 +622,11 @@ class MissionService:
item.update(MissionService._compute_project_risk(item)) item.update(MissionService._compute_project_risk(item))
result.append(item) result.append(item)
# Important fallback: migration may have created mission_projects but with zero rows.
if not result:
return MissionService._get_projects_from_cases(limit)
return result return result
@staticmethod @staticmethod
def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]: def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]:
if not MissionService._table_exists("mission_projects"): if not MissionService._table_exists("sag_sager"):
return None return None
rows = MissionService.get_projects(limit=200) rows = MissionService.get_projects(limit=200)
@ -556,15 +669,19 @@ class MissionService:
s.titel, s.titel,
s.status, s.status,
s.priority, s.priority,
s.start_date,
s.deadline, s.deadline,
s.ansvarlig_bruger_id, s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
s.project_milestone_id, s.project_milestone_id,
s.is_project_blocker, s.is_project_blocker,
s.project_task_type, s.project_task_type,
s.created_at, s.created_at,
COALESCE(ts.open_todo_count, 0) AS open_todo_count, COALESCE(ts.open_todo_count, 0) AS open_todo_count,
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles,
COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos
FROM sag_sager s FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
COUNT(*) FILTER ( COUNT(*) FILTER (
@ -582,6 +699,24 @@ class MissionService:
), ),
NULL NULL
) AS open_todo_titles ) AS open_todo_titles
,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', t.id,
'title', t.title,
'due_date', t.due_date,
'is_next', COALESCE(t.is_next, FALSE)
)
ORDER BY COALESCE(t.is_next, FALSE) DESC,
COALESCE(t.due_date, DATE '9999-12-31') ASC,
t.id ASC
) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
),
'[]'::jsonb
) AS open_todos
FROM sag_todo_steps t FROM sag_todo_steps t
WHERE t.sag_id = s.id WHERE t.sag_id = s.id
) ts ON TRUE ) ts ON TRUE
@ -626,8 +761,8 @@ class MissionService:
if isinstance(titles_raw, list): if isinstance(titles_raw, list):
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()] project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
# Fallback for case-backed projects: fetch directly related/under cases from relation table. # Case-backed projects store their actual subcases as directed `undersag`
# This is used when a project is a case of type project/projekt and tasks are linked as case relations. # relations. Other relation types must not appear as project subcases.
if not tasks and MissionService._table_exists("sag_relationer"): if not tasks and MissionService._table_exists("sag_relationer"):
tasks = execute_query( tasks = execute_query(
""" """
@ -638,31 +773,27 @@ class MissionService:
FROM sag_relationer sr FROM sag_relationer sr
WHERE sr.deleted_at IS NULL WHERE sr.deleted_at IS NULL
AND sr.kilde_sag_id = %s AND sr.kilde_sag_id = %s
AND LOWER(TRIM(sr.relationstype)) IN ('undersag', 'barn')
UNION ALL
SELECT
sr.kilde_sag_id AS task_id,
sr.relationstype AS relation_type
FROM sag_relationer sr
WHERE sr.deleted_at IS NULL
AND sr.målsag_id = %s
) )
SELECT SELECT
s.id, s.id,
s.titel, s.titel,
s.status, s.status,
s.priority, s.priority,
s.start_date,
s.deadline, s.deadline,
s.ansvarlig_bruger_id, s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
s.project_milestone_id, s.project_milestone_id,
s.is_project_blocker, s.is_project_blocker,
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type, COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
s.created_at, s.created_at,
COALESCE(ts.open_todo_count, 0) AS open_todo_count, COALESCE(ts.open_todo_count, 0) AS open_todo_count,
COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles,
COALESCE(ts.open_todos, '[]'::jsonb) AS open_todos
FROM related r FROM related r
JOIN sag_sager s ON s.id = r.task_id JOIN sag_sager s ON s.id = r.task_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
COUNT(*) FILTER ( COUNT(*) FILTER (
@ -679,7 +810,24 @@ class MissionService:
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
), ),
NULL NULL
) AS open_todo_titles ) AS open_todo_titles,
COALESCE(
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', t.id,
'title', t.title,
'due_date', t.due_date,
'is_next', COALESCE(t.is_next, FALSE)
)
ORDER BY COALESCE(t.is_next, FALSE) DESC,
COALESCE(t.due_date, DATE '9999-12-31') ASC,
t.id ASC
) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
),
'[]'::jsonb
) AS open_todos
FROM sag_todo_steps t FROM sag_todo_steps t
WHERE t.sag_id = s.id WHERE t.sag_id = s.id
) ts ON TRUE ) ts ON TRUE
@ -690,7 +838,7 @@ class MissionService:
s.created_at DESC s.created_at DESC
LIMIT 200 LIMIT 200
""", """,
(project_id, project_id, project_id), (project_id, project_id),
) or [] ) or []
return { return {

View File

@ -190,7 +190,16 @@ async def search_sag(q: str):
s.status, s.status,
s.created_at, s.created_at,
s.customer_id, s.customer_id,
c.name as customer_name c.name as customer_name,
ARRAY(
SELECT b.word
FROM sag_buzzwords sb
JOIN buzzwords b ON b.id = sb.buzzword_id
WHERE sb.sag_id = s.id
AND sb.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY b.word
) AS buzzwords
FROM sag_sager s FROM sag_sager s
LEFT JOIN customers c ON s.customer_id = c.id LEFT JOIN customers c ON s.customer_id = c.id
WHERE s.deleted_at IS NULL WHERE s.deleted_at IS NULL

File diff suppressed because it is too large Load Diff

View File

@ -9,12 +9,11 @@ from typing import List, Optional, Dict, Any
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
import unicodedata import unicodedata
import html
import re import re
from html.parser import HTMLParser
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
@ -130,84 +129,8 @@ def _is_supplier_case_type(case_type: Optional[str]) -> bool:
return "indk" in value or "leverand" in value or "supplier" in value return "indk" in value or "leverand" in value or "supplier" in value
class _SafeDescriptionHtmlSanitizer(HTMLParser):
"""Allow-list HTML sanitizer for case descriptions created from email content."""
_ALLOWED_TAGS = {
"b", "strong", "i", "em", "u",
"p", "br", "hr",
"ul", "ol", "li",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
"a",
}
_VOID_TAGS = {"br", "hr"}
_ALLOWED_ATTRS = {
"a": {"href", "title", "target", "rel"},
"th": {"colspan", "rowspan"},
"td": {"colspan", "rowspan"},
}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: List[str] = []
def handle_starttag(self, tag, attrs):
tag = (tag or "").lower()
if tag not in self._ALLOWED_TAGS:
return
attr_allow = self._ALLOWED_ATTRS.get(tag, set())
safe_attrs = []
for key, value in attrs or []:
key_l = str(key or "").lower()
if key_l not in attr_allow:
continue
safe_value = str(value or "").strip()
if key_l == "href":
href_l = safe_value.lower()
if href_l.startswith(("javascript:", "data:", "vbscript:")):
continue
safe_attrs.append(f'{key_l}="{html.escape(safe_value, quote=True)}"')
attrs_html = (" " + " ".join(safe_attrs)) if safe_attrs else ""
self._parts.append(f"<{tag}{attrs_html}>")
def handle_endtag(self, tag):
tag = (tag or "").lower()
if tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
return
self._parts.append(f"</{tag}>")
def handle_data(self, data):
self._parts.append(html.escape(data or ""))
def handle_entityref(self, name):
self._parts.append(f"&{name};")
def handle_charref(self, name):
self._parts.append(f"&#{name};")
def get_html(self) -> str:
return "".join(self._parts).strip()
def _sanitize_case_description_html(value: Optional[str]) -> str: def _sanitize_case_description_html(value: Optional[str]) -> str:
raw = str(value or "").strip() return sanitize_safe_html(value)
if not raw:
return ""
# Fast path: plain text stays plain text.
if "<" not in raw and ">" not in raw:
return raw
sanitizer = _SafeDescriptionHtmlSanitizer()
try:
sanitizer.feed(raw)
sanitizer.close()
return sanitizer.get_html()
except Exception:
# Fallback to escaped text if parsing fails.
return html.escape(raw)
def _extract_domain_from_email(email: Optional[str]) -> str: def _extract_domain_from_email(email: Optional[str]) -> str:

View File

@ -1008,14 +1008,19 @@
/* Email Upload Drop Zone */ /* Email Upload Drop Zone */
.email-upload-zone { .email-upload-zone {
padding: 0.75rem 1rem; padding: 0.4rem 0.65rem;
border-bottom: 1px solid rgba(0,0,0,0.05); border-bottom: 1px solid rgba(0,0,0,0.05);
} }
.drop-zone { .drop-zone {
border: 2px dashed var(--accent); display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.25rem 0.45rem;
border: 1px dashed var(--accent);
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 1.5rem 1rem; padding: 0.48rem 0.65rem;
text-align: center; text-align: center;
background: rgba(15, 76, 117, 0.03); background: rgba(15, 76, 117, 0.03);
cursor: pointer; cursor: pointer;
@ -1036,15 +1041,24 @@
} }
.drop-zone i { .drop-zone i {
font-size: 2rem; font-size: 1.05rem;
color: var(--accent); color: var(--accent);
display: block; display: inline-block;
margin-bottom: 0.5rem; margin: 0;
} }
.drop-zone p { .drop-zone p {
margin: 0; margin: 0;
font-size: 0.9rem; font-size: 0.78rem;
}
.drop-zone small {
font-size: 0.68rem;
}
.drop-zone .d-block {
display: inline !important;
margin-top: 0 !important;
} }
.upload-progress { .upload-progress {
@ -1122,7 +1136,7 @@
</div> </div>
<!-- Email Upload Drop Zone --> <!-- Email Upload Drop Zone -->
<div class="email-upload-zone" id="emailUploadZone" style="display: none;"> <div class="email-upload-zone" id="emailUploadZone">
<div class="drop-zone" id="dropZone"> <div class="drop-zone" id="dropZone">
<i class="bi bi-cloud-upload"></i> <i class="bi bi-cloud-upload"></i>
<p class="mb-1"><strong>Træk emails hertil</strong></p> <p class="mb-1"><strong>Træk emails hertil</strong></p>
@ -1137,11 +1151,6 @@
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small> <small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
</div> </div>
</div> </div>
<div class="p-2 border-bottom">
<button class="btn btn-sm btn-outline-secondary w-100" onclick="toggleUploadZone()">
<i class="bi bi-upload me-1"></i> Upload Emails
</button>
</div>
<div class="email-list-filters"> <div class="email-list-filters">
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')"> <button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
@ -5278,16 +5287,6 @@ function showNotification(message, type = 'info') {
setTimeout(() => toast.remove(), 3000); setTimeout(() => toast.remove(), 3000);
} }
// Email Upload Functionality
function toggleUploadZone() {
const uploadZone = document.getElementById('emailUploadZone');
if (uploadZone.style.display === 'none') {
uploadZone.style.display = 'block';
} else {
uploadZone.style.display = 'none';
}
}
// Setup drag and drop // Setup drag and drop
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('dropZone'); const dropZone = document.getElementById('dropZone');
@ -5398,7 +5397,6 @@ async function uploadEmailFiles(files) {
console.log('✅ Email list and stats reloaded'); console.log('✅ Email list and stats reloaded');
uploadProgress.style.display = 'none'; uploadProgress.style.display = 'none';
fileInput.value = ''; fileInput.value = '';
toggleUploadZone(); // Close upload zone after successful upload
}, 2000); }, 2000);
} else { } else {
console.log(' No new emails uploaded, not reloading'); console.log(' No new emails uploaded, not reloading');

View File

@ -1,6 +1,6 @@
{% extends "shared/frontend/base.html" %} {% extends "shared/frontend/base.html" %}
{% block title %}Email v2 - BMC Hub{% endblock %} {% block title %}Email - BMC Hub{% endblock %}
{% block extra_css %} {% block extra_css %}
<style> <style>
@ -47,6 +47,45 @@
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.emails-quick-upload {
margin: 0.65rem 0.9rem;
padding: 0.55rem 0.7rem;
border: 1px dashed color-mix(in srgb, var(--accent, #0f4c75) 45%, var(--border-color));
border-radius: 9px;
background: color-mix(in srgb, var(--accent, #0f4c75) 4%, var(--bg-card));
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
text-align: center;
transition: 0.15s ease;
}
.emails-quick-upload:hover,
.emails-quick-upload.dragover {
border-color: var(--accent);
color: var(--accent);
background: color-mix(in srgb, var(--accent, #0f4c75) 9%, var(--bg-card));
}
.emails-quick-upload.busy {
pointer-events: none;
opacity: 0.65;
}
.emails-primary-action {
border: 1px solid color-mix(in srgb, var(--accent, #0f4c75) 35%, var(--border-color));
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-shortcuts {
font-size: 0.72rem;
color: var(--text-secondary);
}
.emails-shortcuts kbd {
font-size: 0.68rem;
}
.emails-v2-filters { .emails-v2-filters {
padding: 0.65rem 0.9rem; padding: 0.65rem 0.9rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@ -357,19 +396,25 @@
<div class="emails-v2-shell"> <div class="emails-v2-shell">
<section class="emails-v2-panel"> <section class="emails-v2-panel">
<div class="emails-v2-header"> <div class="emails-v2-header">
<h1 class="emails-v2-title">Email v2</h1> <h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
<div class="emails-v2-version-nav"> <button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Gå til v1</a> <i class="bi bi-arrow-clockwise"></i>
<a href="/emails/v2" class="btn btn-sm btn-primary" aria-current="page">v2</a> </button>
</div>
</div> </div>
<div class="emails-v2-search"> <div class="emails-v2-search">
<input id="v2Search" class="form-control form-control-sm" placeholder="Søg afsender eller emne..."> <input id="v2Search" class="form-control form-control-sm" placeholder="Søg i mails… (tryk /)">
</div> </div>
<div id="v2Filters" class="emails-v2-filters"></div> <div id="v2Filters" class="emails-v2-filters"></div>
<div class="emails-quick-upload" id="v2UploadZone" role="button" tabindex="0">
<span id="v2UploadLabel">
<i class="bi bi-paperclip me-1"></i>Træk .eml/.msg hertil eller klik
</span>
<input id="v2UploadInput" type="file" accept=".eml,.msg" multiple hidden>
</div>
<div id="v2List" class="emails-v2-list"></div> <div id="v2List" class="emails-v2-list"></div>
<div id="v2ListStatus" class="emails-v2-status">Klar</div> <div id="v2ListStatus" class="emails-v2-status">Klar</div>
@ -378,10 +423,7 @@
<section class="emails-v2-panel"> <section class="emails-v2-panel">
<div class="emails-v2-header"> <div class="emails-v2-header">
<h2 class="emails-v2-title">Detalje</h2> <h2 class="emails-v2-title">Detalje</h2>
<div class="d-flex gap-2"> <span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
<button id="v2FetchTest" class="btn btn-sm btn-outline-success">Hent fra test-mappe</button>
<button id="v2Refresh" class="btn btn-sm btn-outline-primary">Opdater</button>
</div>
</div> </div>
<div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div> <div id="v2MailHeader" class="emails-v2-mail-header small text-muted">Vælg en email for at se info</div>
@ -394,7 +436,7 @@
<section class="emails-v2-panel"> <section class="emails-v2-panel">
<div class="emails-v2-header"> <div class="emails-v2-header">
<h2 class="emails-v2-title">Handlinger</h2> <h2 class="emails-v2-title">Handlinger</h2>
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Sammenlign v1</a> <span class="small text-muted">Vælg hvad der skal ske</span>
</div> </div>
<div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div> <div id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
@ -408,8 +450,6 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
(() => { (() => {
const TEST_MAILBOX_FOLDER = 'BMC_TEST';
const FILTERS = [ const FILTERS = [
{ key: 'active', label: 'Aktive' }, { key: 'active', label: 'Aktive' },
{ key: 'awaiting_user_action', label: 'Afventer handling' }, { key: 'awaiting_user_action', label: 'Afventer handling' },
@ -424,7 +464,7 @@
workflowPreview: null, workflowPreview: null,
filter: 'active', filter: 'active',
query: '', query: '',
folder: TEST_MAILBOX_FOLDER, folder: '',
searchTimer: null, searchTimer: null,
sagSearchTimer: null, sagSearchTimer: null,
vendorSuggestion: null, vendorSuggestion: null,
@ -667,13 +707,15 @@
state.emails = emails; state.emails = emails;
renderFilters(); renderFilters();
renderList(); renderList();
setListStatus(`${emails.length} emails vist (mappe: ${state.folder || 'alle'})`); setListStatus(`${emails.length} emails`);
if (state.selectedEmailId) { if (state.selectedEmailId) {
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId)); const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
if (stillExists) { if (stillExists) {
await selectEmail(state.selectedEmailId, { silentListRefresh: true }); await selectEmail(state.selectedEmailId, { silentListRefresh: true });
} }
} else if (emails.length) {
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
} }
} catch (error) { } catch (error) {
state.emails = []; state.emails = [];
@ -884,19 +926,6 @@
} }
} }
async function fetchFromTestFolder() {
try {
setDetailStatus('Henter nye emails fra test-mappe...');
await apiFetch(`/api/v1/emails/process?folder=${encodeURIComponent(state.folder)}&limit=50`, {
method: 'POST',
});
await loadEmails();
setDetailStatus(`Import fuldført fra ${state.folder}`);
} catch (error) {
setDetailStatus(`Kunne ikke hente fra test-mappe: ${error.message}`);
}
}
async function createCaseFromCurrent() { async function createCaseFromCurrent() {
if (!state.selectedEmailId || !state.selectedEmail) return; if (!state.selectedEmailId || !state.selectedEmail) return;
@ -1153,53 +1182,69 @@
sideActions.className = 'emails-v2-actions-pane'; sideActions.className = 'emails-v2-actions-pane';
sideActions.innerHTML = ` sideActions.innerHTML = `
<div class="emails-v2-card"> ${email.linked_case_id ? `
<h6>Hurtighandlinger</h6> <div class="emails-v2-card emails-primary-action">
<div class="emails-v2-actions"> <h6>Allerede knyttet til sag</h6>
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}</button> <a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<button id="v2Archive" class="btn btn-sm btn-outline-primary">Arkivér</button> <i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
<button id="v2Processed" class="btn btn-sm btn-outline-success">Markér behandlet</button> </a>
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button> <button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button> <i class="bi bi-check2 me-1"></i>Markér mailen behandlet
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button> </button>
<button id="v2AutoParse" class="btn btn-sm btn-primary">Auto parse tråd/sag</button> </div>` : `
<div class="emails-v2-card emails-primary-action">
<h6>Opret sag fra denne email</h6>
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
<option value="support">Support</option>
<option value="bogholderi">Bogholderi</option>
<option value="leverandor">Leverandør</option>
<option value="helhedsopgave">Projekt/helhedsopgave</option>
<option value="andet">Andet</option>
</select>
<button id="v2CreateCase" class="btn btn-primary text-nowrap">
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div> </div>
</div> </div>`}
<div class="emails-v2-card"> <div class="emails-v2-card">
<h6>Workflow match-preview</h6> <h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter preview...</div></div> <input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
</div>
<div class="emails-v2-card">
<h6>Link til eksisterende sag</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Søg sag-ID, titel eller beskrivelse...">
<div id="v2SagResults" class="emails-v2-sag-results"></div> <div id="v2SagResults" class="emails-v2-sag-results"></div>
</div> </div>
<div class="emails-v2-card"> <div class="emails-v2-card">
<h6>Opret ny sag fra email</h6> <h6>Hurtige mailhandlinger</h6>
<div class="row g-2"> <div class="emails-v2-actions mb-0">
<div class="col-12"> <button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
<input id="v2CaseTitle" class="form-control form-control-sm" value="${escapeHtml(email.subject || '')}" placeholder="Sags titel"> <i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
</div> </button>
<div class="col-12"> <button id="v2Archive" class="btn btn-sm btn-outline-secondary">
<select id="v2CaseType" class="form-select form-select-sm"> <i class="bi bi-archive me-1"></i>Arkivér
<option value="support">Support</option> </button>
<option value="bogholderi">Bogholderi</option> ${email.linked_case_id ? '' : `
<option value="leverandor">Leverandør</option> <button id="v2Processed" class="btn btn-sm btn-outline-success">
<option value="helhedsopgave">Helhedsopgave</option> <i class="bi bi-check2 me-1"></i>Behandlet
<option value="andet">Andet</option> </button>`}
</select>
</div>
</div>
<div class="emails-v2-right-actions mt-2">
<button id="v2CreateCase" class="btn btn-sm btn-primary">Opret sag</button>
</div> </div>
</div> </div>
<div class="emails-v2-card"> <details class="emails-v2-card">
<h6>Leverandør faktura</h6> <summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
<button id="v2ExecuteWorkflows" class="btn btn-sm btn-outline-dark">Kør workflows</button>
<button id="v2AutoRun" class="btn btn-sm btn-outline-danger" disabled>Autokør</button>
<button id="v2AutoParse" class="btn btn-sm btn-outline-primary">Find tråd/sag automatisk</button>
</div>
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div> <div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div> <div class="emails-v2-kv"><div class="k">CVR</div><div class="v">${escapeHtml(email.extracted_vendor_cvr || '-')}</div></div>
<div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div> <div class="emails-v2-kv"><div class="k">Faktura nr</div><div class="v">${escapeHtml(email.extracted_invoice_number || '-')}</div></div>
@ -1210,16 +1255,14 @@
</div> </div>
<div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div> <div id="v2VendorSuggestion" class="mt-2"><div class="small text-muted">Ingen forslag endnu</div></div>
<div id="v2SupplierStatus" class="emails-v2-inline-status"></div> <div id="v2SupplierStatus" class="emails-v2-inline-status"></div>
</div> <hr>
<h6>Automatisk kundematch</h6>
<div class="emails-v2-card">
<h6>Auto match til kunde/sag</h6>
<div class="emails-v2-right-actions"> <div class="emails-v2-right-actions">
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button> <button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
</div> </div>
<div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div> <div id="v2DomainSuggestion" class="mt-2"><div class="small text-muted">Ingen domæneforslag endnu</div></div>
<div id="v2DomainStatus" class="emails-v2-inline-status"></div> <div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</div> </details>
<details class="emails-v2-card"> <details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary> <summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
@ -1262,6 +1305,101 @@
}); });
} }
async function uploadEmailFiles(files) {
const accepted = Array.from(files || []).filter((file) =>
/\.(eml|msg)$/i.test(file.name || '')
);
if (!accepted.length) {
setListStatus('Vælg .eml- eller .msg-filer');
return;
}
const zone = document.getElementById('v2UploadZone');
const label = document.getElementById('v2UploadLabel');
const original = label?.innerHTML;
if (zone) {
zone.classList.add('busy');
}
if (label) {
label.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span>Importerer ${accepted.length} mail${accepted.length === 1 ? '' : 's'}…`;
}
try {
const formData = new FormData();
accepted.forEach((file) => formData.append('files', file));
const result = await apiFetch('/api/v1/emails/upload', {
method: 'POST',
body: formData,
});
const uploaded = Number(result?.uploaded || 0);
const duplicates = Number(result?.duplicates || 0);
setListStatus(`${uploaded} importeret${duplicates ? ` · ${duplicates} fandtes allerede` : ''}`);
state.filter = 'active';
state.selectedEmailId = null;
await loadEmails();
} catch (error) {
setListStatus(`Import fejlede: ${error.message}`);
} finally {
if (zone) {
zone.classList.remove('busy', 'dragover');
}
if (label) label.innerHTML = original;
}
}
function setupUploadZone() {
const zone = document.getElementById('v2UploadZone');
const input = document.getElementById('v2UploadInput');
if (!zone || !input || zone.dataset.ready === 'true') return;
zone.dataset.ready = 'true';
zone.addEventListener('click', () => input.click());
zone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
input.click();
}
});
['dragenter', 'dragover'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.add('dragover');
}));
['dragleave', 'drop'].forEach((type) => zone.addEventListener(type, (event) => {
event.preventDefault();
zone.classList.remove('dragover');
}));
zone.addEventListener('drop', (event) => uploadEmailFiles(event.dataTransfer?.files));
input.addEventListener('change', () => {
uploadEmailFiles(input.files);
input.value = '';
});
}
function selectAdjacentEmail(offset) {
if (!state.emails.length) return;
const current = state.emails.findIndex((email) => Number(email.id) === Number(state.selectedEmailId));
const next = Math.min(Math.max((current < 0 ? 0 : current) + offset, 0), state.emails.length - 1);
selectEmail(Number(state.emails[next].id));
}
function setupKeyboardShortcuts() {
document.addEventListener('keydown', (event) => {
const target = event.target;
const typing = target instanceof HTMLInputElement
|| target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement
|| target?.isContentEditable;
if (event.key === '/' && !typing) {
event.preventDefault();
document.getElementById('v2Search')?.focus();
} else if (!typing && event.key.toLowerCase() === 'j') {
selectAdjacentEmail(1);
} else if (!typing && event.key.toLowerCase() === 'k') {
selectAdjacentEmail(-1);
} else if (!typing && event.key.toLowerCase() === 'r') {
loadEmails();
}
});
}
function setupEvents() { function setupEvents() {
document.getElementById('v2Search')?.addEventListener('input', (event) => { document.getElementById('v2Search')?.addEventListener('input', (event) => {
const value = String(event.target.value || '').trim(); const value = String(event.target.value || '').trim();
@ -1273,7 +1411,8 @@
}); });
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails()); document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
document.getElementById('v2FetchTest')?.addEventListener('click', fetchFromTestFolder); setupUploadZone();
setupKeyboardShortcuts();
} }
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {

View File

@ -17,10 +17,10 @@ templates = Jinja2Templates(directory="app")
@router.get("/emails", response_class=HTMLResponse) @router.get("/emails", response_class=HTMLResponse)
async def emails_page(request: Request): async def emails_page(request: Request):
"""Email management UI - 3-column modern email interface""" """Email management UI - fast, simplified workflow."""
return templates.TemplateResponse( return templates.TemplateResponse(
"emails/frontend/emails.html", "emails/frontend/emails_v2.html",
{"request": request, "email_ui_version": "v1"} {"request": request, "email_ui_version": "v2"}
) )

View File

@ -338,6 +338,12 @@
<span class="info-label">Mærke/Model</span> <span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span> <span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
</div> </div>
{% if hardware.current_location_id %}
<div class="info-row">
<span class="info-label">Rækkefølge på lokation</span>
<span class="info-value"><span class="badge bg-primary">Nr. {{ hardware.location_display_order or '—' }}</span></span>
</div>
{% endif %}
{% if hardware.eset_uuid %} {% if hardware.eset_uuid %}
<div class="info-row"> <div class="info-row">
<span class="info-label">ESET UUID</span> <span class="info-label">ESET UUID</span>
@ -717,6 +723,7 @@
{% if uisp_device %} {% if uisp_device %}
{% set overview = uisp_device.overview or {} %} {% set overview = uisp_device.overview or {} %}
<div class="row g-3 small"> <div class="row g-3 small">
<div class="col-md-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp_device.hostname or uisp_device.display_name or uisp_device.name or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div> <div class="col-md-3"><span class="text-muted d-block">Status</span><strong>{{ uisp_device.status or 'Ukendt' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div> <div class="col-md-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp_device.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div> <div class="col-md-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp_device.mac_address or '—' }}</strong></div>

View File

@ -97,7 +97,7 @@
border-top: 1px solid rgba(0,0,0,0.1); border-top: 1px solid rgba(0,0,0,0.1);
} }
.btn { .form-container .btn {
padding: 0.75rem 2rem; padding: 0.75rem 2rem;
border-radius: 8px; border-radius: 8px;
font-weight: 500; font-weight: 500;
@ -108,23 +108,23 @@
text-decoration: none; text-decoration: none;
} }
.btn-primary { .form-container .btn-primary {
background-color: var(--accent); background-color: var(--accent);
color: white; color: white;
} }
.btn-primary:hover { .form-container .btn-primary:hover {
background-color: #0056b3; background-color: #0056b3;
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3); box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
} }
.btn-secondary { .form-container .btn-secondary {
background-color: #6c757d; background-color: #6c757d;
color: white; color: white;
} }
.btn-secondary:hover { .form-container .btn-secondary:hover {
background-color: #5a6268; background-color: #5a6268;
} }
@ -141,7 +141,7 @@
flex-direction: column-reverse; flex-direction: column-reverse;
} }
.btn { .form-container .btn {
width: 100%; width: 100%;
} }
} }

View File

@ -69,6 +69,19 @@ GENERIC_SEGMENT_TITLE_PATTERNS = (
) )
def _normalize_service_location(value: Optional[str]) -> str:
normalized = str(value or "").lower()
normalized = normalized.replace("æ", "ae").replace("ø", "oe").replace("å", "aa")
return re.sub(r"[^a-z0-9]+", "", normalized)
class InvoiceSyncReviewRequest(BaseModel):
line_number: int
action: str
connection_id: Optional[int] = None
note: Optional[str] = None
def _internet_customer_doc_dir() -> Path: def _internet_customer_doc_dir() -> Path:
base = Path(settings.UPLOAD_DIR).resolve() base = Path(settings.UPLOAD_DIR).resolve()
target = base / "internet_customer_docs" target = base / "internet_customer_docs"
@ -1247,6 +1260,231 @@ async def internet_connections_health():
return {"status": "healthy", "service": "internet-connections-module"} return {"status": "healthy", "service": "internet-connections-module"}
@router.get("/internet-connections/invoice-sync-runs")
async def list_internet_invoice_sync_runs(
status: Optional[str] = Query(None),
limit: int = Query(100, ge=1, le=500),
):
"""Show GlobalConnect invoices and their latest internet-sync outcome."""
rows = execute_query(
"""
WITH raw_invoice_keys AS (
SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id,
si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name,
si.invoice_date, si.total_amount, si.currency, si.created_at
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%'
UNION ALL
SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number,
run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at
FROM internet_connections_invoice_sync_runs run
WHERE NOT EXISTS (
SELECT 1 FROM supplier_invoices si
WHERE si.id = run.supplier_invoice_id
OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number
AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, ''))
)
),
invoice_keys AS (
SELECT DISTINCT ON (
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, '')))
) *
FROM raw_invoice_keys
ORDER BY
TRIM(COALESCE(invoice_number, '')),
LOWER(TRIM(COALESCE(vendor_name, ''))),
supplier_invoice_id NULLS LAST,
created_at DESC
)
SELECT key.*,
COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) AS processing_status,
latest.id AS run_id, latest.connections_synced, latest.connections_created,
latest.connections_updated, latest.ip_ranges_synced, latest.total_lines,
latest.actionable_lines, latest.skipped_lines,
COALESCE(latest.error_message, file.error_message) AS error_message,
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,
GREATEST(COALESCE(latest.skipped_lines, 0) - COALESCE(review.resolved_lines, 0), 0) AS unresolved_lines,
COALESCE(review.decisions, '[]'::jsonb) AS review_decisions
FROM invoice_keys key
LEFT JOIN incoming_files file ON file.file_id = key.file_id
LEFT JOIN LATERAL (
SELECT run.*
FROM internet_connections_invoice_sync_runs run
WHERE (key.supplier_invoice_id IS NOT NULL AND run.supplier_invoice_id = key.supplier_invoice_id)
OR (key.extraction_id IS NOT NULL AND run.extraction_id = key.extraction_id)
OR (run.invoice_number = key.invoice_number
AND COALESCE(run.vendor_name, '') = COALESCE(key.vendor_name, ''))
ORDER BY run.processed_at DESC, run.id DESC
LIMIT 1
) latest ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(DISTINCT history.connection_id)::integer AS connection_count
FROM internet_connections_history history
WHERE key.invoice_number IS NOT NULL
AND history.summary ILIKE ('%%' || key.invoice_number || '%%')
AND history.event_type IN (
'connection_created_from_supplier_invoice', 'supplier_invoice_sync_changed',
'supplier_invoice_ip_range_changed', 'supplier_invoice_ip_range_created'
)
) legacy ON TRUE
LEFT JOIN LATERAL (
SELECT COUNT(*)::integer AS resolved_lines,
COALESCE(
jsonb_agg(jsonb_build_object(
'line_number', decision.line_number,
'action', decision.action,
'connection_id', decision.connection_id,
'note', decision.note,
'resolved_at', decision.resolved_at
) ORDER BY decision.line_number),
'[]'::jsonb
) AS decisions
FROM internet_connections_invoice_review_decisions decision
WHERE decision.run_id = latest.id
) review ON TRUE
WHERE (%s IS NULL OR COALESCE(
latest.status,
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
) = %s)
ORDER BY COALESCE(latest.processed_at, file.processed_at, key.created_at) DESC
LIMIT %s
""",
(status, status, limit),
) or []
items = [dict(row) for row in rows]
return {
"items": items,
"summary": {
"total": len(items),
"success": sum(item.get("processing_status") in {"success", "legacy_success"} for item in items),
"warnings": sum(item.get("processing_status") in {"warning", "skipped", "not_logged"} for item in items),
"errors": sum(item.get("processing_status") == "error" for item in items),
},
}
@router.post("/internet-connections/invoice-sync-runs/{run_id}/review")
async def review_internet_invoice_sync_line(run_id: int, data: InvoiceSyncReviewRequest):
if data.action not in {"ignore", "link_existing", "create_separate"}:
raise HTTPException(status_code=400, detail="Ugyldig kontrolhandling")
if data.action == "ignore" and not str(data.note or "").strip():
raise HTTPException(status_code=400, detail="Angiv en årsag, når en linje ignoreres")
run = execute_query_single(
"SELECT * FROM internet_connections_invoice_sync_runs WHERE id = %s",
(run_id,),
)
if not run:
raise HTTPException(status_code=404, detail="Behandlingskørslen blev ikke fundet")
result = run.get("result_json") or {}
skipped_items = result.get("skipped_items") or []
audit_item = next(
(item for item in skipped_items if int(item.get("line_number") or -1) == data.line_number),
None,
)
if not audit_item:
raise HTTPException(status_code=404, detail="Kontrollinjen blev ikke fundet")
target_connection_id = data.connection_id
if data.action in {"link_existing", "create_separate"}:
if data.action == "link_existing":
if not target_connection_id:
raise HTTPException(status_code=400, detail="Vælg en eksisterende forbindelse")
target = execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id = %s AND deleted_at IS NULL",
(target_connection_id,),
)
if not target:
raise HTTPException(status_code=404, detail="Forbindelsen blev ikke fundet")
else:
reference = str(audit_item.get("provider_reference") or "").strip()
service_address = str(audit_item.get("service_address") or "").strip()
target_connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, address, status, circuit_number, notes,
allocation_model, value_type
)
VALUES (%s, 'GlobalConnect A/S', %s, 'pending', %s, %s, 'dedicated', 'other')
RETURNING id
""",
(
f"Afventer kontrol · {reference or 'ukendt reference'}",
service_address or None,
reference or None,
f"Oprettet manuelt under kontrol af faktura {run.get('invoice_number')}.",
),
)
if audit_item.get("classification") == "ip_range":
extraction_line = execute_query_single(
"""
SELECT *
FROM extraction_lines
WHERE extraction_id = %s AND line_number = %s
ORDER BY line_id DESC
LIMIT 1
""",
(run.get("extraction_id"), data.line_number),
)
if not extraction_line:
raise HTTPException(status_code=404, detail="Den oprindelige fakturalinje blev ikke fundet")
from app.billing.backend.supplier_invoices import _upsert_globalconnect_ip_range
range_id = _upsert_globalconnect_ip_range(
int(target_connection_id),
dict(extraction_line),
str(run.get("invoice_number") or "ukendt"),
)
if not range_id:
raise HTTPException(status_code=400, detail="IP-rangen kunne ikke forbindes")
execute_query(
"""
INSERT INTO internet_connections_invoice_review_decisions (
run_id, line_number, action, connection_id, note
)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (run_id, line_number) DO UPDATE
SET action = EXCLUDED.action,
connection_id = EXCLUDED.connection_id,
note = EXCLUDED.note,
resolved_at = CURRENT_TIMESTAMP
""",
(run_id, data.line_number, data.action, target_connection_id, (data.note or "").strip() or None),
fetch=False,
)
resolved = execute_query_single(
"SELECT COUNT(*)::integer AS count FROM internet_connections_invoice_review_decisions WHERE run_id = %s",
(run_id,),
) or {"count": 0}
skipped_count = len(skipped_items)
remaining = max(skipped_count - int(resolved.get("count") or 0), 0)
if remaining == 0:
execute_query(
"UPDATE internet_connections_invoice_sync_runs SET status = 'success' WHERE id = %s",
(run_id,),
fetch=False,
)
return {
"status": "resolved",
"run_id": run_id,
"line_number": data.line_number,
"action": data.action,
"connection_id": target_connection_id,
"remaining": remaining,
}
@router.get("/internet-connections", response_model=List[dict]) @router.get("/internet-connections", response_model=List[dict])
async def list_connections( async def list_connections(
q: Optional[str] = Query(None), q: Optional[str] = Query(None),
@ -1486,6 +1724,99 @@ async def get_connection(connection_id: int):
return connection return connection
@router.get("/internet-connections/{connection_id:int}/cross-field-ports")
async def get_connection_cross_field_ports(connection_id: int):
"""Find cross-field ports related by customer or service-location address."""
connection = execute_query_single(
"""
SELECT ic.id, ic.customer_id, ic.address, parent.address AS parent_address
FROM internet_connections_connections ic
LEFT JOIN internet_connections_connections parent ON parent.id = ic.parent_id
WHERE ic.id = %s AND ic.deleted_at IS NULL
""",
(connection_id,),
)
if not connection:
raise HTTPException(status_code=404, detail="Connection not found")
rows = execute_query(
"""
SELECT o.id AS outlet_id, o.outlet_number, o.category, o.patch_panel,
o.patch_port, o.switch_name, o.switch_port, o.status,
o.is_active, o.is_wan, o.customer_id,
p.id AS cross_field_port_id, p.port_number, p.port_order,
cf.id AS cross_field_id, cf.name AS cross_field_name,
l.id AS location_id, l.name AS location_name, l.customer_id AS location_customer_id,
l.address_street, l.address_postal_code, l.address_city,
h.id AS switch_hardware_id, h.model AS switch_hardware_model
FROM locations_wall_outlets o
JOIN locations_locations l ON l.id = o.location_id AND l.deleted_at IS NULL
LEFT JOIN locations_cross_field_ports p ON p.id = o.cross_field_port_id
LEFT JOIN locations_cross_fields cf ON cf.id = p.cross_field_id AND cf.deleted_at IS NULL
LEFT JOIN hardware h ON h.id = o.switch_hardware_id AND h.deleted_at IS NULL
WHERE o.deleted_at IS NULL
AND o.is_active = TRUE
AND o.cross_field_port_id IS NOT NULL
ORDER BY l.name, cf.display_order, p.port_order, o.id
"""
) or []
customer_id = int(connection.get("customer_id") or 0)
target_addresses = {
_normalize_service_location(value)
for value in (connection.get("address"), connection.get("parent_address"))
if value
}
target_addresses.discard("")
ports = []
for row in rows:
item = dict(row)
def clean_location_part(value: object) -> str:
text = str(value or "").strip()
return "" if text.lower() in {"none", "null", "-"} else text
location_address = ", ".join(filter(None, [
clean_location_part(item.get("address_street")),
" ".join(filter(None, [
clean_location_part(item.get("address_postal_code")),
clean_location_part(item.get("address_city")),
])).strip(),
]))
normalized_location = _normalize_service_location(location_address)
customer_match = bool(
customer_id
and customer_id in {
int(item.get("customer_id") or 0),
int(item.get("location_customer_id") or 0),
}
)
address_match = bool(
normalized_location
and any(
target == normalized_location
or (len(normalized_location) >= 5 and target.startswith(normalized_location))
for target in target_addresses
)
)
if not customer_match and not address_match:
continue
item["network_role"] = "wan" if item.get("is_wan") else "lan"
item["is_faulty"] = str(item.get("status") or "").lower() == "faulty"
item["location_address"] = location_address or None
ports.append(item)
return {
"items": ports,
"summary": {
"total": len(ports),
"lan": sum(item["network_role"] == "lan" for item in ports),
"wan": sum(item["network_role"] == "wan" for item in ports),
"faulty": sum(bool(item["is_faulty"]) for item in ports),
},
}
@router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict]) @router.get("/internet-connections/{connection_id:int}/children", response_model=List[dict])
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)): async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
where_parts = [" AND ic.parent_id = %s "] where_parts = [" AND ic.parent_id = %s "]

View File

@ -556,6 +556,30 @@
<div id="pricingHistoryList"></div> <div id="pricingHistoryList"></div>
</div> </div>
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-0">Porte i krydsfelt</h5>
<div class="small text-muted" id="crossFieldPortsSummary"></div>
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Lokation / krydsfelt</th>
<th>Port</th>
<th>Vægstik</th>
<th>Switch</th>
<th>Netværk</th>
<th>Status</th>
</tr>
</thead>
<tbody id="crossFieldPortsBody"></tbody>
</table>
</div>
</div>
<div class="detail-panel p-4"> <div class="detail-panel p-4">
<h5 class="mb-3">Historik</h5> <h5 class="mb-3">Historik</h5>
<div id="historyList"></div> <div id="historyList"></div>
@ -1296,6 +1320,7 @@
pricingHistoryResponse, pricingHistoryResponse,
contractsResponse, contractsResponse,
historyResponse, historyResponse,
crossFieldPortsResponse,
] = await Promise.all([ ] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`), fetch(`/api/v1/internet-connections/${connectionId}`),
fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`), fetch(`/api/v1/internet-connections/${connectionId}/ip-ranges`),
@ -1305,6 +1330,7 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`), fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'), fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`), fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]); ]);
if (!connectionResponse.ok) { if (!connectionResponse.ok) {
@ -1321,6 +1347,7 @@
const pricingHistory = await pricingHistoryResponse.json(); const pricingHistory = await pricingHistoryResponse.json();
const contracts = await contractsResponse.json(); const contracts = await contractsResponse.json();
const history = await historyResponse.json(); const history = await historyResponse.json();
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
currentConnection = connection; currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : []; currentAddresses = Array.isArray(addresses) ? addresses : [];
@ -1337,6 +1364,44 @@
renderRelationGrid(connection); renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren); renderBmcnetChildren(connection, currentBmcnetChildren);
renderContractsOverview(contracts); renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
}
function renderCrossFieldPorts(payload) {
const panel = document.getElementById('crossFieldPortsPanel');
const body = document.getElementById('crossFieldPortsBody');
const summaryEl = document.getElementById('crossFieldPortsSummary');
const items = Array.isArray(payload?.items) ? payload.items : [];
const summary = payload?.summary || {};
panel.classList.toggle('d-none', items.length === 0);
if (!items.length) {
body.innerHTML = '';
return;
}
summaryEl.textContent = `${summary.total || items.length} porte · ${summary.lan || 0} LAN · ${summary.wan || 0} WAN · ${summary.faulty || 0} defekte`;
body.innerHTML = items.map((port) => {
const status = String(port.status || '').toLowerCase();
const statusLabel = port.is_faulty ? 'Defekt' : (status === 'reserved' ? 'Reserveret' : 'Aktiv');
const statusClass = port.is_faulty ? 'bg-danger' : (status === 'reserved' ? 'bg-warning text-dark' : 'bg-success');
const roleClass = port.network_role === 'wan' ? 'bg-primary' : 'bg-info text-dark';
const switchLabel = port.switch_name || port.switch_hardware_model || '-';
return `
<tr class="${port.is_faulty ? 'table-danger' : ''}">
<td>
<a href="/app/locations/${port.location_id}?tab=cross-field" class="fw-semibold text-decoration-none">
${escapeHtml(port.location_name || '-')}
</a>
<div class="small text-muted">${escapeHtml(port.cross_field_name || '-')}</div>
</td>
<td><strong>${escapeHtml(port.port_number || port.patch_port || '-')}</strong></td>
<td>${escapeHtml(port.outlet_number || '-')}</td>
<td>${escapeHtml(switchLabel)}${port.switch_port ? ` · port ${escapeHtml(port.switch_port)}` : ''}</td>
<td><span class="badge ${roleClass}">${port.network_role === 'wan' ? 'WAN' : 'LAN'}</span></td>
<td><span class="badge ${statusClass}">${statusLabel}</span></td>
</tr>
`;
}).join('');
} }
function renderCore(connection, pricing, summary) { function renderCore(connection, pricing, summary) {

View File

@ -137,6 +137,24 @@
padding: 1rem; padding: 1rem;
} }
.invoice-sync-status {
display:inline-flex;
align-items:center;
border-radius:999px;
padding:.3rem .65rem;
font-size:.72rem;
font-weight:800;
white-space:nowrap;
}
.invoice-sync-status.success { background:rgba(25,135,84,.14); color:#146c43; }
.invoice-sync-status.warning,
.invoice-sync-status.skipped,
.invoice-sync-status.not_logged { background:rgba(255,193,7,.2); color:#765a02; }
.invoice-sync-status.error { background:rgba(220,53,69,.14); color:#b02a37; }
.invoice-sync-status.legacy_success { background:rgba(13,110,253,.12); color:#084298; }
.invoice-sync-error { max-width:420px; white-space:normal; color:#b02a37; font-size:.8rem; }
@media (max-width: 991px) { @media (max-width: 991px) {
.internet-toolbar { .internet-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@ -155,6 +173,9 @@
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div> <div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
</a>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()"> <button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater <i class="bi bi-arrow-repeat me-1"></i>Opdater
</button> </button>
@ -327,10 +348,58 @@
</table> </table>
</div> </div>
</div> </div>
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div>
<div class="d-flex gap-2">
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option>
<option value="success">Gennemført</option>
<option value="warning">Kræver kontrol</option>
<option value="error">Fejl</option>
<option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option>
</select>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fakturaer</div><div class="internet-kpi-value" id="invoiceMetricTotal">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Gennemført</div><div class="internet-kpi-value text-success" id="invoiceMetricSuccess">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Kræver kontrol</div><div class="internet-kpi-value text-warning" id="invoiceMetricWarnings">0</div></div></div>
<div class="col-6 col-xl-3"><div class="internet-kpi"><div class="internet-kpi-label">Fejl</div><div class="internet-kpi-value text-danger" id="invoiceMetricErrors">0</div></div></div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light"><tr><th>Faktura</th><th>Behandlet</th><th>Resultat</th><th>Forbindelser</th><th>IP-ranges</th><th>Fejl / bemærkning</th></tr></thead>
<tbody id="invoiceSyncTableBody"><tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div><h5 class="modal-title mb-1">Kontrollér internetfaktura <span id="invoiceReviewNumber"></span></h5><div class="internet-mini" id="invoiceReviewSummary"></div></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
<div id="invoiceReviewLines"></div>
</div>
</div>
</div>
</div> </div>
<script> <script>
let allConnections = []; let allConnections = [];
let invoiceSyncItems = [];
let activeTab = 'all'; let activeTab = 'all';
let subscriptionOptions = []; let subscriptionOptions = [];
@ -405,6 +474,165 @@
return fallback; return fallback;
} }
function escapeInvoiceText(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function invoiceSyncBadge(status) {
const labels = {
success: 'Gennemført',
legacy_success: 'Ældre · gennemført',
warning: 'Kræver kontrol',
skipped: 'Sprunget over',
error: 'Fejl',
not_logged: 'Ingen detaljer',
};
return `<span class="invoice-sync-status ${escapeInvoiceText(status)}">${labels[status] || escapeInvoiceText(status || 'Ukendt')}</span>`;
}
function formatInvoiceSyncDate(value) {
if (!value) return '-';
const date = new Date(value);
return Number.isNaN(date.getTime()) ? escapeInvoiceText(value) : date.toLocaleString('da-DK');
}
function invoiceSyncMessage(item) {
if (item.error_message) return `<div class="invoice-sync-error">${escapeInvoiceText(item.error_message)}</div>`;
if (item.processing_status === 'warning') {
const skipped = Number(item.skipped_lines || 0);
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const reasons = [...new Set(skippedItems.map(entry => entry.reason).filter(Boolean))];
return `<div class="text-warning small mb-2">${Number(item.unresolved_lines ?? skipped)} af ${skipped} linje(r) mangler kontrol${reasons.length ? `: ${escapeInvoiceText(reasons.join('; '))}` : ''}</div>${item.run_id ? `<button class="btn btn-sm btn-warning" type="button" onclick="openInvoiceReview(${Number(item.run_id)})"><i class="bi bi-check2-square me-1"></i>Kontrollér</button>` : ''}`;
}
if (item.processing_status === 'skipped') return '<div class="text-warning small">Fakturaen gav ingen internetdata til synkronisering.</div>';
if (item.processing_status === 'not_logged') return '<div class="text-muted small">Ældre faktura uden gemt behandlingsresultat.</div>';
if (item.processing_status === 'legacy_success') return '<div class="text-muted small">Fundet via eksisterende forbindelseshistorik.</div>';
return '<div class="text-muted small">Ingen fejl registreret.</div>';
}
async function loadInvoiceSyncRuns() {
const status = document.getElementById('invoiceSyncStatusFilter').value;
const params = new URLSearchParams({limit: '200'});
if (status) params.set('status', status);
const body = document.getElementById('invoiceSyncTableBody');
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Indlæser fakturabehandling…</td></tr>';
try {
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs?${params.toString()}`);
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Kunne ikke hente fakturabehandling.'));
const payload = await response.json();
const items = Array.isArray(payload.items) ? payload.items : [];
invoiceSyncItems = items;
const summary = payload.summary || {};
document.getElementById('invoiceMetricTotal').textContent = String(summary.total || 0);
document.getElementById('invoiceMetricSuccess').textContent = String(summary.success || 0);
document.getElementById('invoiceMetricWarnings').textContent = String(summary.warnings || 0);
document.getElementById('invoiceMetricErrors').textContent = String(summary.errors || 0);
if (!items.length) {
body.innerHTML = '<tr><td colspan="6" class="text-muted py-4">Ingen fakturaer matcher filteret.</td></tr>';
return;
}
body.innerHTML = items.map(item => `
<tr>
<td><div class="fw-semibold">${escapeInvoiceText(item.invoice_number || '-')}</div><div class="internet-mini">${escapeInvoiceText(item.vendor_name || 'GlobalConnect')} · ${escapeInvoiceText(item.invoice_date || '-')}</div></td>
<td>${formatInvoiceSyncDate(item.processed_at)}</td>
<td>${invoiceSyncBadge(item.processing_status)}</td>
<td><strong>${Number(item.connections_synced || item.legacy_connection_count || 0)}</strong><div class="internet-mini">${Number(item.connections_created || 0)} nye · ${Number(item.connections_updated || 0)} opdateret</div></td>
<td><strong>${Number(item.ip_ranges_synced || 0)}</strong></td>
<td>${invoiceSyncMessage(item)}</td>
</tr>
`).join('');
} catch (error) {
body.innerHTML = `<tr><td colspan="6" class="text-danger py-4">${escapeInvoiceText(error.message || 'Kunne ikke indlæse fakturabehandling.')}</td></tr>`;
}
}
function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
.map(item => `<option value="${Number(item.id)}" ${String(item.id) === String(selectedId) ? 'selected' : ''}>${escapeInvoiceText(item.circuit_number || item.name || `#${item.id}`)} · ${escapeInvoiceText(item.address || '-')}</option>`)
.join('');
}
function openInvoiceReview(runId) {
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
return result;
}, {});
document.getElementById('invoiceReviewLines').innerHTML = Object.entries(groups).map(([reason, lines]) => `
<section class="mb-4">
<h6 class="mb-2">${escapeInvoiceText(reason)} <span class="badge bg-secondary">${lines.length}</span></h6>
<div class="vstack gap-2">
${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number));
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
<div class="internet-mini">Reference: ${escapeInvoiceText(line.provider_reference || '-')} · Adresse: ${escapeInvoiceText(line.service_address || '-')}</div>
${line.ip_address ? `<div class="internet-mini">IP-range: ${escapeInvoiceText(line.ip_address)}</div>` : ''}
${decision ? `<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>Løst: ${escapeInvoiceText(decision.action)}${decision.note ? ` · ${escapeInvoiceText(decision.note)}` : ''}</div>` : ''}
</div>
${decision ? '' : `<div style="min-width:340px;">
<select class="form-select form-select-sm mb-2" id="reviewConnection-${runId}-${Number(line.line_number)}">${reviewConnectionOptions()}</select>
<input class="form-control form-control-sm mb-2" id="reviewNote-${runId}-${Number(line.line_number)}" placeholder="Begrundelse / note">
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'link_existing')">Brug valgt</button>
<button class="btn btn-sm btn-outline-primary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'create_separate')">Opret separat</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="submitInvoiceReview(${runId}, ${Number(line.line_number)}, 'ignore')">Ignorer</button>
</div>
</div>`}
</div>
</div>`;
}).join('')}
</div>
</section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
}
async function submitInvoiceReview(runId, lineNumber, action) {
const connectionId = document.getElementById(`reviewConnection-${runId}-${lineNumber}`)?.value || null;
const note = document.getElementById(`reviewNote-${runId}-${lineNumber}`)?.value.trim() || '';
if (action === 'link_existing' && !connectionId) {
alert('Vælg først en eksisterende forbindelse.');
return;
}
if (action === 'ignore' && !note) {
alert('Skriv en begrundelse for at ignorere linjen.');
return;
}
if (action === 'create_separate' && !confirm('Opret en separat, afventende forbindelse for denne linje?')) return;
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}/review`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
line_number: lineNumber,
action,
connection_id: connectionId ? Number(connectionId) : null,
note: note || null,
}),
});
if (!response.ok) {
alert(await extractErrorMessage(response, 'Kontrollen kunne ikke gemmes.'));
return;
}
await loadInvoiceSyncRuns();
openInvoiceReview(runId);
}
async function loadInternetPage() { async function loadInternetPage() {
const search = document.getElementById('searchInput').value.trim(); const search = document.getElementById('searchInput').value.trim();
const provider = document.getElementById('providerFilter').value.trim(); const provider = document.getElementById('providerFilter').value.trim();
@ -626,7 +854,7 @@
}); });
toggleCreateValueFields(); toggleCreateValueFields();
await loadSubscriptionOptions(); await loadSubscriptionOptions();
await loadInternetPage(); await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
}); });
</script> </script>
<datalist id="subscriptionLookupList"></datalist> <datalist id="subscriptionLookupList"></datalist>

View File

@ -866,9 +866,9 @@ async def create_wall_outlet(data: WallOutletCreate):
try: try:
rows = execute_query( rows = execute_query(
"""INSERT INTO locations_wall_outlets """INSERT INTO locations_wall_outlets
(location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, status, notes, is_active) (location_id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id, switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""",
(data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.status, data.notes, data.is_active), (data.location_id, (data.outlet_number or '').strip() or None, data.customer_id, data.category, data.patch_panel, data.patch_port, data.cross_field_port_id, data.switch_hardware_id, data.switch_name, data.switch_port, data.is_wan, data.status, data.notes, data.is_active),
) or [] ) or []
except Exception as exc: except Exception as exc:
if 'unique' in str(exc).lower(): if 'unique' in str(exc).lower():

View File

@ -659,7 +659,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
wall_outlets = execute_query( wall_outlets = execute_query(
""" """
SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, switch_hardware_id, switch_name, switch_port, status, notes, is_active SELECT id, outlet_number, customer_id, category, patch_panel, patch_port, cross_field_port_id,
switch_hardware_id, switch_name, switch_port, is_wan, status, notes, is_active
FROM locations_wall_outlets FROM locations_wall_outlets
WHERE location_id = %s AND deleted_at IS NULL WHERE location_id = %s AND deleted_at IS NULL
ORDER BY outlet_number ORDER BY outlet_number
@ -780,7 +781,9 @@ def detail_location_view(id: int = Path(..., gt=0)):
for cross_field in cross_fields or []: for cross_field in cross_fields or []:
cross_field["ports"] = execute_query( cross_field["ports"] = execute_query(
"""SELECT p.id, p.port_number, p.port_order, p.is_active, """SELECT p.id, p.port_number, p.port_order, p.is_active,
o.id AS outlet_id, o.outlet_number, o.status AS outlet_status, o.id AS outlet_id, o.outlet_number, o.customer_id, o.category,
o.patch_panel, o.patch_port, o.switch_hardware_id, o.switch_name, o.switch_port,
o.status AS outlet_status, o.notes AS outlet_notes, o.is_wan,
l.name AS outlet_location_name l.name AS outlet_location_name
FROM locations_cross_field_ports p FROM locations_cross_field_ports p
LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL LEFT JOIN locations_wall_outlets o ON o.cross_field_port_id = p.id AND o.deleted_at IS NULL
@ -788,6 +791,17 @@ def detail_location_view(id: int = Path(..., gt=0)):
WHERE p.cross_field_id = %s ORDER BY p.port_order""", WHERE p.cross_field_id = %s ORDER BY p.port_order""",
(cross_field["id"],), (cross_field["id"],),
) or [] ) or []
for port in cross_field["ports"]:
linked_uisp = uisp_by_hardware_id.get(port.get("switch_hardware_id")) or {}
port["switch_live"] = (linked_uisp.get("live_ports") or {}).get(str(port.get("switch_port")))
if port.get("is_wan"):
port["smart_state"] = "wan"
elif port.get("outlet_id") and port.get("switch_live") and not port["switch_live"].get("plugged"):
port["smart_state"] = "issue"
elif port.get("outlet_id"):
port["smart_state"] = "assigned"
else:
port["smart_state"] = "free"
audit_log = execute_query( audit_log = execute_query(
""" """

View File

@ -121,6 +121,7 @@ class WallOutletBase(BaseModel):
switch_hardware_id: Optional[int] = Field(None, ge=1) switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255) switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100) switch_port: Optional[str] = Field(None, max_length=100)
is_wan: bool = False
status: str = Field('unknown') status: str = Field('unknown')
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True is_active: bool = True
@ -147,6 +148,7 @@ class WallOutletUpdate(BaseModel):
switch_hardware_id: Optional[int] = Field(None, ge=1) switch_hardware_id: Optional[int] = Field(None, ge=1)
switch_name: Optional[str] = Field(None, max_length=255) switch_name: Optional[str] = Field(None, max_length=255)
switch_port: Optional[str] = Field(None, max_length=100) switch_port: Optional[str] = Field(None, max_length=100)
is_wan: Optional[bool] = None
status: Optional[str] = None status: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None

View File

@ -7,9 +7,16 @@
.patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); } .patch-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .9rem; box-shadow: inset 0 1px 3px rgba(255,255,255,.12); }
.patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; } .patch-panel-grid { display: grid; grid-template-columns: repeat(24, minmax(34px, 1fr)); gap: .35rem; }
.patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } .patch-port { min-height: 45px; border-radius: .35rem; background: #f4f6f8; border: 2px solid #aeb7c1; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
button.patch-port:not(.assigned):hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; } button.patch-port:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); cursor:pointer; }
.patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; } .patch-port.assigned { background: #198754; border-color: #146c43; color:#fff; }
.patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; } .patch-port.hardware-linked { background: #6f42c1; border-color: #59359f; color:#fff; }
.patch-port.wan { background: #0dcaf0; border-color: #087990; color:#052c34; box-shadow: inset 0 0 0 2px rgba(255,255,255,.45); }
.patch-port.smart-issue { background:#ffc107; border-color:#b58105; color:#332701; animation:smart-port-pulse 1.8s ease-in-out infinite; }
.patch-port.unconfigured { background:#52677d; border-color:#34495e; color:#fff; }
.patch-port.border-success { border-width:4px !important; border-color:#20c997 !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
.patch-port.border-danger { border-width:4px !important; border-color:#ff5c6c !important; box-shadow:0 0 0 1px rgba(255,255,255,.55); }
.smart-port-hidden { display:none !important; }
@keyframes smart-port-pulse { 50% { box-shadow:0 0 0 3px rgba(255,193,7,.3); } }
.patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; } .patch-port.reserved { background: #ffc107; border-color: #d39e00; color:#332701; }
.patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; } .patch-port.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; } .patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
@ -810,7 +817,7 @@
{% elif location.wall_outlets %} {% elif location.wall_outlets %}
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody> <div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Stik</th><th>Status</th><th>Patchpanel</th><th>Switch</th><th></th></tr></thead><tbody>
{% for outlet in location.wall_outlets %} {% for outlet in location.wall_outlets %}
<tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr> <tr><td><strong>{{ outlet.outlet_number or 'Ikke navngivet' }}</strong>{% if outlet.is_wan %} <span class="badge bg-info text-dark">WAN</span>{% endif %}{% if outlet.category %}<div class="small text-muted">{{ outlet.category }}</div>{% endif %}</td><td><span class="badge bg-secondary">{{ outlet.status }}</span></td><td>{{ outlet.patch_panel or '—' }}{% if outlet.patch_port %} / {{ outlet.patch_port }}{% endif %}</td><td>{{ outlet.switch_name or '—' }}{% if outlet.switch_port %} / {{ outlet.switch_port }}{% endif %}</td><td class="text-end"><button type="button" class="btn btn-outline-primary btn-sm edit-outlet-btn" data-id="{{ outlet.id }}" data-number="{{ outlet.outlet_number or '' }}" data-customer-id="{{ outlet.customer_id or '' }}" data-category="{{ outlet.category or '' }}" data-panel="{{ outlet.patch_panel or '' }}" data-patch-port="{{ outlet.patch_port or '' }}" data-switch="{{ outlet.switch_name or '' }}" data-switch-port="{{ outlet.switch_port or '' }}" data-is-wan="{{ 'true' if outlet.is_wan else 'false' }}" data-status="{{ outlet.status }}" data-notes="{{ outlet.notes or '' }}"><i class="bi bi-pencil"></i></button></td></tr>
{% endfor %} {% endfor %}
</tbody></table></div> </tbody></table></div>
{% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %} {% else %}<span class="text-muted">Ingen vægstik registreret endnu.</span>{% endif %}
@ -865,9 +872,22 @@
<div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div> <div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm" id="addCrossFieldHardwareBtn"><i class="bi bi-hdd-network me-1"></i>Tilføj switch</button><button type="button" class="btn btn-primary btn-sm" id="addCrossFieldBtn"><i class="bi bi-plus-lg me-1"></i>Tilføj krydsfelt</button></div>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-4 p-3 rounded border bg-light">
<div class="d-flex flex-wrap gap-2 align-items-center">
<strong class="small">Vis porte:</strong>
<div class="btn-group btn-group-sm" role="group" aria-label="Filtrer porte">
<button type="button" class="btn btn-primary smart-port-filter active" data-filter="all">Alle</button>
<button type="button" class="btn btn-outline-warning smart-port-filter" data-filter="issue">Kun fejl <span class="badge text-bg-warning ms-1" id="smartIssueCount">0</span></button>
<button type="button" class="btn btn-outline-info smart-port-filter" data-filter="wan">WAN</button>
<button type="button" class="btn btn-outline-secondary smart-port-filter" data-filter="free">Ledige</button>
</div>
<span class="small text-muted">Grøn = aktiv · Gul = fejl · Turkis = WAN · Lilla = hardware · Blågrå = ikke konfigureret</span>
</div>
<button type="button" class="btn btn-sm btn-primary" id="openBulkPatchBtn"><i class="bi bi-diagram-3 me-1"></i>Massepatch</button>
</div>
{% for field in location.cross_fields %} {% for field in location.cross_fields %}
<div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div> <div class="mb-4"><div class="d-flex justify-content-between align-items-center mb-2"><div><strong>{{ field.name }}</strong> <span class="text-muted">Panel {{ field.display_order }} · {{ field.port_count }} porte{% if field.port_label_format == 'paired' %} · A/B-par{% endif %}</span></div><div class="d-flex gap-2"><button type="button" class="btn btn-outline-primary btn-sm edit-port-labels-btn" data-id="{{ field.id }}" data-name="{{ field.name }}"><i class="bi bi-list-ol"></i> Portnumre</button><button type="button" class="btn btn-outline-secondary btn-sm edit-cross-field-btn" data-id="{{ field.id }}" data-name="{{ field.name }}" data-port-count="{{ field.port_count }}" data-label-format="{{ field.port_label_format }}" data-start-number="{{ field.start_port_number }}" data-row-size="{{ field.panel_row_size }}" data-display-order="{{ field.display_order }}" data-notes="{{ field.notes or '' }}"><i class="bi bi-pencil"></i> Rediger</button></div></div>
<div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else '') %}<button type="button" class="patch-port {{ port_class }}" {% if not port.outlet_id %}data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}"{% else %}disabled{% endif %} title="{% if port.outlet_id %}{{ port.outlet_location_name }} · {{ port.outlet_number }} ({{ port.outlet_status }}){% else %}Ledig port — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% endif %}</button>{% endfor %}</div></div></div> <div class="patch-panel"><div class="patch-panel-grid" style="grid-template-columns: repeat({{ field.panel_row_size or 24 }}, minmax(34px, 1fr));">{% for port in field.ports %}{% set port_class = 'wan' if port.is_wan else ('smart-issue' if port.smart_state == 'issue' else ('assigned' if port.outlet_id and port.outlet_status == 'active' else (port.outlet_status if port.outlet_id else 'unconfigured'))) %}<button type="button" class="patch-port smart-port {{ port_class }}" data-smart-state="{{ port.smart_state }}" data-cross-field-port-id="{{ port.id }}" data-cross-field-name="{{ field.name }}" data-port-number="{{ port.port_number }}" data-outlet-id="{{ port.outlet_id or '' }}" data-outlet-number="{{ port.outlet_number or '' }}" data-outlet-customer-id="{{ port.customer_id or '' }}" data-outlet-category="{{ port.category or '' }}" data-outlet-panel="{{ port.patch_panel or '' }}" data-outlet-patch-port="{{ port.patch_port or '' }}" data-outlet-switch="{{ port.switch_name or '' }}" data-outlet-switch-port="{{ port.switch_port or '' }}" data-outlet-is-wan="{{ 'true' if port.is_wan else 'false' }}" data-outlet-status="{{ port.outlet_status or '' }}" data-outlet-notes="{{ port.outlet_notes or '' }}" title="{% if port.outlet_id %}{{ port.outlet_location_name }} → {{ port.outlet_number or 'vægstik' }} → {{ field.name }} port {{ port.port_number }}{% if port.switch_name %} → {{ port.switch_name }} port {{ port.switch_port }}{% endif %}{% if port.smart_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %}{% else %}Ikke konfigureret — klik for opsætning{% endif %}"><span>{{ port.port_number }}</span>{% if port.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet_number %} · {{ port.outlet_number }}{% endif %}</span>{% elif port.smart_state == 'issue' %}<span class="patch-port-outlet">⚠ INTET LINK</span>{% elif port.outlet_id %}<span class="patch-port-outlet">{{ port.outlet_number }}</span>{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}</button>{% endfor %}</div></div></div>
{% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %} {% else %}<span class="text-muted">Ingen krydsfelter oprettet endnu.</span>{% endfor %}
</div> </div>
</div> </div>
@ -887,7 +907,11 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-center gap-3"> <div class="d-flex justify-content-between align-items-center gap-3">
<div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div> <div><div class="fw-600"><a href="/hardware/{{ hw.id }}" class="text-decoration-none">{{ hw.brand }} {{ hw.model }}</a></div><div class="text-muted small">{{ hw.asset_type }}{% if hw.serial_number %} · {{ hw.serial_number }}{% endif %}</div></div>
<div class="d-flex align-items-center gap-2"><div class="input-group input-group-sm" style="width: 175px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div><span class="badge bg-secondary">{{ hw.status }}</span></div> <div class="d-flex align-items-center gap-2">
<span class="badge bg-primary text-nowrap">Nr. {{ hw.location_display_order or loop.index }}</span>
<div class="input-group input-group-sm" style="width: 225px;"><span class="input-group-text">Rækkefølge</span><input type="number" min="1" class="form-control hardware-display-order" data-hardware-id="{{ hw.id }}" value="{{ hw.location_display_order or loop.index }}" aria-label="Rækkefølge for {{ hw.brand }} {{ hw.model }}"><button type="button" class="btn btn-outline-primary save-hardware-order-btn" data-hardware-id="{{ hw.id }}">Gem</button></div>
<span class="badge bg-secondary">{{ hw.status }}</span>
</div>
</div> </div>
{% if hw.uisp_device %} {% if hw.uisp_device %}
{% set uisp = hw.uisp_device %} {% set uisp = hw.uisp_device %}
@ -895,6 +919,7 @@
<div class="mt-3 p-3 rounded border bg-light"> <div class="mt-3 p-3 rounded border bg-light">
<div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div> <div class="d-flex justify-content-between align-items-center mb-2"><div class="small fw-semibold text-primary"><i class="bi bi-broadcast-pin me-1"></i>UISP live-data</div>{% if uisp.device_link %}<a href="{{ uisp.device_link }}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn i UISP</a>{% endif %}</div>
<div class="row g-2 small"> <div class="row g-2 small">
<div class="col-sm-3"><span class="text-muted d-block">Hostnavn</span><strong>{{ uisp.hostname or uisp.display_name or uisp.name or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div> <div class="col-sm-3"><span class="text-muted d-block">Status</span><strong>{{ uisp.status or 'Ukendt' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div> <div class="col-sm-3"><span class="text-muted d-block">IP-adresser</span><strong>{{ (uisp.ip_addresses or []) | join(', ') or '—' }}</strong></div>
<div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div> <div class="col-sm-3"><span class="text-muted d-block">MAC</span><strong>{{ uisp.mac_address or '—' }}</strong></div>
@ -909,7 +934,21 @@
</div> </div>
{% endif %} {% endif %}
{% if hw.switch_ports %} {% if hw.switch_ports %}
<details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}<a href="/hardware/{{ hw.id }}" class="patch-port text-decoration-none {% if port.hardware_link %}hardware-linked{% elif port.outlet %}assigned{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" title="{% if port.live %}Live: {{ port.live.status or ('forbundet' if port.live.plugged else 'ikke forbundet') }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}. {% endif %}{% if port.hardware_link %}Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %}{% elif port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% else %}Ledig port — åbn switch for at tilknytte{% endif %}"><span>{{ port.port_number }}</span>{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>{% else %}<span class="patch-port-outlet">Ledig</span>{% endif %}</a>{% endfor %}</div></div><div class="form-text mt-2">Grøn/rød kant viser live linkstatus fra UISP.</div></details> <details class="mt-3" open><summary class="small fw-semibold mb-2">Switch-porte ({{ hw.switch_ports | length }})</summary><div class="patch-panel"><div class="patch-panel-grid">{% for port in hw.switch_ports %}
{% if port.hardware_link %}
<a href="/hardware/{{ hw.id }}" class="patch-port smart-port text-decoration-none hardware-linked{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="hardware" title="Forbundet til {{ port.hardware_link.target_brand or '' }} {{ port.hardware_link.target_model }}{% if port.hardware_link.target_port %} · port {{ port.hardware_link.target_port }}{% endif %} — klik for hardwaredetaljer">
{% else %}
{% set switch_state = 'wan' if port.outlet and port.outlet.is_wan else ('issue' if (port.outlet and port.live and not port.live.plugged) or (not port.outlet and port.live and port.live.plugged) else ('assigned' if port.outlet else 'free')) %}
<button type="button" class="patch-port smart-port switch-port-action{% if switch_state == 'wan' %} wan{% elif switch_state == 'issue' %} smart-issue{% elif port.outlet %} assigned{% else %} unconfigured{% endif %}{% if port.live %} {{ 'border border-success' if port.live.plugged else 'border border-danger' }}{% endif %}" data-smart-state="{{ switch_state }}" data-switch-name="{{ hw.uisp_device.hostname if hw.uisp_device and hw.uisp_device.hostname else ((hw.brand ~ ' · ' if hw.brand else '') ~ (hw.model or '') ~ (' · ' ~ hw.serial_number if hw.serial_number else '')) }}" data-switch-port="{{ port.port_number }}" data-outlet-id="{{ port.outlet.id if port.outlet else '' }}" data-outlet-number="{{ port.outlet.outlet_number if port.outlet else '' }}" data-outlet-customer-id="{{ port.outlet.customer_id if port.outlet else '' }}" data-outlet-category="{{ port.outlet.category if port.outlet else '' }}" data-outlet-panel="{{ port.outlet.patch_panel if port.outlet else '' }}" data-outlet-patch-port="{{ port.outlet.patch_port if port.outlet else '' }}" data-outlet-cross-field-port-id="{{ port.outlet.cross_field_port_id if port.outlet else '' }}" data-outlet-is-wan="{{ 'true' if port.outlet and port.outlet.is_wan else 'false' }}" data-outlet-status="{{ port.outlet.status if port.outlet else '' }}" data-outlet-notes="{{ port.outlet.notes if port.outlet else '' }}" title="{% if port.outlet %}{{ port.outlet.outlet_number or 'Ikke navngivet' }}{% if port.outlet.is_wan %} · WAN{% endif %}{% if switch_state == 'issue' %} · FEJL: registreret, men intet fysisk link{% endif %} — klik for at redigere{% elif switch_state == 'issue' %}UISP ser fysisk link, men porten er ikke registreret{% else %}Ikke konfigureret — klik for at tilknytte vægstik{% endif %}">
{% endif %}
<span>{{ port.port_number }}</span>
{% if port.live %}<span class="patch-port-outlet">{{ 'LIVE' if port.live.plugged else 'INTET LINK' }}{% if port.live.speed %} · {{ port.live.speed }}{% endif %}</span>
{% elif port.hardware_link %}<span class="patch-port-outlet">{{ port.hardware_link.target_model or 'Hardware' }}{% if port.hardware_link.target_port %} · {{ port.hardware_link.target_port }}{% endif %}</span>
{% elif port.outlet and port.outlet.is_wan %}<span class="patch-port-outlet">WAN{% if port.outlet.outlet_number %} · {{ port.outlet.outlet_number }}{% endif %}</span>
{% elif port.outlet %}<span class="patch-port-outlet">{{ port.outlet.outlet_number or 'Tilknyttet' }}</span>
{% else %}<span class="patch-port-outlet">IKKE KONFIG.</span>{% endif %}
{% if port.hardware_link %}</a>{% else %}</button>{% endif %}
{% endfor %}</div></div><div class="form-text mt-2">Klik på en switch-port for at oprette eller redigere tilknytningen. Grøn/rød kant viser live linkstatus fra UISP.</div></details>
{% endif %} {% endif %}
</div> </div>
{% endfor %} {% endfor %}
@ -1092,20 +1131,42 @@
<div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div> <div class="modal fade" id="crossFieldHardwareModal" tabindex="-1" aria-hidden="true"><div class="modal-dialog"><form class="modal-content" id="crossFieldHardwareForm"><div class="modal-header"><h5 class="modal-title">Tilføj switch</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div><div class="modal-body"><div class="mb-3"><label class="form-label">Mærke</label><input class="form-control" id="switchBrand" placeholder="Fx Ubiquiti"></div><div class="mb-3"><label class="form-label">Model *</label><input class="form-control" id="switchModel" required placeholder="Fx USW-Pro-48"></div><div class="mb-3"><label class="form-label">Antal porte</label><input class="form-control" type="number" id="switchPortCount" min="1" max="999" placeholder="Fx 48"></div><div><label class="form-label">Serienummer</label><input class="form-control" id="switchSerial"></div></div><div class="modal-footer"><button class="btn btn-primary">Opret switch</button></div></form></div></div>
<div class="modal fade" id="bulkPatchModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><form class="modal-content" id="bulkPatchForm">
<div class="modal-header"><h5 class="modal-title">Massepatch porte</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body">
<div class="alert alert-warning small"><strong>Kontroller før du gemmer.</strong> Funktionen forbinder et sammenhængende område af krydsfelt-porte med samme antal switch-porte.</div>
<div class="row g-3">
<div class="col-md-6"><label class="form-label">Krydsfelt</label><select class="form-select" id="bulkCrossField" required></select></div>
<div class="col-md-3"><label class="form-label">Fra portposition</label><input class="form-control" id="bulkFromPort" type="number" min="1" value="1" required></div>
<div class="col-md-3"><label class="form-label">Til portposition</label><input class="form-control" id="bulkToPort" type="number" min="1" value="24" required></div>
<div class="col-md-6"><label class="form-label">Switch (UISP-hostnavn)</label><select class="form-select" id="bulkSwitch" required></select></div>
<div class="col-md-3"><label class="form-label">Start switch-port</label><input class="form-control" id="bulkSwitchStart" type="number" min="1" value="1" required></div>
<div class="col-md-3 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" id="bulkIsWan" type="checkbox"><label class="form-check-label fw-semibold" for="bulkIsWan">WAN</label></div></div>
<div class="col-12"><label class="form-label">Firma</label><input class="form-control mb-2" id="bulkCustomerSearch" type="search" placeholder="Søg firma…"><select class="form-select" id="bulkCustomer"><option value="">Ingen specifik kunde</option></select></div>
<div class="col-12"><div class="p-3 rounded border bg-light small" id="bulkPatchPreview">Vælg område og switch for at se en forhåndsvisning.</div></div>
</div>
</div>
<div class="modal-footer"><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button type="submit" class="btn btn-primary" id="bulkPatchSubmit">Kontrollér og gem</button></div>
</form></div>
</div>
<div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="outletModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div> <div class="modal-dialog modal-lg"><div class="modal-content"><div class="modal-header"><h5 class="modal-title" id="outletModalTitle">Tilføj vægstik</h5><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<form id="outletForm"><div class="modal-body"> <form id="outletForm"><div class="modal-body">
<input type="hidden" id="outletId"><div class="row g-3"> <input type="hidden" id="outletId"><div class="row g-3">
<div class="col-12"><div class="alert alert-primary py-2 mb-0 small" id="outletConnectionPath"><strong>Kabelsti:</strong> endnu ikke forbundet</div></div>
<div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div> <div class="col-12"><label class="form-label">Lokation *</label><select class="form-select" id="outletLocationId" required></select></div>
<div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div> <div class="col-md-6"><label class="form-label">Stiknavn/-nummer</label><input class="form-control" id="outletNumber" placeholder="Valgfrit, fx A-12 eller 1.23.04"></div>
<div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div> <div class="col-md-6"><label class="form-label">Netværkskategori</label><input class="form-control" id="outletCategory" placeholder="Fx Cat6a"></div>
<div class="col-12"><label class="form-label">Kunde på porten</label><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Bruges fx hvis et stik eller en switch-port er tildelt en bestemt lejer/kunde.</div></div> <div class="col-12"><label class="form-label" for="outletCustomerSearch">Søg firma</label><input class="form-control mb-2" id="outletCustomerSearch" type="search" placeholder="Skriv firmanavn…"><select class="form-select" id="outletCustomerId"><option value="">Ingen specifik kunde / brug lokationens kunde</option></select><div class="form-text">Søg og vælg firmaet, som porten er tildelt.</div></div>
<div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div> <div class="col-md-6"><label class="form-label">Patchpanel</label><input class="form-control" id="outletPatchPanel" placeholder="Fx Patchpanel A"></div>
<div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div> <div class="col-md-6"><label class="form-label">Patchpanel-port</label><input class="form-control" id="outletPatchPort" placeholder="Fx 12"></div>
<div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div> <div class="col-12"><label class="form-label">Krydsfelt-port</label><select class="form-select" id="outletCrossFieldPort"><option value="">Vælg senere / ingen kobling</option></select><div class="form-text">Viser ledige porte fra alle krydsfelter.</div></div>
<div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div> <div class="col-md-6"><label class="form-label">Switch</label><input class="form-control" id="outletSwitch" list="outletSwitchOptions" placeholder="Vælg registreret switch eller skriv navn"><datalist id="outletSwitchOptions"></datalist></div>
<div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div> <div class="col-md-6"><label class="form-label">Switch-port</label><select class="form-select" id="outletSwitchPort"><option value="">Vælg port</option></select><div class="form-text" id="outletSwitchPortHelp">Vælg først en switch.</div></div>
<div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div> <div class="col-md-6"><label class="form-label">Status</label><select class="form-select" id="outletStatus"><option value="unknown">Ukendt</option><option value="available">Ledig</option><option value="active">Aktiv</option><option value="reserved">Reserveret</option><option value="faulty">Defekt</option></select></div>
<div class="col-md-6 d-flex align-items-end"><div class="form-check form-switch mb-2"><input class="form-check-input" type="checkbox" role="switch" id="outletIsWan"><label class="form-check-label fw-semibold" for="outletIsWan">WAN-forbindelse</label><div class="form-text">WAN-porte vises med turkis farve.</div></div></div>
<div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div> <div class="col-12"><label class="form-label">Note</label><textarea class="form-control" id="outletNotes" rows="2"></textarea></div>
</div> </div>
</div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form> </div><div class="modal-footer"><button type="button" class="btn btn-outline-danger me-auto d-none" id="deleteOutletBtn">Slet stik</button><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div></form>
@ -1147,6 +1208,7 @@ document.addEventListener('DOMContentLoaded', function() {
const locationId = '{{ location.id }}'; const locationId = '{{ location.id }}';
const locationHardware = {{ location.hardware | tojson }}; const locationHardware = {{ location.hardware | tojson }};
const locationWallOutlets = {{ location.wall_outlets | tojson }}; const locationWallOutlets = {{ location.wall_outlets | tojson }};
const locationCrossFields = {{ location.cross_fields | tojson }};
const existingContactSearchInput = document.getElementById('existingContactSearch'); const existingContactSearchInput = document.getElementById('existingContactSearch');
const existingContactResultsContainer = document.getElementById('existingContactResults'); const existingContactResultsContainer = document.getElementById('existingContactResults');
const existingContactIdInput = document.getElementById('existingContactId'); const existingContactIdInput = document.getElementById('existingContactId');
@ -1498,6 +1560,8 @@ document.addEventListener('DOMContentLoaded', function() {
const outletValue = (id) => document.getElementById(id).value.trim() || null; const outletValue = (id) => document.getElementById(id).value.trim() || null;
function switchDisplayName(hardware) { function switchDisplayName(hardware) {
const uispHostname = hardware.uisp_device?.hostname;
if (uispHostname) return uispHostname;
return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`; return [hardware.brand, hardware.model, hardware.serial_number].filter(Boolean).join(' · ') || `Switch #${hardware.id}`;
} }
@ -1510,6 +1574,125 @@ document.addEventListener('DOMContentLoaded', function() {
return Number.isInteger(count) && count > 0 ? count : 0; return Number.isInteger(count) && count > 0 ? count : 0;
} }
const smartPorts = Array.from(document.querySelectorAll('.smart-port'));
document.getElementById('smartIssueCount').textContent = String(smartPorts.filter(port => port.dataset.smartState === 'issue').length);
document.querySelectorAll('.smart-port-filter').forEach(button => button.addEventListener('click', () => {
const filter = button.dataset.filter;
smartPorts.forEach(port => port.classList.toggle('smart-port-hidden', filter !== 'all' && port.dataset.smartState !== filter));
document.querySelectorAll('.smart-port-filter').forEach(item => {
item.classList.toggle('active', item === button);
item.classList.toggle('btn-primary', item === button);
if (item !== button) item.classList.remove('btn-primary');
});
}));
const bulkPatchElement = document.getElementById('bulkPatchModal');
const bulkPatchModal = bulkPatchElement ? new bootstrap.Modal(bulkPatchElement) : null;
const bulkFieldSelect = document.getElementById('bulkCrossField');
const bulkSwitchSelect = document.getElementById('bulkSwitch');
const bulkCustomerSelect = document.getElementById('bulkCustomer');
function selectedBulkField() {
return (locationCrossFields || []).find(field => Number(field.id) === Number(bulkFieldSelect.value));
}
function updateBulkPreview() {
const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
const count = Number.isInteger(from) && Number.isInteger(to) && to >= from ? to - from + 1 : 0;
const firstPort = field?.ports?.[from - 1]?.port_number || '—';
const lastPort = field?.ports?.[to - 1]?.port_number || '—';
document.getElementById('bulkPatchPreview').innerHTML = count
? `<strong>${count} forbindelser:</strong> ${field?.name || '—'} port ${firstPort}${lastPort} → ${hardware ? switchDisplayName(hardware) : '—'} port ${switchStart}${switchStart + count - 1}`
: 'Vælg et gyldigt portområde.';
}
async function openBulkPatch() {
bulkFieldSelect.innerHTML = (locationCrossFields || []).map(field => `<option value="${field.id}">${field.name} · ${field.port_count} porte</option>`).join('');
const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
bulkSwitchSelect.innerHTML = switches.map(item => `<option value="${item.id}">${switchDisplayName(item)} · ${switchPortCount(item)} porte</option>`).join('');
const response = await fetch('/api/v1/customers?limit=1000&offset=0');
const data = response.ok ? await response.json() : [];
const customers = Array.isArray(data) ? data : (data.customers || []);
bulkCustomerSelect.innerHTML = '<option value="">Ingen specifik kunde</option>' + customers.map(customer => `<option value="${customer.id}">${customer.name || customer.navn || `Kunde #${customer.id}`}</option>`).join('');
const firstField = selectedBulkField();
document.getElementById('bulkToPort').value = Math.min(24, firstField?.ports?.length || 1);
updateBulkPreview();
bulkPatchModal?.show();
}
document.getElementById('openBulkPatchBtn')?.addEventListener('click', openBulkPatch);
['bulkCrossField', 'bulkSwitch', 'bulkFromPort', 'bulkToPort', 'bulkSwitchStart'].forEach(id => document.getElementById(id)?.addEventListener('input', updateBulkPreview));
document.getElementById('bulkCustomerSearch')?.addEventListener('input', event => {
const query = event.target.value.trim().toLocaleLowerCase('da');
Array.from(bulkCustomerSelect.options).forEach((option, index) => {
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
});
const match = Array.from(bulkCustomerSelect.options).find((option, index) => index > 0 && !option.hidden);
if (query && match) bulkCustomerSelect.value = match.value;
});
document.getElementById('bulkPatchForm')?.addEventListener('submit', async event => {
event.preventDefault();
const field = selectedBulkField();
const hardware = (locationHardware || []).find(item => Number(item.id) === Number(bulkSwitchSelect.value));
const from = Number(document.getElementById('bulkFromPort').value);
const to = Number(document.getElementById('bulkToPort').value);
const switchStart = Number(document.getElementById('bulkSwitchStart').value);
if (!field || !hardware || !Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from || to > field.ports.length) {
alert('Vælg et gyldigt krydsfelt, en switch og et portområde.');
return;
}
const ports = field.ports.slice(from - 1, to);
if (switchStart < 1 || switchStart + ports.length - 1 > switchPortCount(hardware)) {
alert('Portområdet går ud over switchens registrerede antal porte.');
return;
}
const conflicts = ports.map((port, index) => switchPortConflict(hardware, switchDisplayName(hardware), switchStart + index, port.outlet_id || null)).filter(Boolean);
if (conflicts.length) {
alert(`Massepatch blev stoppet: ${conflicts.length} switch-port(e) er allerede knyttet til andre vægstik.`);
return;
}
const existingCount = ports.filter(port => port.outlet_id).length;
if (!confirm(`Opret/opdatér ${ports.length} forbindelser?\n\n${existingCount} eksisterende vægstik bliver opdateret.`)) return;
const submit = document.getElementById('bulkPatchSubmit');
submit.disabled = true;
submit.textContent = 'Gemmer…';
const customerId = bulkCustomerSelect.value ? Number(bulkCustomerSelect.value) : null;
const isWan = document.getElementById('bulkIsWan').checked;
const results = await Promise.all(ports.map(async (port, index) => {
const payload = {
outlet_number: port.outlet_number || `${field.name}-${port.port_number}`,
customer_id: customerId,
category: port.category || null,
patch_panel: field.name,
patch_port: String(port.port_number),
cross_field_port_id: Number(port.id),
switch_hardware_id: Number(hardware.id),
switch_name: switchDisplayName(hardware),
switch_port: String(switchStart + index),
is_wan: isWan,
status: 'active',
notes: port.outlet_notes || null
};
if (!port.outlet_id) payload.location_id = Number(locationId);
const response = await fetch(port.outlet_id ? `/api/v1/locations/outlets/${port.outlet_id}` : '/api/v1/locations/outlets', {
method: port.outlet_id ? 'PATCH' : 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
return response.ok;
}));
const failed = results.filter(ok => !ok).length;
if (failed) {
alert(`${results.length - failed} forbindelser blev gemt, men ${failed} fejlede. Siden genindlæses.`);
}
location.reload();
});
function selectedSwitchHardwareId() { function selectedSwitchHardwareId() {
const selectedName = document.getElementById('outletSwitch').value; const selectedName = document.getElementById('outletSwitch').value;
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName); const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
@ -1587,20 +1770,46 @@ document.addEventListener('DOMContentLoaded', function() {
async function loadOutletCustomers(selectedCustomerId = null) { async function loadOutletCustomers(selectedCustomerId = null) {
const select = document.getElementById('outletCustomerId'); const select = document.getElementById('outletCustomerId');
const customers = await fetch('/api/v1/customers?limit=1000').then(response => response.ok ? response.json() : []); document.getElementById('outletCustomerSearch').value = '';
select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>'; select.innerHTML = '<option value="">Ingen specifik kunde / brug lokationens kunde</option>';
(customers || []).forEach(customer => { const response = await fetch('/api/v1/customers?limit=1000&offset=0');
if (!response.ok) {
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Kunne ikke indlæse kunder</option>');
return;
}
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
customers.forEach(customer => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = String(customer.id); option.value = String(customer.id);
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`; option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
option.selected = String(customer.id) === String(selectedCustomerId || ''); option.selected = String(customer.id) === String(selectedCustomerId || '');
select.appendChild(option); select.appendChild(option);
}); });
if (!customers.length) {
select.insertAdjacentHTML('beforeend', '<option value="" disabled>Ingen kunder fundet</option>');
}
} }
document.getElementById('outletCustomerSearch')?.addEventListener('input', (event) => {
const query = event.target.value.trim().toLocaleLowerCase('da');
const select = document.getElementById('outletCustomerId');
Array.from(select.options).forEach((option, index) => {
option.hidden = index > 0 && Boolean(query) && !option.textContent.toLocaleLowerCase('da').includes(query);
});
const firstMatch = Array.from(select.options).find((option, index) => index > 0 && !option.hidden && !option.disabled);
if (query && firstMatch) select.value = firstMatch.value;
});
async function openOutletModal(outlet = null, selectedPort = null) { async function openOutletModal(outlet = null, selectedPort = null) {
if (!outletModal) return; if (!outletModal) return;
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]); const isExistingOutlet = Boolean(outlet?.id);
outletModal.show();
try {
await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
} catch (error) {
console.error('Kunne ikke indlæse vægstik-data', error);
}
document.getElementById('outletId').value = outlet?.id || ''; document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('outletNumber').value = outlet?.number || ''; document.getElementById('outletNumber').value = outlet?.number || '';
document.getElementById('outletCategory').value = outlet?.category || ''; document.getElementById('outletCategory').value = outlet?.category || '';
@ -1608,7 +1817,21 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('outletPatchPort').value = outlet?.patchPort || ''; document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null); loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
document.getElementById('outletStatus').value = outlet?.status || 'unknown'; document.getElementById('outletStatus').value = outlet?.status || 'unknown';
document.getElementById('outletIsWan').checked = Boolean(outlet?.isWan);
document.getElementById('outletNotes').value = outlet?.notes || ''; document.getElementById('outletNotes').value = outlet?.notes || '';
const pathParts = [
outlet?.number || 'Nyt vægstik',
selectedPort ? `${selectedPort.fieldName} port ${selectedPort.portNumber}` : (outlet?.panel && outlet?.patchPort ? `${outlet.panel} port ${outlet.patchPort}` : null),
outlet?.switchName && outlet?.switchPort ? `${outlet.switchName} port ${outlet.switchPort}` : null
].filter(Boolean);
document.getElementById('outletConnectionPath').textContent = `Kabelsti: ${pathParts.join(' → ')}`;
if (outlet?.crossFieldPortId) {
const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(outlet.crossFieldPortId);
if (portSelect.value !== String(outlet.crossFieldPortId)) {
portSelect.insertAdjacentHTML('beforeend', `<option value="${outlet.crossFieldPortId}" selected>Tilknyttet krydsfelt-port</option>`);
}
}
if (selectedPort) { if (selectedPort) {
const portSelect = document.getElementById('outletCrossFieldPort'); const portSelect = document.getElementById('outletCrossFieldPort');
portSelect.value = String(selectedPort.id); portSelect.value = String(selectedPort.id);
@ -1618,16 +1841,52 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('outletPatchPanel').value = selectedPort.fieldName; document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
document.getElementById('outletPatchPort').value = selectedPort.portNumber; document.getElementById('outletPatchPort').value = selectedPort.portNumber;
} }
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik'; document.getElementById('outletModalTitle').textContent = isExistingOutlet ? 'Rediger vægstik' : 'Tilføj vægstik';
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet); document.getElementById('deleteOutletBtn').classList.toggle('d-none', !isExistingOutlet);
outletModal.show();
} }
document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal()); document.getElementById('addOutletBtn')?.addEventListener('click', () => openOutletModal());
document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => openOutletModal(null, {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber}))); document.querySelectorAll('[data-cross-field-port-id]').forEach(port => port.addEventListener('click', () => {
const selectedPort = {id: port.dataset.crossFieldPortId, fieldName: port.dataset.crossFieldName, portNumber: port.dataset.portNumber};
const outlet = port.dataset.outletId ? {
id: port.dataset.outletId,
number: port.dataset.outletNumber,
customerId: port.dataset.outletCustomerId,
category: port.dataset.outletCategory,
panel: port.dataset.outletPanel,
patchPort: port.dataset.outletPatchPort,
switchName: port.dataset.outletSwitch,
switchPort: port.dataset.outletSwitchPort,
isWan: port.dataset.outletIsWan === 'true',
status: port.dataset.outletStatus,
notes: port.dataset.outletNotes
} : null;
openOutletModal(outlet, selectedPort);
}));
document.querySelectorAll('.switch-port-action').forEach(port => port.addEventListener('click', () => {
const outlet = port.dataset.outletId ? {
id: port.dataset.outletId,
number: port.dataset.outletNumber,
customerId: port.dataset.outletCustomerId,
category: port.dataset.outletCategory,
panel: port.dataset.outletPanel,
patchPort: port.dataset.outletPatchPort,
crossFieldPortId: port.dataset.outletCrossFieldPortId,
switchName: port.dataset.switchName,
switchPort: port.dataset.switchPort,
isWan: port.dataset.outletIsWan === 'true',
status: port.dataset.outletStatus,
notes: port.dataset.outletNotes
} : {
switchName: port.dataset.switchName,
switchPort: port.dataset.switchPort,
isWan: false
};
openOutletModal(outlet);
}));
document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({ document.querySelectorAll('.edit-outlet-btn').forEach(btn => btn.addEventListener('click', () => openOutletModal({
id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel, id: btn.dataset.id, number: btn.dataset.number, customerId: btn.dataset.customerId, category: btn.dataset.category, panel: btn.dataset.panel,
patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort, patchPort: btn.dataset.patchPort, switchName: btn.dataset.switch, switchPort: btn.dataset.switchPort, isWan: btn.dataset.isWan === 'true',
status: btn.dataset.status, notes: btn.dataset.notes status: btn.dataset.status, notes: btn.dataset.notes
}))); })));
@ -1639,6 +1898,7 @@ document.addEventListener('DOMContentLoaded', function() {
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'), patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null, cross_field_port_id: document.getElementById('outletCrossFieldPort').value ? Number(document.getElementById('outletCrossFieldPort').value) : null,
switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'), switch_hardware_id: selectedSwitchHardwareId(), switch_name: outletValue('outletSwitch'), switch_port: outletValue('outletSwitchPort'),
is_wan: document.getElementById('outletIsWan').checked,
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes') status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
}; };
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId()); const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());

View File

@ -0,0 +1 @@
"""Manual migration centre for CRM subscriptions and imported invoice lines."""

View File

@ -0,0 +1 @@
"""Migration centre backend."""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,641 @@
"""Core repositories and workflows for the manual migration centre."""
from __future__ import annotations
import csv
import hashlib
import io
import json
from datetime import date, datetime, timedelta, timezone
from decimal import Decimal
from difflib import SequenceMatcher
from typing import Any, Dict, Iterable, List, Optional
import jwt
from fastapi import HTTPException, Request
from psycopg2.extras import Json, RealDictCursor
from app.core.config import settings
from app.core.database import (
execute_query,
execute_query_single,
get_db_connection,
release_db_connection,
)
MUTABLE_LOCK_STATES = {"unlocked", "lock_failed"}
def subscription_like_item_sql(alias: str = "migration_center_session_items") -> str:
"""Fast predicate using the classification refreshed when snapshots change."""
p = f"{alias}." if alias else ""
return f"{p}subscription_like=TRUE"
def refresh_subscription_relevance(session_id: int) -> Dict[str, int]:
"""Classify imported invoice lines once so all views share a fast, consistent filter."""
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='crm_subscription'
WHERE session_id=%s AND source_system<>'economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=FALSE, subscription_relevance_reason='one_off'
WHERE session_id=%s AND source_system='economic'
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_relevance_reason='excluded_charge'
WHERE session_id=%s AND source_system='economic'
AND (
POSITION('gebyr' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('fragt' IN LOWER(COALESCE(product_name,'')))>0
OR POSITION('porto' IN LOWER(COALESCE(product_name,'')))>0
)
""",
(session_id,), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items
SET subscription_like=TRUE, subscription_relevance_reason='subscription_keyword'
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND LOWER(COALESCE(product_name,'')) ~
'(abonnement|subscription|måned|kvartal|årlig|licens|license|fiber|internet|bredbånd|hosting|domæne|domain|cloud|microsoft|office[ ]?365|backup|supportaftale|driftsaftale|udlejning|leje|telefoni|simkort|eset)'
""",
(session_id,), fetch=False,
)
execute_query(
"""
WITH recurring AS (
SELECT
COALESCE(NULLIF(customer_no,''),source_customer_id,customer_name) AS customer_key,
COALESCE(NULLIF(product_code,''),LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key
FROM migration_center_session_items
WHERE session_id=%s AND source_system='economic'
AND subscription_relevance_reason<>'excluded_charge'
AND invoice_date >= (DATE_TRUNC('month',CURRENT_DATE)-INTERVAL '12 months')::date
GROUP BY 1,2
HAVING COUNT(DISTINCT invoice_no)>=2
AND COUNT(DISTINCT DATE_TRUNC('month',invoice_date))>=2
)
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='recurring_invoice'
FROM recurring
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND COALESCE(NULLIF(item.customer_no,''),item.source_customer_id,item.customer_name)
IS NOT DISTINCT FROM recurring.customer_key
AND COALESCE(NULLIF(item.product_code,''),LOWER(REGEXP_REPLACE(item.product_name,'\\s+',' ','g')))
IS NOT DISTINCT FROM recurring.product_key
""",
(session_id, session_id), fetch=False,
)
execute_query(
"""
UPDATE migration_center_session_items item
SET subscription_like=TRUE, subscription_relevance_reason='existing_subscription'
WHERE item.session_id=%s AND item.source_system='economic'
AND item.subscription_relevance_reason<>'excluded_charge'
AND EXISTS (
SELECT 1 FROM sag_subscriptions subscription
WHERE subscription.customer_id=item.hub_customer_id
AND subscription.status<>'cancelled'
AND (
LOWER(TRIM(COALESCE(subscription.product_name,'')))=
LOWER(TRIM(COALESCE(item.product_name,'')))
OR ABS(COALESCE(subscription.price,0)-COALESCE(item.amount,0))<=0.01
)
)
""",
(session_id,), fetch=False,
)
row = execute_query_single(
"""
SELECT COUNT(*) FILTER (WHERE subscription_like) AS visible,
COUNT(*) FILTER (WHERE NOT subscription_like) AS hidden
FROM migration_center_session_items WHERE session_id=%s
""",
(session_id,),
)
return {"visible": int(row["visible"]), "hidden": int(row["hidden"])}
def json_value(value: Any) -> Any:
if isinstance(value, (datetime, date)):
return value.isoformat()
if isinstance(value, Decimal):
return float(value)
if isinstance(value, dict):
return {str(k): json_value(v) for k, v in value.items()}
if isinstance(value, (list, tuple)):
return [json_value(v) for v in value]
return value
def snapshot_hash(payload: Dict[str, Any]) -> str:
packed = json.dumps(json_value(payload), ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(packed.encode("utf-8")).hexdigest()
def user_id(current_user: Dict[str, Any]) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id")
return int(value) if value is not None else None
def audit(
*,
request: Request,
current_user: Dict[str, Any],
action: str,
entity_type: str,
entity_id: Any = None,
session_id: Optional[int] = None,
item_id: Optional[int] = None,
old_value: Any = None,
new_value: Any = None,
source_hash_value: Optional[str] = None,
success: bool = True,
error_message: Optional[str] = None,
) -> None:
execute_query(
"""
INSERT INTO migration_center_audit_log
(session_id, session_item_id, entity_type, entity_id, action, old_value, new_value,
source_hash, performed_by_user_id, ip_address, success, error_message)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
session_id, item_id, entity_type, str(entity_id) if entity_id is not None else None,
action, Json(json_value(old_value)) if old_value is not None else None,
Json(json_value(new_value)) if new_value is not None else None,
source_hash_value, user_id(current_user),
request.client.host if request.client else None, success, error_message,
),
fetch=False,
)
def ensure_writable(session_id: Optional[int] = None) -> None:
if getattr(settings, "MIGRATION_CENTER_READ_ONLY", False):
raise HTTPException(status_code=423, detail="Migreringscenteret er i read-only tilstand")
if session_id:
session = execute_query_single(
"SELECT read_only, status FROM migration_center_sessions WHERE id = %s", (session_id,)
)
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
if session["read_only"] or session["status"] in {"completed", "archived"}:
raise HTTPException(status_code=423, detail="Kontrolsessionen er skrivebeskyttet")
class EconomicSnapshotRepository:
"""Read-only access to the Invoice Error Finder invoice snapshot."""
@staticmethod
def latest_run() -> Optional[Dict[str, Any]]:
return execute_query_single(
"""
SELECT r.id, r.source_type, r.started_at, r.completed_at, r.status,
r.records_imported, r.records_failed, COUNT(i.id) AS invoice_count
FROM invoice_error_finder_import_runs r
JOIN invoice_error_finder_economic_invoices i ON i.import_run_id = r.id
WHERE r.source_type = 'economic_invoices'
AND r.status IN ('success', 'partial')
AND r.completed_at IS NOT NULL
GROUP BY r.id
HAVING COUNT(i.id) > 0
ORDER BY r.completed_at DESC, r.id DESC
LIMIT 1
"""
)
@staticmethod
def lines(run_id: int) -> List[Dict[str, Any]]:
# DISTINCT ON makes the source identity stable even if an API endpoint repeats a line.
return execute_query(
"""
SELECT DISTINCT ON (
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id)
)
i.id AS invoice_id, i.source_invoice_number, i.source_type, i.customer_number,
i.customer_name, i.invoice_date, i.currency, i.source_raw AS invoice_raw,
l.id AS invoice_line_id, l.line_number, l.product_number, l.product_name,
l.description, l.quantity, l.unit_price, l.line_net_amount, l.source_raw AS line_raw
FROM invoice_error_finder_economic_invoices i
JOIN invoice_error_finder_economic_invoice_lines l ON l.invoice_id = i.id
WHERE i.import_run_id = %s
AND i.invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
AND i.invoice_date <= CURRENT_DATE
AND LOWER(
COALESCE(l.product_name,'') || ' ' ||
COALESCE(l.description,'') || ' ' ||
COALESCE(l.product_number,'')
) NOT SIMILAR TO '%%(gebyr|fragt|porto)%%'
ORDER BY
COALESCE(i.source_invoice_number, i.id::text),
i.source_type,
COALESCE(l.line_number, l.id),
l.id DESC
""",
(run_id,),
) or []
def attach_economic_snapshot(session_id: int, run: Dict[str, Any]) -> Dict[str, int]:
"""Attach the selected usable 13-month IEF snapshot to an explicit session."""
counts = {"created": 0, "unchanged": 0}
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
UPDATE migration_center_sessions
SET economic_import_run_id=%s, economic_snapshot_at=%s, updated_at=CURRENT_TIMESTAMP
WHERE id=%s
""",
(run["id"], run["completed_at"], session_id),
)
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
cursor.execute(
"""
INSERT INTO migration_center_session_items
(session_id, entity_type, source_system, source_record_id, source_customer_id,
customer_no, customer_name, product_code, product_name, amount, quantity,
billing_frequency, period_from, period_to, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
RETURNING id
""",
(
session_id, item["entity_type"], item["source_system"], item["source_record_id"],
item["source_customer_id"], item["customer_no"], item["customer_name"],
item["product_code"], item["product_name"], item["amount"], item["quantity"],
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
inserted = cursor.fetchone()
counts["created" if inserted else "unchanged"] += 1
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
return counts
def _normalize_economic_line(row: Dict[str, Any]) -> Dict[str, Any]:
source_id = f"{row.get('source_type')}:{row.get('source_invoice_number') or row['invoice_id']}:{row.get('line_number') or row['invoice_line_id']}"
raw = {"invoice": row.get("invoice_raw") or {}, "line": row.get("line_raw") or {}}
normalized = {
"entity_type": "invoice_line",
"source_system": "economic",
"source_record_id": source_id,
"source_customer_id": str(row.get("customer_number") or ""),
"customer_no": str(row.get("customer_number") or ""),
"customer_name": str(row.get("customer_name") or "")[:255] or None,
"product_code": str(row.get("product_number") or "")[:100] or None,
"product_name": str(row.get("product_name") or row.get("description") or "Fakturalinje")[:500],
"amount": row.get("line_net_amount") or 0,
"quantity": row.get("quantity") or 1,
"billing_frequency": None,
"period_from": row.get("invoice_date"),
"period_to": None,
"invoice_no": row.get("source_invoice_number"),
"invoice_date": row.get("invoice_date"),
"source_payload": raw,
}
normalized["source_hash"] = snapshot_hash(normalized)
return normalized
def _customer_candidates(item: Dict[str, Any]) -> List[Dict[str, Any]]:
rows = execute_query(
"""
SELECT id, name, cvr_number, email, email_domain, economic_customer_number
FROM customers
WHERE deleted_at IS NULL
ORDER BY id
"""
) or []
source_no = str(item.get("customer_no") or "").strip().lower()
source_name = str(item.get("customer_name") or "").strip().lower()
source_cvr = str((item.get("source_payload") or {}).get("customer_cvr") or "").replace(" ", "").strip()
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
if source_no and str(row.get("economic_customer_number") or "").strip().lower() == source_no:
score += 0.75
rules.append("Kundenummer stemmer")
if source_cvr and str(row.get("cvr_number") or "").replace(" ", "").strip() == source_cvr:
score += 0.90
rules.append("CVR stemmer")
name_ratio = SequenceMatcher(None, source_name, str(row.get("name") or "").strip().lower()).ratio()
if source_name and name_ratio >= 0.70:
score += min(0.25, name_ratio * 0.25)
rules.append(f"Firmanavn ligner ({round(name_ratio * 100)} %)")
if score:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def _resolve_source_customer(item: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Resolve authoritative CRM mappings before heuristic customer matching."""
source_id = str(item.get("source_customer_id") or "").strip()
if not source_id:
return None
if item.get("source_system") == "simply":
row = execute_query_single(
"""
SELECT source_customer_name AS customer_name, source_customer_cvr AS cvr,
hub_customer_id
FROM simply_subscription_staging
WHERE source_account_id=%s
ORDER BY (hub_customer_id IS NOT NULL) DESC, updated_at DESC, id DESC
LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Eksisterende Simply-kundemapping"
return result
if item.get("source_system") == "vtiger":
row = execute_query_single(
"""
SELECT id AS hub_customer_id, name AS customer_name, cvr_number AS cvr
FROM customers WHERE vtiger_id=%s AND deleted_at IS NULL LIMIT 1
""",
(source_id,),
)
if row:
result = dict(row)
result["rule"] = "Vtiger-konto-id stemmer"
return result
return None
def _subscription_candidates(item: Dict[str, Any], customer_id: Optional[int]) -> List[Dict[str, Any]]:
if not customer_id:
return []
rows = execute_query(
"""
SELECT id, product_name, price, billing_interval, start_date, end_date
FROM sag_subscriptions
WHERE customer_id = %s AND status <> 'cancelled'
ORDER BY updated_at DESC, id DESC
""",
(customer_id,),
) or []
source_name = str(item.get("product_name") or "").strip().lower()
source_amount = Decimal(str(item.get("amount") or 0))
candidates = []
for row in rows:
rules: List[str] = []
score = 0.0
ratio = SequenceMatcher(None, source_name, str(row.get("product_name") or "").strip().lower()).ratio()
if ratio >= 0.55:
score += ratio * 0.55
rules.append(f"Produktnavn ligner ({round(ratio * 100)} %)")
hub_amount = Decimal(str(row.get("price") or 0))
if abs(source_amount - hub_amount) <= Decimal("0.01"):
score += 0.35
rules.append("Beløb stemmer")
elif max(abs(source_amount), Decimal("1")) and abs(source_amount - hub_amount) / max(abs(source_amount), Decimal("1")) <= Decimal("0.10"):
score += 0.15
rules.append("Beløb afviger højst 10 %")
if item.get("billing_frequency") and item["billing_frequency"] == row.get("billing_interval"):
score += 0.10
rules.append("Frekvens stemmer")
if score >= 0.35:
candidates.append({"id": row["id"], "score": min(score, 1.0), "rules": rules})
return sorted(candidates, key=lambda value: value["score"], reverse=True)
def match_item(item_id: int) -> Dict[str, Any]:
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
if not item:
raise HTTPException(status_code=404, detail="Post blev ikke fundet")
authoritative = _resolve_source_customer(item)
if authoritative:
enriched_payload = dict(item.get("source_payload") or {})
if authoritative.get("cvr"):
enriched_payload["customer_cvr"] = authoritative["cvr"]
execute_query(
"""
UPDATE migration_center_session_items
SET customer_name=COALESCE(NULLIF(%s,''),customer_name),
hub_customer_id=COALESCE(%s,hub_customer_id), source_payload=%s,
updated_at=CURRENT_TIMESTAMP
WHERE id=%s AND lock_status IN ('unlocked','lock_failed')
""",
(
authoritative.get("customer_name"), authoritative.get("hub_customer_id"),
Json(json_value(enriched_payload)), item_id,
), fetch=False,
)
item = execute_query_single("SELECT * FROM migration_center_session_items WHERE id=%s", (item_id,))
customer_matches = _customer_candidates(item)
customer_id = item.get("hub_customer_id") or (customer_matches[0]["id"] if customer_matches else None)
sub_matches = _subscription_candidates(item, customer_id)
best = sub_matches[0] if sub_matches else None
explanations = []
if authoritative:
explanations.append(authoritative["rule"])
if customer_matches:
explanations.extend(customer_matches[0]["rules"])
explanations.extend(best["rules"] if best else ["Intet sikkert abonnement-match"])
confidence = best["score"] if best else (customer_matches[0]["score"] * 0.4 if customer_matches else 0)
status = "match_found" if best and confidence >= 0.60 else ("manual_review" if confidence else "no_match")
hub_status = item["hub_status"]
if hub_status == "not_created" and not best:
hub_status = "ready_for_creation" if customer_id else "not_created"
execute_query(
"""
UPDATE migration_center_session_items
SET hub_customer_id = COALESCE(hub_customer_id, %s), suggested_hub_record_id = %s,
match_confidence = %s, match_explanation = %s, match_status = %s,
hub_status = %s, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND lock_status IN ('unlocked','lock_failed')
""",
(customer_id, best["id"] if best else None, confidence, Json(explanations), status, hub_status, item_id),
fetch=False,
)
execute_query(
"DELETE FROM migration_center_matches WHERE session_item_id=%s AND approved IS NULL",
(item_id,), fetch=False,
)
for candidate in customer_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'customer',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
for candidate in sub_matches[:5]:
execute_query(
"""
INSERT INTO migration_center_matches
(session_item_id, matched_entity_type, matched_hub_id, confidence, rules)
VALUES (%s,'subscription',%s,%s,%s)
ON CONFLICT (session_item_id, matched_entity_type, matched_hub_id)
DO UPDATE SET confidence=EXCLUDED.confidence, rules=EXCLUDED.rules
""",
(item_id, candidate["id"], candidate["score"], Json(candidate["rules"])),
fetch=False,
)
return execute_query_single("SELECT * FROM migration_center_session_items WHERE id = %s", (item_id,))
def create_session(name: str, current_user: Dict[str, Any], request: Request) -> Dict[str, Any]:
ensure_writable()
run = EconomicSnapshotRepository.latest_run()
if not run:
raise HTTPException(
status_code=409,
detail="Ingen færdig e-conomic-import findes i Faktura-fejl-finder",
)
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(
"""
INSERT INTO migration_center_sessions
(name, status, economic_import_run_id, economic_snapshot_at, created_by_user_id)
VALUES (%s, 'active', %s, %s, %s) RETURNING *
""",
(name.strip(), run["id"], run["completed_at"], user_id(current_user)),
)
session = dict(cursor.fetchone())
for row in EconomicSnapshotRepository.lines(run["id"]):
item = _normalize_economic_line(dict(row))
if item["source_customer_id"]:
customer_payload = {
"source_customer_id": item["source_customer_id"],
"customer_no": item["customer_no"],
"customer_name": item["customer_name"] or item["source_customer_id"],
}
cursor.execute(
"""
INSERT INTO migration_center_source_customers
(source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
VALUES ('economic',%s,%s,%s,%s,%s)
ON CONFLICT (source_system, source_customer_id) DO UPDATE SET
customer_no=EXCLUDED.customer_no, customer_name=EXCLUDED.customer_name,
raw_payload=EXCLUDED.raw_payload, snapshot_hash=EXCLUDED.snapshot_hash,
updated_at=CURRENT_TIMESTAMP
""",
(
item["source_customer_id"], item["customer_no"], customer_payload["customer_name"],
Json(customer_payload), snapshot_hash(customer_payload),
),
)
cursor.execute(
"""
INSERT INTO migration_center_session_items
(session_id, entity_type, source_system, source_record_id, source_customer_id,
customer_no, customer_name, product_code, product_name, amount, quantity,
billing_frequency, period_from, period_to, invoice_no, invoice_date,
source_payload, source_hash)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
ON CONFLICT (session_id, entity_type, source_system, source_record_id) DO NOTHING
""",
(
session["id"], item["entity_type"], item["source_system"], item["source_record_id"],
item["source_customer_id"], item["customer_no"], item["customer_name"],
item["product_code"], item["product_name"], item["amount"], item["quantity"],
item["billing_frequency"], item["period_from"], item["period_to"], item["invoice_no"],
item["invoice_date"], Json(json_value(item["source_payload"])), item["source_hash"],
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
audit(
request=request, current_user=current_user, action="session_created",
entity_type="session", entity_id=session["id"], session_id=session["id"], new_value=session,
)
# Matching is deliberately outside the snapshot transaction and can be re-run safely.
ids = execute_query("SELECT id FROM migration_center_session_items WHERE session_id = %s", (session["id"],)) or []
for row in ids:
match_item(int(row["id"]))
refresh_subscription_relevance(int(session["id"]))
return session
def preflight_token(item: Dict[str, Any], current_user: Dict[str, Any]) -> str:
payload = {
"purpose": "migration_center_create",
"item_id": item["id"],
"source_hash": item["source_hash"],
"user_id": user_id(current_user),
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm="HS256")
def verify_preflight(token: str, item: Dict[str, Any], current_user: Dict[str, Any]) -> None:
try:
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=["HS256"])
except jwt.PyJWTError as exc:
raise HTTPException(status_code=409, detail="Preflight er udløbet eller ugyldig") from exc
if (
payload.get("purpose") != "migration_center_create"
or int(payload.get("item_id", 0)) != int(item["id"])
or payload.get("source_hash") != item["source_hash"]
or int(payload.get("user_id", 0)) != int(user_id(current_user) or 0)
):
raise HTTPException(status_code=409, detail="Kildedata eller bruger er ændret siden preflight")
def report_csv(session_id: int) -> str:
session = execute_query_single("SELECT * FROM migration_center_sessions WHERE id = %s", (session_id,))
if not session:
raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
rows = execute_query(
f"""
SELECT entity_type, source_system, source_record_id, invoice_no, customer_no, customer_name,
product_code, product_name, amount, quantity, match_status, approval_status,
hub_status, lock_status, hub_customer_id, hub_sag_id, hub_record_id,
ignore_reason, verified_at, locked_at
FROM migration_center_session_items
WHERE session_id = %s
AND {subscription_like_item_sql()}
ORDER BY id
""",
(session_id,),
) or []
output = io.StringIO()
fields = list(rows[0].keys()) if rows else [
"entity_type", "source_system", "source_record_id", "match_status",
"approval_status", "hub_status", "lock_status",
]
writer = csv.DictWriter(output, fieldnames=fields, extrasaction="ignore")
writer.writeheader()
for row in rows:
writer.writerow({key: json_value(value) for key, value in dict(row).items()})
return output.getvalue()

View File

@ -0,0 +1 @@
"""Migration centre frontend."""

View File

@ -0,0 +1,20 @@
"""Jinja views for the migration centre."""
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.auth_dependencies import require_permission
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/migration-center", response_class=HTMLResponse)
async def migration_center(
request: Request,
current_user: dict = Depends(require_permission("migration_center.view")),
):
return templates.TemplateResponse(
"modules/migration_center/templates/index.html",
{"request": request, "current_user": current_user},
)

View File

@ -0,0 +1,11 @@
{
"name": "migration_center",
"version": "1.0.0",
"description": "Manuel migrering og kontrol af abonnementer og fakturalinjer.",
"author": "BMC Networks",
"enabled": true,
"dependencies": ["sag", "invoice_error_finder"],
"table_prefix": "migration_center_",
"api_prefix": "/api/v1/migration-center",
"tags": ["Migration", "Subscriptions", "Invoices"]
}

View File

@ -0,0 +1,349 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Migreringscenter - BMC Hub{% endblock %}
{% block content %}
<style>
.mc-shell { --mc-navy:#14243a; --mc-blue:#2563eb; --mc-bg:#f4f7fb; color:#172033; }
.mc-hero { background:linear-gradient(120deg,#14243a,#203e65); color:white; border-radius:18px; padding:24px; }
.mc-stat { border:0; border-radius:14px; box-shadow:0 5px 20px rgba(20,36,58,.07); }
.mc-label { font-size:.72rem; text-transform:uppercase; letter-spacing:.07em; color:#728099; font-weight:700; }
.mc-number { font-size:1.65rem; font-weight:750; }
.mc-table-wrap { border-radius:14px; overflow:hidden; box-shadow:0 5px 20px rgba(20,36,58,.07); background:white; }
.mc-table th { white-space:nowrap; font-size:.76rem; text-transform:uppercase; color:#6b778c; background:#f8fafc; }
.mc-table td { vertical-align:middle; }
.mc-product { max-width:300px; }
.mc-badge { border-radius:999px; padding:.34rem .6rem; font-size:.72rem; font-weight:700; display:inline-block; }
.mc-green { background:#dcfce7; color:#166534; } .mc-yellow { background:#fef3c7; color:#92400e; }
.mc-red { background:#fee2e2; color:#991b1b; } .mc-gray { background:#e5e7eb; color:#374151; }
.mc-blue { background:#dbeafe; color:#1e40af; }
.mc-queue { border-left:4px solid var(--mc-blue); }
.mc-source-card { background:#f8fafc; border:1px solid #e5eaf1; border-radius:12px; padding:16px; }
.mc-explanation { margin:0; padding-left:1.1rem; color:#56647a; font-size:.88rem; }
.mc-actions .btn { margin:2px; }
@media(max-width:768px){ .mc-hero{padding:18px}.mc-product{max-width:180px}.mc-actions{min-width:170px} }
</style>
<div class="container-fluid py-4 mc-shell">
<section class="mc-hero mb-4">
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<div>
<div class="text-uppercase small opacity-75 fw-semibold mb-1">Manuel kontrol og overførsel</div>
<h1 class="h3 mb-2">Migreringscenter</h1>
<p class="mb-0 opacity-75">Abonnementer fra CRM og fakturalinjer fra Faktura-fejl-finder. Intet oprettes automatisk.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<select id="sessionSelect" class="form-select" style="min-width:230px" aria-label="Kontrolsession"></select>
<button class="btn btn-light" onclick="createSession()"><i class="bi bi-plus-circle me-1"></i>Ny session</button>
<button class="btn btn-outline-light" onclick="importCrm('vtiger')">Hent Vtiger</button>
<button class="btn btn-outline-light" onclick="importCrm('simply')">Hent Simply</button>
<button class="btn btn-warning" onclick="loadEconomic13Months()">Indlæs 13 mdr. fakturaer</button>
<button class="btn btn-outline-light" onclick="rematchAll()">Match alle igen</button>
<a id="exportLink" class="btn btn-outline-light" href="#"><i class="bi bi-download me-1"></i>CSV</a>
</div>
</div>
<div id="snapshotWarning" class="alert alert-warning mt-3 mb-0 d-none"></div>
</section>
<div id="emptyState" class="card border-0 shadow-sm d-none">
<div class="card-body text-center py-5">
<i class="bi bi-inboxes fs-1 text-muted"></i>
<h2 class="h5 mt-3">Ingen kontrolsession endnu</h2>
<p class="text-muted">Opret en session for at fastlåse seneste e-conomic-grundlag fra Faktura-fejl-finder.</p>
<button class="btn btn-primary" onclick="createSession()">Opret kontrolsession</button>
</div>
</div>
<div id="workspace" class="d-none">
<div class="row g-3 mb-4" id="stats"></div>
<ul class="nav nav-pills mb-3 gap-1" id="mcTabs">
<li class="nav-item"><button class="nav-link active" data-view="all">Dashboard</button></li>
<li class="nav-item"><button class="nav-link" data-view="queue">Arbejdsbakke</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription">Abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_line">Fakturaer</button></li>
<li class="nav-item"><button class="nav-link" data-view="invoice_history">13 mdr. historik</button></li>
<li class="nav-item"><button class="nav-link" data-view="subscription_candidates">Mulige abonnementer</button></li>
<li class="nav-item"><button class="nav-link" data-view="deviations">Afvigelser</button></li>
<li class="nav-item"><button class="nav-link" data-view="locked">Låste</button></li>
<li class="nav-item"><button class="nav-link" data-view="audit">Log / historik</button></li>
</ul>
<section id="queuePanel" class="card mc-stat mc-queue mb-4 d-none">
<div class="card-body" id="queueContent"></div>
</section>
<section id="listPanel">
<div class="card mc-stat mb-3">
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-lg-3"><input id="searchInput" class="form-control" placeholder="Søg produkt, faktura eller kilde-id"></div>
<div class="col-12 col-lg-3"><select id="companyFilter" class="form-select"><option value="">Alle virksomheder</option></select></div>
<div class="col-6 col-lg-2"><select id="sourceFilter" class="form-select"><option value="">Alle kilder</option><option value="vtiger">Vtiger</option><option value="simply">Simply CRM</option><option value="economic">e-conomic</option></select></div>
<div class="col-6 col-lg-1"><select id="statusFilter" class="form-select"><option value="">Status</option><option value="no_match">Intet match</option><option value="manual_review">Manuel kontrol</option><option value="source_changed">Kilde ændret</option><option value="match_found">Match fundet</option></select></div>
<div class="col-6 col-lg-1"><select id="lockFilter" class="form-select"><option value="">Lås</option><option value="unlocked">Ulåst</option><option value="locking_pending">Låser</option><option value="lock_failed">Låsefejl</option><option value="locked">Låst</option></select></div>
<div class="col-12 col-lg-2"><button class="btn btn-primary w-100" onclick="loadItems(1)">Filtrér</button></div>
</div>
</div>
</div>
<div class="mc-table-wrap">
<div class="table-responsive">
<table class="table table-hover mc-table mb-0">
<thead><tr><th>Dato</th><th>Kunde</th><th>Kilde</th><th>Produkt</th><th>Beløb</th><th>Match</th><th>Workflow</th><th>Hub-ID</th><th>Handling</th></tr></thead>
<tbody id="itemsBody"></tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<small class="text-muted" id="pageInfo"></small>
<div class="btn-group"><button class="btn btn-sm btn-outline-secondary" id="prevPage">Forrige</button><button class="btn btn-sm btn-outline-secondary" id="nextPage">Næste</button></div>
</div>
</div>
</section>
<section id="auditPanel" class="mc-table-wrap d-none">
<div class="table-responsive"><table class="table mc-table mb-0"><thead><tr><th>Tidspunkt</th><th>Bruger</th><th>Handling</th><th>Type</th><th>Reference</th><th>Resultat</th></tr></thead><tbody id="auditBody"></tbody></table></div>
</section>
<section id="historyPanel" class="mc-table-wrap d-none">
<div class="p-3 border-bottom d-flex flex-wrap justify-content-between gap-3 align-items-end"><div><h2 class="h5 mb-1" id="historyTitle">Fakturahistorik</h2><p class="text-muted mb-0">Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.</p></div><div style="min-width:300px"><label class="form-label mc-label">Filtrér på virksomhed</label><select id="historyCompanyFilter" class="form-select" onchange="loadInvoiceHistory(view==='subscription_candidates')"><option value="">Alle virksomheder</option></select></div></div>
<div class="table-responsive"><table class="table table-hover mc-table mb-0"><thead><tr><th>Kunde</th><th>Vare</th><th>Fakturaer</th><th>Måneder</th><th>Periode</th><th>Beløb</th><th>Frekvens</th><th>Hub-match</th><th>Handling</th></tr></thead><tbody id="historyBody"></tbody></table></div>
</section>
</div>
</div>
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><h2 class="modal-title h5" id="modalTitle">Post</h2><button class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-footer" id="modalFooter"><button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div>
</div></div>
</div>
<script>
const API='/api/v1/migration-center';
let sessions=[], sessionId=null, page=1, total=0, view='all', currentItem=null, pendingPreflight=null;
const modal=()=>bootstrap.Modal.getOrCreateInstance(document.getElementById('actionModal'));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c]));
async function api(path, options={}){
const res=await fetch(API+path,{headers:{'Content-Type':'application/json',...(options.headers||{})},...options});
if(!res.ok){let d={};try{d=await res.json()}catch{};throw new Error(typeof d.detail==='object'?(d.detail.message||JSON.stringify(d.detail)):(d.detail||`HTTP ${res.status}`))}
return res.headers.get('content-type')?.includes('json')?res.json():res.text();
}
function badge(value){
const green=['created_in_hub','linked_to_existing','verified','approved','locked','match_found'];
const red=['conflict','source_changed','lock_failed','no_match','rejected'];
const gray=['locked'];
return `<span class="mc-badge ${gray.includes(value)?'mc-gray':green.includes(value)?'mc-green':red.includes(value)?'mc-red':'mc-yellow'}">${esc((value||'-').replaceAll('_',' '))}</span>`;
}
async function init(){
sessions=await api('/sessions');
const select=document.getElementById('sessionSelect');
select.innerHTML=sessions.map(s=>`<option value="${s.id}">${esc(s.name)} · ${s.treated_count}/${s.item_count}</option>`).join('');
const remembered=Number(localStorage.getItem('migrationCenterSession'));
sessionId=(sessions.find(s=>s.id===remembered)||sessions[0])?.id||null;
if(!sessionId){document.getElementById('emptyState').classList.remove('d-none');return}
select.value=sessionId; select.onchange=()=>{sessionId=Number(select.value);localStorage.setItem('migrationCenterSession',sessionId);refresh()};
document.getElementById('workspace').classList.remove('d-none');
await refresh();
}
async function createSession(){
const name=prompt('Navn på kontrolsession',`Migrering ${new Date().toLocaleDateString('da-DK',{month:'long',year:'numeric'})}`);
if(!name)return;
try{const s=await api('/sessions',{method:'POST',body:JSON.stringify({name})});localStorage.setItem('migrationCenterSession',s.id);location.reload()}catch(e){alert(e.message)}
}
async function importCrm(source){
if(!sessionId)return alert('Opret en session først');
if(!confirm(`Hent et nyt ${source} snapshot ind i denne kontrolsession? Ændrede poster kræver ny kontrol.`))return;
try{const d=await api(`/sessions/${sessionId}/crm-import?source=${source}`,{method:'POST'});alert(`${d.records} poster hentet · ${d.created} nye · ${d.changed} ændrede`);await refresh()}catch(e){alert(e.message)}
}
async function loadEconomic13Months(){
if(!sessionId)return alert('Opret en session først');
if(!confirm('Indlæs de seneste 13 måneders allerede importerede fakturaer fra Faktura-fejl-finder?'))return;
try{const d=await api(`/sessions/${sessionId}/economic-snapshot`,{method:'POST'});alert(`${d.created} fakturalinjer indlæst · ${d.customers_mapped} kundematches`);await refresh()}catch(e){alert(e.message)}
}
async function rematchAll(){
if(!sessionId)return;
try{const d=await api(`/sessions/${sessionId}/rematch`,{method:'POST'});alert(`${d.processed} poster behandlet · ${d.customers_mapped} virksomheder mappet · ${d.matches_found} abonnementmatches`);await refresh()}catch(e){alert(e.message)}
}
async function refresh(){document.getElementById('exportLink').href=`${API}/sessions/${sessionId}/report.csv`;await Promise.all([loadDashboard(),loadCompanyOptions(),loadItems(1)])}
async function loadCompanyOptions(){
const rows=await api(`/sessions/${sessionId}/company-options`);
for(const id of ['companyFilter','historyCompanyFilter']){
const select=document.getElementById(id), selected=select.value;
select.innerHTML='<option value="">Alle virksomheder</option>'+rows.map(r=>`<option value="${esc(r.customer_name)}">${esc(r.customer_name)} (${r.item_count})</option>`).join('');
if([...select.options].some(o=>o.value===selected))select.value=selected;
}
}
async function loadDashboard(){
const d=await api(`/sessions/${sessionId}/dashboard`), c=d.counts;
const defs=[['Poster',c.total,'bi-stack'],['Klar',c.ready,'bi-check2-square'],['Oprettet',c.created,'bi-cloud-check'],['Afvigelser',c.conflicts,'bi-exclamation-triangle'],['Ignoreret',c.ignored,'bi-slash-circle'],['Låst',c.locked,'bi-lock']];
document.getElementById('stats').innerHTML=defs.map(x=>`<div class="col-6 col-lg-2"><div class="card mc-stat h-100"><div class="card-body"><div class="mc-label"><i class="bi ${x[2]} me-1"></i>${x[0]}</div><div class="mc-number">${x[1]||0}</div></div></div></div>`).join('');
const age=(Date.now()-new Date(d.session.economic_snapshot_at))/86400000;
const warn=document.getElementById('snapshotWarning');
if(d.read_only){warn.classList.remove('d-none');warn.textContent='Migreringscenteret er i read-only tilstand. Historik og rapporter er fortsat tilgængelige.'}
else if(age>d.stale_after_days){warn.classList.remove('d-none');warn.innerHTML=`Fakturagrundlaget er ${Math.floor(age)} dage gammelt. <a href="/invoice-error-finder" class="alert-link">Åbn Faktura-fejl-finder</a> før du opretter en ny session.`}else warn.classList.add('d-none');
}
function query(){
const p=new URLSearchParams({page, page_size:50});
const q=document.getElementById('searchInput').value.trim(); if(q)p.set('q',q);
const source=document.getElementById('sourceFilter').value;if(source)p.set('source_system',source);
const company=document.getElementById('companyFilter').value;if(company)p.set('company_name',company);
const status=document.getElementById('statusFilter').value;if(status)p.set('match_status',status);
const lock=document.getElementById('lockFilter').value;if(lock)p.set('lock_status',lock);
if(['subscription','invoice_line'].includes(view))p.set('entity_type',view);
if(view==='deviations')p.set('only_deviations','true');
if(view==='locked')p.set('lock_status','locked');
return p;
}
async function loadItems(p=1){
page=p;
if(view==='queue'){await loadQueue();return}
if(view==='audit'){await loadAudit();return}
if(['invoice_history','subscription_candidates'].includes(view)){await loadInvoiceHistory(view==='subscription_candidates');return}
const d=await api(`/sessions/${sessionId}/items?${query()}`);total=d.total;
document.getElementById('itemsBody').innerHTML=d.items.length?d.items.map(rowHtml).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen poster matcher filtrene</td></tr>`;
const end=Math.min(page*50,total);document.getElementById('pageInfo').textContent=total?`${(page-1)*50+1}-${end} af ${total}`:'0 poster';
document.getElementById('prevPage').disabled=page===1;document.getElementById('nextPage').disabled=end>=total;
}
async function loadInvoiceHistory(onlyCandidates){
const company=document.getElementById('historyCompanyFilter').value;
const d=await api(`/sessions/${sessionId}/invoice-history?only_subscription_candidates=${onlyCandidates?'true':'false'}${company?`&company_name=${encodeURIComponent(company)}`:''}`);
document.getElementById('historyTitle').textContent=onlyCandidates?'Mulige manglende abonnementer':'Fakturahistorik · seneste 13 måneder';
document.getElementById('historyBody').innerHTML=d.groups.length?d.groups.map(g=>`<tr>
<td><strong>${esc(g.customer_name||'Ukendt')}</strong><br><small class="text-muted">${esc(g.customer_no||'')}</small></td>
<td><strong>${esc(g.product_name)}</strong><br><small class="text-muted">${esc(g.product_code||'')}</small></td>
<td>${g.invoice_count}</td><td>${g.invoiced_months} / 13</td><td class="text-nowrap">${esc(g.first_invoice_date)} ${esc(g.last_invoice_date)}</td>
<td>${Number(g.min_amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}${Number(g.max_amount||0)!==Number(g.min_amount||0)?` ${Number(g.max_amount).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}`:''}</td>
<td>${badge(g.suggested_frequency)}</td><td>${g.has_hub_subscription?badge('match_found'):badge('no_match')}</td>
<td>${g.subscription_candidate?`<button class="btn btn-sm btn-primary" onclick="openItem(${g.representative_item_id})">Vurdér / opret abonnement</button>`:'<span class="text-muted">Kontrol</span>'}</td></tr>`).join(''):`<tr><td colspan="9" class="text-center text-muted py-5">Ingen fakturagrupper fundet</td></tr>`;
}
function rowHtml(i){
const explanations=(i.match_explanation||[]).map(esc).join(' · ');
return `<tr>
<td class="text-nowrap">${esc(i.invoice_date||i.period_from||'-')}</td>
<td><button class="btn btn-link p-0 text-start" onclick="${i.hub_customer_id?`showCustomer(${i.hub_customer_id})`:'void 0'}"><strong>${esc(i.customer_name||'Ukendt')}</strong></button><br><small class="text-muted">${esc(i.customer_no||'')}</small></td>
<td>${badge(i.source_system)}</td><td class="mc-product"><strong>${esc(i.product_name)}</strong><br><small class="text-muted">${esc(i.product_code||i.invoice_no||'')}</small></td>
<td class="text-nowrap">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td>
<td>${badge(i.match_status)}<br><small class="text-muted" title="${esc(explanations)}">${i.match_confidence!=null?Math.round(i.match_confidence*100)+' %':'-'}</small></td>
<td>${badge(i.hub_status)} ${badge(i.approval_status)} ${badge(i.lock_status)}</td>
<td>${i.hub_record_id?`<a href="${i.hub_sag_id?`/sag/${i.hub_sag_id}/v3`:`/subscriptions/${i.hub_record_id}`}">#${i.hub_record_id}</a>`:'-'}</td>
<td class="mc-actions"><button class="btn btn-sm btn-outline-primary" onclick="openItem(${i.id})">Vis / behandl</button></td></tr>`;
}
function filterCustomer(name){document.getElementById('searchInput').value=name;loadItems(1)}
async function showCustomer(customerId){
try{
const d=await api(`/sessions/${sessionId}/customers/${customerId}`);
document.getElementById('modalTitle').textContent=`Kundeoverblik · ${d.customer.name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3 mb-3"><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">CVR</div>${esc(d.customer.cvr_number||'-')}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Migreringsposter</div>${d.items.length}</div></div><div class="col-md-4"><div class="mc-source-card"><div class="mc-label">Hub-abonnementer</div>${d.hub_subscriptions.length}</div></div></div>
<h3 class="h6">Afvigelser og behandling</h3><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Kilde</th><th>Produkt</th><th>Match</th><th>Workflow</th></tr></thead><tbody>${d.items.map(i=>`<tr><td>${esc(i.source_system)}</td><td>${esc(i.product_name)}</td><td>${badge(i.match_status)}</td><td>${badge(i.approval_status)} ${badge(i.lock_status)}</td></tr>`).join('')}</tbody></table></div>
<h3 class="h6 mt-3">Eksisterende poster i Hubben</h3><ul>${d.hub_subscriptions.map(s=>`<li>#${s.id} · ${esc(s.product_name||s.subscription_number)} · ${esc(s.status)}</li>`).join('')||'<li>Ingen</li>'}</ul>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-dark" ${d.can_lock_all?'':'disabled'} onclick="lockCustomer(${customerId})">Lås alt for kunde</button>`;
modal().show();
}catch(e){alert(e.message)}
}
async function lockCustomer(customerId){if(confirm('Lås alle færdigbehandlede poster for kunden?'))await mutate(`/sessions/${sessionId}/customers/${customerId}/lock`)}
async function loadQueue(after){
const d=await api(`/sessions/${sessionId}/queue/next${after?`?after_id=${after}`:''}`);
const panel=document.getElementById('queueContent');
if(!d.item){panel.innerHTML='<div class="text-center py-4"><i class="bi bi-check-circle text-success fs-2"></i><h3 class="h5 mt-2">Arbejdsbakken er tom</h3></div>';return}
const i=d.item; currentItem=i;
panel.innerHTML=`<div class="d-flex justify-content-between align-items-start mb-3"><div><div class="mc-label">Næste manuelle beslutning</div><h2 class="h5 mb-0">${esc(i.customer_name||'Ukendt kunde')}</h2></div>${badge(i.match_status)}</div>
<div class="row g-3"><div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kilde · ${esc(i.source_system)}</div><strong>${esc(i.product_name)}</strong><div>${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</div><small>${esc(i.source_record_id)}</small></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Foreslået Hub-match</div><strong>${i.suggested_hub_record_id?'Abonnement #'+i.suggested_hub_record_id:'Intet sikkert match'}</strong><ul class="mc-explanation mt-2">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul></div></div></div>
<div class="mt-3 d-flex gap-2 flex-wrap"><button class="btn btn-primary" onclick="openItem(${i.id})">Behandl</button><button class="btn btn-outline-secondary" onclick="loadQueue(${i.id})">Spring over</button></div>`;
}
async function loadAudit(){
const rows=await api(`/sessions/${sessionId}/audit`);
document.getElementById('auditBody').innerHTML=rows.map(a=>`<tr><td>${new Date(a.performed_at).toLocaleString('da-DK')}</td><td>${esc(a.performed_by||'System')}</td><td>${esc(a.action)}</td><td>${esc(a.entity_type)}</td><td>${esc(a.entity_id||'')}</td><td>${a.success?badge('verified'):badge('conflict')}</td></tr>`).join('');
}
async function openItem(id){
let context=await api(`/items/${id}/context`).catch(()=>null), i=context?.item;
if(!i)return alert('Posten kunne ikke hentes');
currentItem=i;
document.getElementById('modalTitle').textContent=`${i.customer_name||'Ukendt kunde'} · ${i.product_name}`;
document.getElementById('modalBody').innerHTML=`<div class="row g-3">
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Kildedata</div><dl class="row small mb-0"><dt class="col-5">Kilde</dt><dd class="col-7">${esc(i.source_system)}</dd><dt class="col-5">Reference</dt><dd class="col-7 text-break">${esc(i.source_record_id)}</dd><dt class="col-5">Kunde</dt><dd class="col-7">${esc(i.customer_name||'-')}</dd><dt class="col-5">Produkt</dt><dd class="col-7">${esc(i.product_name)}</dd><dt class="col-5">Beløb</dt><dd class="col-7">${Number(i.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</dd></dl></div></div>
<div class="col-md-6"><div class="mc-source-card h-100"><div class="mc-label mb-2">Matchforklaring</div><ul class="mc-explanation">${(i.match_explanation||[]).map(x=>`<li>${esc(x)}</li>`).join('')}</ul><div class="mt-3">${badge(i.match_status)} ${badge(i.hub_status)} ${badge(i.lock_status)}</div></div></div></div>
<div class="col-12"><details><summary class="small fw-semibold">Vis alle rå felter fra den valgte kilde</summary><pre class="small bg-dark text-light rounded p-3 mt-2 overflow-auto" style="max-height:280px">${esc(JSON.stringify(i.source_payload||{},null,2))}</pre></details></div>
<div class="col-12"><hr><div class="d-flex justify-content-between align-items-center mb-2"><h3 class="h5 mb-0">Samlet kundegrundlag</h3><small class="text-muted">e-conomic ${context.counts.economic} · Vtiger ${context.counts.vtiger} · Simply ${context.counts.simply} · Hub ${context.counts.hub_subscriptions}</small></div>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#ctxEconomic">e-conomic (${context.counts.economic})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxVtiger">Vtiger (${context.counts.vtiger})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxSimply">Simply (${context.counts.simply})</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#ctxHub">Hubben (${context.counts.hub_subscriptions})</button></li>
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3">
<div class="tab-pane fade show active" id="ctxEconomic">${sourceContextTable(context.sources.economic,'Fakturadato / nr.')}</div>
<div class="tab-pane fade" id="ctxVtiger">${sourceContextTable(context.sources.vtiger,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxSimply">${sourceContextTable(context.sources.simply,'Start / slut')}</div>
<div class="tab-pane fade" id="ctxHub">${hubContext(context.hub)}</div>
</div>
</div>
<hr><div class="row g-2"><div class="col-md-4"><label class="form-label">Hub-kunde ID</label><div class="input-group"><input id="hubCustomerId" class="form-control" type="number" value="${i.hub_customer_id||''}"><button class="btn btn-outline-primary" onclick="linkCustomer()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCustomer()">Opret kunde eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Hub-sag ID</label><div class="input-group"><input id="hubCaseId" class="form-control" type="number" value="${i.hub_sag_id||''}"><button class="btn btn-outline-primary" onclick="linkCase()">Link</button></div><button class="btn btn-link px-0 btn-sm" onclick="createCase()">Opret sag eksplicit</button></div>
<div class="col-md-4"><label class="form-label">Eksisterende abonnement ID</label><div class="input-group"><input id="hubSubscriptionId" class="form-control" type="number" value="${i.suggested_hub_record_id||''}"><button class="btn btn-outline-primary" onclick="linkSubscription()">Link</button></div></div></div>
<div class="mt-3"><label class="form-label">Manuel note</label><textarea id="itemNote" class="form-control" rows="2">${esc(i.manual_note||'')}</textarea><button class="btn btn-sm btn-outline-secondary mt-2" onclick="saveNote()">Gem note</button></div>`;
const disabled=['locked','locking_pending'].includes(i.lock_status)?'disabled':'';
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button><button class="btn btn-outline-warning" ${disabled} onclick="ignoreItem()">Ignorér</button><button class="btn btn-outline-success" ${disabled} onclick="verifyItem()">Markér kontrolleret</button><button class="btn btn-outline-dark" ${disabled} onclick="lockItem()">Lås</button><button class="btn btn-primary" ${disabled} onclick="preflightCreate()">Opret i Hubben</button>`;
modal().show();
}
function sourceContextTable(rows,dateLabel){
if(!rows?.length)return '<div class="text-muted py-3">Ingen relevante poster fra denne kilde for kunden.</div>';
return `<div class="table-responsive" style="max-height:420px"><table class="table table-sm align-middle"><thead class="sticky-top bg-white"><tr><th>${dateLabel}</th><th>Vare</th><th>Beløb</th><th>Status</th><th></th></tr></thead><tbody>${rows.map(r=>`<tr class="${r.id===currentItem.id?'table-primary':''}"><td>${esc(r.invoice_date||r.period_from||'-')}<br><small>${esc(r.invoice_no||r.source_record_id)}</small></td><td><strong>${esc(r.product_name)}</strong><br><small>${esc(r.product_code||'')}</small><details><summary class="small text-muted">Alle kildefelter</summary><pre class="small bg-light p-2 mt-1" style="max-width:520px;max-height:220px;overflow:auto">${esc(JSON.stringify(r.source_payload||{},null,2))}</pre></details></td><td class="text-nowrap">${Number(r.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</td><td>${badge(r.hub_status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="openItem(${r.id})">Brug som grundlag</button></td></tr>`).join('')}</tbody></table></div>`;
}
function hubContext(hub){
const customer=hub.customer;
return `${customer?`<div class="mc-source-card mb-3"><div class="mc-label">Hub-kunde #${customer.id}</div><strong>${esc(customer.name)}</strong><div class="small">CVR ${esc(customer.cvr_number||'-')} · e-conomic ${esc(customer.economic_customer_number||'-')} · Vtiger ${esc(customer.vtiger_id||'-')}</div></div>`:'<div class="alert alert-warning">Kilden er endnu ikke linket til en Hub-kunde.</div>'}
<h4 class="h6">Eksisterende abonnementer</h4>${hub.subscriptions?.length?`<div class="table-responsive"><table class="table table-sm"><thead><tr><th>ID</th><th>Produkt</th><th>Pris/frekvens</th><th>Status</th><th></th></tr></thead><tbody>${hub.subscriptions.map(s=>`<tr><td>#${s.id}</td><td><strong>${esc(s.product_name||s.subscription_number)}</strong>${(s.lines||[]).map(l=>`<br><small>${esc(l.description)} · ${l.quantity} × ${Number(l.unit_price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</small>`).join('')}</td><td>${Number(s.price||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})} / ${esc(s.billing_interval)}</td><td>${badge(s.status)}</td><td><button class="btn btn-sm btn-outline-primary" onclick="document.getElementById('hubSubscriptionId').value=${s.id}">Vælg til link</button></td></tr>`).join('')}</tbody></table></div>`:'<p class="text-muted">Ingen abonnementer i Hubben.</p>'}
<h4 class="h6 mt-3">Sager</h4><div class="d-flex flex-wrap gap-2">${(hub.cases||[]).map(s=>`<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('hubCaseId').value=${s.id}">#${s.id} ${esc(s.titel)}</button>`).join('')||'<span class="text-muted">Ingen sager</span>'}</div>`;
}
async function mutate(path,body){try{await api(path,{method:'POST',body:body?JSON.stringify(body):undefined});modal().hide();await refresh()}catch(e){alert(e.message)}}
async function linkCustomer(){const id=Number(document.getElementById('hubCustomerId').value);if(id)await mutate(`/items/${currentItem.id}/customer-link`,{hub_id:id})}
async function linkCase(){const id=Number(document.getElementById('hubCaseId').value);if(id)await mutate(`/items/${currentItem.id}/case-link`,{hub_id:id})}
async function linkSubscription(){const id=Number(document.getElementById('hubSubscriptionId').value);if(id)await mutate(`/items/${currentItem.id}/hub-link`,{hub_id:id})}
async function createCustomer(){const name=prompt('Kundenavn',currentItem.customer_name||'');if(!name)return;const cvr=prompt('CVR (valgfri)','');if(!confirm(`Opret kunden "${name}" i Hubben?`))return;await mutate(`/items/${currentItem.id}/customers`,{name,cvr_number:cvr||null,customer_no:currentItem.customer_no||null})}
async function createCase(){const title=prompt('Sagens titel',`Migrering · ${currentItem.product_name}`);if(!title||!confirm(`Opret sagen "${title}"?`))return;await mutate(`/items/${currentItem.id}/cases`,{title,description:`Manuelt oprettet fra migreringscenter, kilde ${currentItem.source_record_id}`})}
async function saveNote(){try{await api(`/items/${currentItem.id}/note`,{method:'PUT',body:JSON.stringify({note:document.getElementById('itemNote').value})});alert('Noten er gemt')}catch(e){alert(e.message)}}
async function ignoreItem(){const reason=prompt('Årsag til ignorering');if(reason)await mutate(`/items/${currentItem.id}/ignore`,{reason})}
async function verifyItem(){if(confirm('Markér posten som kontrolleret?'))await mutate(`/items/${currentItem.id}/verify`)}
async function lockItem(){if(confirm('Lås posten endeligt? Den kan ikke låses op i denne version.'))await mutate(`/items/${currentItem.id}/lock`)}
async function preflightCreate(){
try{
const p=await api(`/items/${currentItem.id}/preflight`);
if(!p.ready)return alert(`Oprettelsen kan ikke gennemføres:\n\n${p.blockers.join('\n')}`);
pendingPreflight=p;
const v=p.preview, frequencyLabels={daily:'Dagligt',biweekly:'Hver 14. dag',monthly:'Månedligt',quarterly:'Kvartalsvist',yearly:'Årligt'};
document.getElementById('modalTitle').textContent='Bekræft oprettelse i Hubben';
document.getElementById('modalBody').innerHTML=`<div class="alert alert-primary"><i class="bi bi-shield-check me-2"></i>Der oprettes kun dette ene kladde-abonnement. Intet andet overføres.</div>
<div class="mc-source-card"><div class="mc-label mb-3">Data der sendes til Hubben</div><dl class="row mb-0">
<dt class="col-sm-4">Kunde</dt><dd class="col-sm-8"><strong>${esc(v.customer||'-')}</strong>${v.customer_no?`<br><small class="text-muted">Kundenr. ${esc(v.customer_no)}</small>`:''}</dd>
<dt class="col-sm-4">Hub-kunde</dt><dd class="col-sm-8">#${esc(v.hub_customer_id)}</dd>
<dt class="col-sm-4">Hub-sag</dt><dd class="col-sm-8">#${esc(v.hub_sag_id)}</dd>
<dt class="col-sm-4">Produkt</dt><dd class="col-sm-8"><strong>${esc(v.product)}</strong>${v.product_code?`<br><small class="text-muted">${esc(v.product_code)}</small>`:''}</dd>
<dt class="col-sm-4">Beløb</dt><dd class="col-sm-8"><strong>${Number(v.amount||0).toLocaleString('da-DK',{style:'currency',currency:'DKK'})}</strong></dd>
<dt class="col-sm-4">Antal</dt><dd class="col-sm-8">${esc(v.quantity||1)}</dd>
<dt class="col-sm-4">Fakturering</dt><dd class="col-sm-8">${esc(frequencyLabels[v.frequency]||v.frequency)}</dd>
<dt class="col-sm-4">Periode</dt><dd class="col-sm-8">${esc(v.start_date||'-')} ${esc(v.end_date||'løbende')}</dd>
<dt class="col-sm-4">Kilde</dt><dd class="col-sm-8">${esc(v.source)} · ${esc(v.source_record_id)}</dd>
</dl></div>
<p class="small text-muted mt-3 mb-0">Abonnementet oprettes som kladde med én varelinje og skal fortsat kontrolleres og låses separat.</p>`;
document.getElementById('modalFooter').innerHTML=`<button class="btn btn-outline-secondary" onclick="openItem(${currentItem.id})">Tilbage</button><button class="btn btn-primary" onclick="confirmCreateInHub(this)"><i class="bi bi-check-circle me-1"></i>Bekræft oprettelse</button>`;
}catch(e){alert(e.message)}
}
async function confirmCreateInHub(button){
if(!pendingPreflight)return;
if(button){button.disabled=true;button.innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Opretter…'}
try{
await api(`/items/${currentItem.id}/create-in-hub`,{method:'POST',body:JSON.stringify({preflight_token:pendingPreflight.token,idempotency_key:crypto.randomUUID()})});
pendingPreflight=null;modal().hide();await refresh();
}catch(e){if(button)button.disabled=false;alert(e.message)}
}
document.querySelectorAll('#mcTabs .nav-link').forEach(btn=>btn.onclick=async()=>{
document.querySelectorAll('#mcTabs .nav-link').forEach(x=>x.classList.remove('active'));btn.classList.add('active');view=btn.dataset.view;
document.getElementById('queuePanel').classList.toggle('d-none',view!=='queue');
document.getElementById('auditPanel').classList.toggle('d-none',view!=='audit');
document.getElementById('historyPanel').classList.toggle('d-none',!['invoice_history','subscription_candidates'].includes(view));
document.getElementById('listPanel').classList.toggle('d-none',['queue','audit','invoice_history','subscription_candidates'].includes(view));
await loadItems(1);
});
document.getElementById('prevPage').onclick=()=>loadItems(page-1);document.getElementById('nextPage').onclick=()=>loadItems(page+1);
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')loadItems(1)});
init().catch(e=>{document.getElementById('emptyState').classList.remove('d-none');document.getElementById('emptyState').querySelector('.card-body').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`});
</script>
{% endblock %}

View File

@ -10,11 +10,14 @@ from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert from app.core.database import execute_query, execute_insert
from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service from app.services.reminder_notification_service import reminder_notification_service
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
# ============================================================================ # ============================================================================
@ -47,9 +50,10 @@ def _get_user_id_from_request(request: Request) -> int:
class UserNotificationPreferences(BaseModel): class UserNotificationPreferences(BaseModel):
"""User notification preferences""" """User notification preferences"""
notify_mattermost: bool = True notify_mattermost: bool = True
notify_email: bool = False notify_email: bool = True
notify_frontend: bool = True notify_frontend: bool = True
email_override: Optional[str] = None email_override: Optional[str] = None
mattermost_username: Optional[str] = None
quiet_hours_enabled: bool = False quiet_hours_enabled: bool = False
quiet_hours_start: Optional[str] = None # HH:MM format quiet_hours_start: Optional[str] = None # HH:MM format
quiet_hours_end: Optional[str] = None # HH:MM format quiet_hours_end: Optional[str] = None # HH:MM format
@ -154,7 +158,8 @@ async def get_user_notification_preferences(request: Request):
query = """ query = """
SELECT SELECT
notify_mattermost, notify_email, notify_frontend, notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end email_override, mattermost_username,
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
FROM user_notification_preferences FROM user_notification_preferences
WHERE user_id = %s WHERE user_id = %s
""" """
@ -165,9 +170,10 @@ async def get_user_notification_preferences(request: Request):
r = result[0] r = result[0]
return UserNotificationPreferences( return UserNotificationPreferences(
notify_mattermost=r.get('notify_mattermost', True), notify_mattermost=r.get('notify_mattermost', True),
notify_email=r.get('notify_email', False), notify_email=r.get('notify_email', True),
notify_frontend=r.get('notify_frontend', True), notify_frontend=r.get('notify_frontend', True),
email_override=r.get('email_override'), email_override=r.get('email_override'),
mattermost_username=r.get('mattermost_username'),
quiet_hours_enabled=r.get('quiet_hours_enabled', False), quiet_hours_enabled=r.get('quiet_hours_enabled', False),
quiet_hours_start=r.get('quiet_hours_start'), quiet_hours_start=r.get('quiet_hours_start'),
quiet_hours_end=r.get('quiet_hours_end') quiet_hours_end=r.get('quiet_hours_end')
@ -198,6 +204,7 @@ async def update_user_notification_preferences(
notify_email = %s, notify_email = %s,
notify_frontend = %s, notify_frontend = %s,
email_override = %s, email_override = %s,
mattermost_username = %s,
quiet_hours_enabled = %s, quiet_hours_enabled = %s,
quiet_hours_start = %s, quiet_hours_start = %s,
quiet_hours_end = %s, quiet_hours_end = %s,
@ -211,6 +218,7 @@ async def update_user_notification_preferences(
preferences.notify_email, preferences.notify_email,
preferences.notify_frontend, preferences.notify_frontend,
preferences.email_override, preferences.email_override,
preferences.mattermost_username,
preferences.quiet_hours_enabled, preferences.quiet_hours_enabled,
preferences.quiet_hours_start, preferences.quiet_hours_start,
preferences.quiet_hours_end, preferences.quiet_hours_end,
@ -221,9 +229,10 @@ async def update_user_notification_preferences(
query = """ query = """
INSERT INTO user_notification_preferences ( INSERT INTO user_notification_preferences (
user_id, notify_mattermost, notify_email, notify_frontend, user_id, notify_mattermost, notify_email, notify_frontend,
email_override, quiet_hours_enabled, quiet_hours_start, quiet_hours_end email_override, mattermost_username,
quiet_hours_enabled, quiet_hours_start, quiet_hours_end
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""" """
@ -233,6 +242,7 @@ async def update_user_notification_preferences(
preferences.notify_email, preferences.notify_email,
preferences.notify_frontend, preferences.notify_frontend,
preferences.email_override, preferences.email_override,
preferences.mattermost_username,
preferences.quiet_hours_enabled, preferences.quiet_hours_enabled,
preferences.quiet_hours_start, preferences.quiet_hours_start,
preferences.quiet_hours_end preferences.quiet_hours_end
@ -250,7 +260,11 @@ async def update_user_notification_preferences(
# Reminder CRUD Endpoints # Reminder CRUD Endpoints
# ============================================================================ # ============================================================================
@router.get("/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse]) @router.get(
"/api/v1/sag/{sag_id}/reminders",
response_model=List[ReminderResponse],
dependencies=[Depends(case_read_access)],
)
async def list_sag_reminders(sag_id: int): async def list_sag_reminders(sag_id: int):
"""List all reminders for a case""" """List all reminders for a case"""
@ -323,7 +337,11 @@ async def list_my_reminders(request: Request):
] ]
@router.post("/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse) @router.post(
"/api/v1/sag/{sag_id}/reminders",
response_model=ReminderResponse,
dependencies=[Depends(case_edit_access)],
)
async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate): async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate):
"""Create a new reminder for a case""" """Create a new reminder for a case"""
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
@ -418,9 +436,10 @@ async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderC
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.patch("/api/v1/sag/reminders/{reminder_id}") @router.patch("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate): async def update_sag_reminder(reminder_id: int, update: ReminderUpdate, request: Request):
"""Update a reminder""" """Update a reminder"""
user_id = _get_user_id_from_request(request)
# Build update query dynamically # Build update query dynamically
updates = [] updates = []
@ -472,10 +491,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
query = f""" query = f"""
UPDATE sag_reminders UPDATE sag_reminders
SET {', '.join(updates)} SET {', '.join(updates)}
WHERE id = %s WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
RETURNING id RETURNING id
""" """
params.append(user_id)
result = execute_insert(query, tuple(params)) result = execute_insert(query, tuple(params))
if not result: if not result:
raise HTTPException(status_code=404, detail="Reminder not found") raise HTTPException(status_code=404, detail="Reminder not found")
@ -483,30 +502,35 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
logger.info(f"✅ Reminder {reminder_id} updated") logger.info(f"✅ Reminder {reminder_id} updated")
return {"success": True, "message": "Reminder updated"} return {"success": True, "message": "Reminder updated"}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Error updating reminder: {e}") logger.error(f"❌ Error updating reminder: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/sag/reminders/{reminder_id}") @router.delete("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
async def delete_sag_reminder(reminder_id: int): async def delete_sag_reminder(reminder_id: int, request: Request):
"""Soft-delete a reminder""" """Soft-delete a reminder"""
user_id = _get_user_id_from_request(request)
try: try:
query = """ query = """
UPDATE sag_reminders UPDATE sag_reminders
SET deleted_at = CURRENT_TIMESTAMP, is_active = false SET deleted_at = CURRENT_TIMESTAMP, is_active = false
WHERE id = %s WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
RETURNING id RETURNING id
""" """
result = execute_insert(query, (reminder_id,)) result = execute_insert(query, (reminder_id, user_id))
if not result: if not result:
raise HTTPException(status_code=404, detail="Reminder not found") raise HTTPException(status_code=404, detail="Reminder not found")
logger.info(f"✅ Reminder {reminder_id} deleted") logger.info(f"✅ Reminder {reminder_id} deleted")
return {"success": True, "message": "Reminder deleted"} return {"success": True, "message": "Reminder deleted"}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Error deleting reminder: {e}") logger.error(f"❌ Error deleting reminder: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))

View File

@ -11,13 +11,14 @@ from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict from typing import List, Optional, Dict
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection from app.core.database import execute_query, execute_query_single, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.services.email_service import EmailService from app.services.email_service import EmailService
from app.services.case_analysis_service import CaseAnalysisService from app.services.case_analysis_service import CaseAnalysisService
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
@ -31,7 +32,38 @@ import email
from email.header import decode_header from email.header import decode_header
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() case_read_access = require_any_permission("cases.view", "tickets.view")
case_create_access = require_any_permission("cases.create", "tickets.create")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
case_delete_access = require_any_permission("cases.delete", "tickets.delete")
async def case_route_access(request: Request, current_user: dict = Depends(get_current_user)) -> dict:
"""Apply read/create/edit/delete access consistently to every case route."""
if current_user.get("is_superadmin"):
return current_user
method = request.method.upper()
normalized_path = request.url.path.rstrip("/")
if method in {"GET", "HEAD", "OPTIONS"}:
required = {"cases.view", "tickets.view"}
elif method == "POST" and normalized_path == "/api/v1/sag":
required = {"cases.create", "tickets.create"}
elif method == "DELETE" and re.fullmatch(r"/api/v1/sag/\d+", normalized_path):
required = {"cases.delete", "tickets.delete"}
else:
required = {"cases.edit", "tickets.edit"}
available = set(current_user.get("permissions") or [])
if available.intersection(required):
return current_user
raise HTTPException(
status_code=403,
detail=f"Missing required permission. Need one of: {', '.join(sorted(required))}",
)
router = APIRouter(dependencies=[Depends(case_route_access)])
def _table_exists(table_name: str) -> bool: def _table_exists(table_name: str) -> bool:
@ -142,7 +174,7 @@ def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Opti
try: try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is not None: if parsed.tzinfo is not None:
parsed = parsed.replace(tzinfo=None) parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.strftime("%Y-%m-%d %H:%M:%S") return parsed.strftime("%Y-%m-%d %H:%M:%S")
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}") raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
@ -208,6 +240,38 @@ def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
return ", ".join(cleaned) return ", ".join(cleaned)
def _normalize_relation_input(sag_id: int, data: dict) -> tuple[int, str]:
try:
target_id = int(data.get("målsag_id"))
except (AttributeError, TypeError, ValueError):
raise HTTPException(status_code=400, detail="målsag_id must be an integer")
raw_relation_type = str(data.get("relationstype") or "").strip()
relation_aliases = {
"relateret til": "Relateret til",
"relateret_til": "Relateret til",
"afledt af": "Afledt af",
"afledt_af": "Afledt af",
"årsag til": "Årsag til",
"årsag_til": "Årsag til",
"blokkerer": "Blokkerer",
"afhænger af": "afhænger af",
"afhænger_af": "afhænger af",
"undersag": "undersag",
"duplikat": "duplikat",
"forælder": "forælder",
"barn": "barn",
"udfører for": "udfører_for",
"udfører_for": "udfører_for",
}
relation_type = relation_aliases.get(raw_relation_type.casefold())
if not relation_type:
raise HTTPException(status_code=400, detail="Invalid relationstype")
if sag_id == target_id:
raise HTTPException(status_code=400, detail="A case cannot be related to itself")
return target_id, relation_type
def _deferred_status_matches( def _deferred_status_matches(
deferred_until_status: Optional[str], deferred_until_status: Optional[str],
previous_status: Optional[str], previous_status: Optional[str],
@ -926,8 +990,8 @@ async def list_all_sale_items(
logger.error("❌ Error listing sale items: %s", e) logger.error("❌ Error listing sale items: %s", e)
raise HTTPException(status_code=500, detail="Failed to list sale items") raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.post("/sag") @router.post("/sag", dependencies=[Depends(case_create_access)])
async def create_sag(data: dict): async def create_sag(request: Request, data: dict):
"""Create a case and its optional pipeline/order data atomically.""" """Create a case and its optional pipeline/order data atomically."""
try: try:
if not data.get("titel"): if not data.get("titel"):
@ -1025,7 +1089,7 @@ async def create_sag(data: dict):
RETURNING * RETURNING *
""", """,
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id, (data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"), assigned_group_id, _get_user_id_from_request(request), deadline, deferred_until, data.get("deferred_until_case_id"),
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]), data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
) )
result = cursor.fetchone() result = cursor.fetchone()
@ -1471,7 +1535,7 @@ async def delete_todo_step(step_id: int):
logger.error("❌ Error deleting todo step: %s", e) logger.error("❌ Error deleting todo step: %s", e)
raise HTTPException(status_code=500, detail="Failed to delete todo step") raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}") @router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
async def update_sag(sag_id: int, updates: dict = Body(...)): async def update_sag(sag_id: int, updates: dict = Body(...)):
"""Update a case.""" """Update a case."""
try: try:
@ -1680,7 +1744,7 @@ class BeskrivelsePatch(BaseModel):
beskrivelse: str beskrivelse: str
@router.patch("/sag/{sag_id}/beskrivelse") @router.patch("/sag/{sag_id}/beskrivelse", dependencies=[Depends(case_edit_access)])
async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request): async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request):
"""Update case description and store a change history entry.""" """Update case description and store a change history entry."""
try: try:
@ -1759,7 +1823,7 @@ class PipelineUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
@router.patch("/sag/{sag_id}/pipeline") @router.patch("/sag/{sag_id}/pipeline", dependencies=[Depends(case_edit_access)])
async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate): async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
"""Update pipeline fields for a case.""" """Update pipeline fields for a case."""
try: try:
@ -1817,7 +1881,7 @@ async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate):
logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e) logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to update pipeline") raise HTTPException(status_code=500, detail="Failed to update pipeline")
@router.delete("/sag/{sag_id:int}") @router.delete("/sag/{sag_id:int}", dependencies=[Depends(case_delete_access)])
async def delete_sag(sag_id: int): async def delete_sag(sag_id: int):
"""Soft-delete a case.""" """Soft-delete a case."""
try: try:
@ -1870,15 +1934,14 @@ async def get_relationer(sag_id: int):
logger.error("❌ Error getting relations: %s", e) logger.error("❌ Error getting relations: %s", e)
raise HTTPException(status_code=500, detail="Failed to get relations") raise HTTPException(status_code=500, detail="Failed to get relations")
@router.post("/sag/{sag_id}/relationer") @router.post("/sag/{sag_id}/relationer", dependencies=[Depends(case_edit_access)])
async def create_relation(sag_id: int, data: dict): async def create_relation(sag_id: int, data: dict):
"""Add a relation to another case.""" """Add a relation to another case."""
try: try:
if not data.get("målsag_id") or not data.get("relationstype"): if not data.get("målsag_id") or not data.get("relationstype"):
raise HTTPException(status_code=400, detail="målsag_id and relationstype required") raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
målsag_id = data.get("målsag_id") målsag_id, relationstype = _normalize_relation_input(sag_id, data)
relationstype = data.get("relationstype")
# Validate both cases exist # Validate both cases exist
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
@ -1886,6 +1949,20 @@ async def create_relation(sag_id: int, data: dict):
if not check1 or not check2: if not check1 or not check2:
raise HTTPException(status_code=404, detail="One or both cases not found") raise HTTPException(status_code=404, detail="One or both cases not found")
duplicate = execute_query(
"""
SELECT id
FROM sag_relationer
WHERE kilde_sag_id = %s
AND målsag_id = %s
AND relationstype = %s
AND deleted_at IS NULL
""",
(sag_id, målsag_id, relationstype),
)
if duplicate:
raise HTTPException(status_code=409, detail="Relation already exists")
query = """ query = """
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
@ -1904,7 +1981,7 @@ async def create_relation(sag_id: int, data: dict):
logger.error("❌ Error creating relation: %s", e) logger.error("❌ Error creating relation: %s", e)
raise HTTPException(status_code=500, detail="Failed to create relation") raise HTTPException(status_code=500, detail="Failed to create relation")
@router.delete("/sag/{sag_id}/relationer/{relation_id}") @router.delete("/sag/{sag_id}/relationer/{relation_id}", dependencies=[Depends(case_edit_access)])
async def delete_relation(sag_id: int, relation_id: int): async def delete_relation(sag_id: int, relation_id: int):
"""Soft-delete a relation.""" """Soft-delete a relation."""
try: try:
@ -1958,7 +2035,7 @@ async def get_tags(sag_id: int):
logger.error("❌ Error getting tags: %s", e) logger.error("❌ Error getting tags: %s", e)
raise HTTPException(status_code=500, detail="Failed to get tags") raise HTTPException(status_code=500, detail="Failed to get tags")
@router.post("/sag/{sag_id}/tags") @router.post("/sag/{sag_id}/tags", dependencies=[Depends(case_edit_access)])
async def add_tag(sag_id: int, data: dict): async def add_tag(sag_id: int, data: dict):
"""Add a tag to a case.""" """Add a tag to a case."""
try: try:
@ -1986,7 +2063,7 @@ async def add_tag(sag_id: int, data: dict):
logger.error("❌ Error adding tag: %s", e) logger.error("❌ Error adding tag: %s", e)
raise HTTPException(status_code=500, detail="Failed to add tag") raise HTTPException(status_code=500, detail="Failed to add tag")
@router.delete("/sag/{sag_id}/tags/{tag_id}") @router.delete("/sag/{sag_id}/tags/{tag_id}", dependencies=[Depends(case_edit_access)])
async def delete_tag(sag_id: int, tag_id: int): async def delete_tag(sag_id: int, tag_id: int):
"""Soft-delete a tag.""" """Soft-delete a tag."""
try: try:
@ -4407,15 +4484,20 @@ def _generate_stored_name(filename: str, subdir: str) -> str:
return f"{subdir}/{unique}" return f"{subdir}/{unique}"
def _resolve_attachment_path(stored_name: str) -> Path: def _resolve_attachment_path(stored_name: str) -> Path:
return UPLOAD_BASE_PATH / stored_name candidate = (UPLOAD_BASE_PATH / str(stored_name or "")).resolve()
try:
candidate.relative_to(UPLOAD_BASE_PATH)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid attachment path") from exc
return candidate
def _store_upload_file(upload_file: UploadFile, subdir: str): def _store_upload_file(upload_file: UploadFile, subdir: str):
if not upload_file.filename: if not upload_file.filename:
raise HTTPException(400, detail="Filename missing") raise HTTPException(400, detail="Filename missing")
ext = Path(upload_file.filename).suffix.lower().lstrip(".") ext = Path(upload_file.filename).suffix.lower().lstrip(".")
# Basic check - allow more types for generic files? if not ext or ext not in ALLOWED_EXTENSIONS:
# if ext not in ALLOWED_EXTENSIONS: ... raise HTTPException(status_code=400, detail=f"File type .{ext or '?'} is not allowed")
upload_file.file.seek(0, os.SEEK_END) upload_file.file.seek(0, os.SEEK_END)
size = upload_file.file.tell() size = upload_file.file.tell()
@ -4462,7 +4544,7 @@ async def list_sag_files(sag_id: int):
logger.error("❌ Error listing files: %s", e) logger.error("❌ Error listing files: %s", e)
raise HTTPException(status_code=500, detail="Failed to list files") raise HTTPException(status_code=500, detail="Failed to list files")
@router.post("/sag/{sag_id}/files") @router.post("/sag/{sag_id}/files", dependencies=[Depends(case_edit_access)])
async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)): async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
"""Upload files to a case.""" """Upload files to a case."""
if not _table_exists("sag_files"): if not _table_exists("sag_files"):
@ -4473,6 +4555,7 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
raise HTTPException(status_code=404, detail="Case not found") raise HTTPException(status_code=404, detail="Case not found")
saved_files = [] saved_files = []
errors = []
for file in files: for file in files:
try: try:
@ -4488,12 +4571,17 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
saved = result[0] saved = result[0]
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}" saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
saved_files.append(saved) saved_files.append(saved)
except HTTPException: except HTTPException as exc:
continue # Skip invalid errors.append({"filename": file.filename, "detail": exc.detail})
except Exception as e: except Exception as e:
logger.error(f"Error saving file {file.filename}: {e}") logger.error(f"Error saving file {file.filename}: {e}")
continue errors.append({"filename": file.filename, "detail": "Server upload failed"})
if errors:
raise HTTPException(
status_code=400,
detail={"message": "One or more files could not be uploaded", "files": errors, "saved": saved_files},
)
return saved_files return saved_files
@router.get("/sag/{sag_id}/files/{file_id}") @router.get("/sag/{sag_id}/files/{file_id}")
@ -4582,7 +4670,7 @@ async def preview_sag_pdf_as_image(sag_id: int, file_id: int, page: int = Query(
logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e) logger.error("❌ PDF preview render failed for SAG-%s file %s: %s", sag_id, file_id, e)
raise HTTPException(status_code=500, detail="Could not render PDF preview") raise HTTPException(status_code=500, detail="Could not render PDF preview")
@router.delete("/sag/{sag_id}/files/{file_id}") @router.delete("/sag/{sag_id}/files/{file_id}", dependencies=[Depends(case_edit_access)])
async def delete_sag_file(sag_id: int, file_id: int): async def delete_sag_file(sag_id: int, file_id: int):
"""Delete a file.""" """Delete a file."""
if not _table_exists("sag_files"): if not _table_exists("sag_files"):

View File

@ -1,12 +1,14 @@
import logging import logging
from fastapi import APIRouter, HTTPException, Depends from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional from typing import Optional
from app.core.database import execute_query from app.core.database import execute_query
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
from app.core.auth_dependencies import require_any_permission
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution]) @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int): async def get_solution(sag_id: int):
@ -21,8 +23,12 @@ async def get_solution(sag_id: int):
logger.error("❌ Error getting solution for case %s: %s", sag_id, e) logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution") raise HTTPException(status_code=500, detail="Failed to get solution")
@router.post("/sag/{sag_id}/solution", response_model=Solution) @router.post(
async def create_solution(sag_id: int, solution: SolutionCreate): "/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case.""" """Create a solution for a case."""
try: try:
# Check if case exists # Check if case exists
@ -47,7 +53,7 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
solution.description, solution.description,
solution.solution_type, solution.solution_type,
solution.result, solution.result,
solution.created_by_user_id getattr(request.state, "user_id", None)
) )
result = execute_query(query, params) result = execute_query(query, params)
@ -61,7 +67,11 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
logger.error("❌ Error creating solution: %s", e) logger.error("❌ Error creating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to create solution") raise HTTPException(status_code=500, detail="Failed to create solution")
@router.patch("/sag/{sag_id}/solution", response_model=Solution) @router.patch(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def update_solution(sag_id: int, updates: SolutionUpdate): async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution.""" """Update a solution."""
try: try:

View File

@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from pathlib import Path from pathlib import Path
from app.core.database import execute_query from app.core.database import execute_query
from app.utils.safe_html import sanitize_safe_html
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -76,6 +77,7 @@ def _is_deadline_overdue(deadline_value) -> bool:
# Setup template directory # Setup template directory
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
templates.env.filters["safe_case_html"] = sanitize_safe_html
def _fetch_assignment_users(): def _fetch_assignment_users():

View File

@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""Reversibel end-to-end test af Sag-modulets HTTP API.
Eksempel:
BMC_TEST_PASSWORD='...' python app/modules/sag/scripts/sag_module_e2e.py \
--username admin --customer-id 1
Alternativt kan et eksisterende JWT angives via BMC_TEST_TOKEN eller --token.
Testen opretter to tydeligt navngivne testsager og sletter dem igen i finally.
"""
from __future__ import annotations
import argparse
import getpass
import json
import os
import sys
import time
import uuid
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
@dataclass
class Result:
name: str
status: str
detail: str = ""
duration_ms: int = 0
class ApiError(RuntimeError):
def __init__(self, method: str, path: str, status: int, body: Any):
super().__init__(f"{method} {path} -> HTTP {status}: {body}")
self.status = status
self.body = body
class SagE2E:
def __init__(self, args: argparse.Namespace):
self.args = args
self.base = args.base_url.rstrip("/")
self.token = args.token or os.getenv("BMC_TEST_TOKEN")
self.results: list[Result] = []
self.created_cases: list[int] = []
self.cleanup: list[tuple[str, Callable[[], Any]]] = []
self.run_id = f"{datetime.now():%Y%m%d-%H%M%S}-{uuid.uuid4().hex[:6]}"
self.marker = f"SAG-E2E-{self.run_id}"
def request(
self,
method: str,
path: str,
data: Any = None,
*,
expected: tuple[int, ...] = (200,),
headers: dict[str, str] | None = None,
raw: bool = False,
) -> Any:
request_headers = {"Accept": "application/json"}
if self.token:
request_headers["Authorization"] = f"Bearer {self.token}"
if headers:
request_headers.update(headers)
body = data
if data is not None and not isinstance(data, bytes):
body = json.dumps(data).encode()
request_headers["Content-Type"] = "application/json"
req = Request(self.base + path, data=body, headers=request_headers, method=method)
try:
with urlopen(req, timeout=self.args.timeout) as response:
payload = response.read()
if response.status not in expected:
raise ApiError(method, path, response.status, payload.decode(errors="replace"))
if raw:
return payload
if not payload:
return None
content_type = response.headers.get("Content-Type", "")
return json.loads(payload) if "json" in content_type else payload.decode(errors="replace")
except HTTPError as exc:
payload = exc.read().decode(errors="replace")
try:
payload = json.loads(payload)
except json.JSONDecodeError:
pass
if exc.code in expected:
return payload
raise ApiError(method, path, exc.code, payload) from exc
except URLError as exc:
raise RuntimeError(f"Kan ikke forbinde til {self.base}: {exc.reason}") from exc
def check(self, name: str, operation: Callable[[], Any]) -> Any:
started = time.monotonic()
try:
value = operation()
elapsed = int((time.monotonic() - started) * 1000)
self.results.append(Result(name, "PASS", duration_ms=elapsed))
print(f"PASS {name} ({elapsed} ms)")
return value
except Exception as exc:
elapsed = int((time.monotonic() - started) * 1000)
detail = str(exc).replace("\n", " ")[:800]
self.results.append(Result(name, "FAIL", detail, elapsed))
print(f"FAIL {name}: {detail}")
return None
def skip(self, name: str, reason: str) -> None:
self.results.append(Result(name, "SKIP", reason))
print(f"SKIP {name}: {reason}")
def authenticate(self) -> dict[str, Any]:
if not self.token:
if not self.args.username:
raise RuntimeError("Angiv --token/BMC_TEST_TOKEN eller --username.")
password = self.args.password or os.getenv("BMC_TEST_PASSWORD")
if not password and sys.stdin.isatty():
password = getpass.getpass("Adgangskode: ")
if not password:
raise RuntimeError("Angiv --password eller BMC_TEST_PASSWORD.")
payload = {"username": self.args.username, "password": password}
if self.args.otp:
payload["otp_code"] = self.args.otp
response = self.request("POST", "/api/v1/auth/login", payload)
self.token = response.get("access_token")
if not self.token:
raise RuntimeError("Login returnerede intet access_token.")
me = self.request("GET", "/api/v1/auth/me")
print(f"Bruger: {me.get('full_name') or me.get('username') or me.get('user_id')}")
return me
def resolve_customer_id(self) -> int:
if self.args.customer_id:
return self.args.customer_id
customers = self.request("GET", "/api/v1/customers?limit=1&is_active=true")
rows = customers.get("customers", []) if isinstance(customers, dict) else customers
if not rows:
raise RuntimeError("Ingen aktiv kunde fundet. Angiv --customer-id.")
return int(rows[0]["id"])
@staticmethod
def require(value: Any, message: str) -> Any:
if not value:
raise AssertionError(message)
return value
def create_case(self, customer_id: int, suffix: str) -> dict[str, Any]:
case = self.request(
"POST",
"/api/v1/sag",
{
"titel": f"[{self.marker}] {suffix}",
"beskrivelse": "Automatisk testsag. Må slettes.",
"customer_id": customer_id,
"type": "ticket",
"status": "åben",
},
expected=(200, 201),
)
case_id = int(case["id"])
self.created_cases.append(case_id)
return case
def multipart_file(
self, field: str, filename: str, content: bytes, media_type: str
) -> tuple[bytes, str]:
boundary = f"----SagE2E{uuid.uuid4().hex}"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
f"Content-Type: {media_type}\r\n\r\n"
).encode() + content + f"\r\n--{boundary}--\r\n".encode()
return body, f"multipart/form-data; boundary={boundary}"
def run(self) -> None:
self.check("Serverens health endpoint", lambda: self.request("GET", "/health"))
if self.check("Login og aktuel bruger", self.authenticate) is None:
return
customer_id = self.check("Find testkunde", self.resolve_customer_id)
if customer_id is None:
return
parent = self.check("Opret hovedsag", lambda: self.create_case(customer_id, "Hovedsag"))
child = self.check("Opret undersag", lambda: self.create_case(customer_id, "Undersag"))
if not parent or not child:
return
parent_id, child_id = int(parent["id"]), int(child["id"])
self.check("Hent sag", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}").get("id") == parent_id,
"Forkert sag returneret",
))
self.check("Sag vises i sagslisten", lambda: self.require(
any(int(row["id"]) == parent_id for row in self.request(
"GET", f"/api/v1/sag?search={self.marker}"
)),
"Testsagen blev ikke fundet i listen",
))
self.check("Redigér sag", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}", {
"titel": f"[{self.marker}] Hovedsag redigeret",
"status": "under behandling",
"deadline": (datetime.now(timezone.utc) + timedelta(days=14)).isoformat(),
}).get("id") == parent_id,
"Sag blev ikke opdateret",
))
self.check("Gem beskrivelse", lambda: self.request(
"PATCH", f"/api/v1/sag/{parent_id}/beskrivelse",
{"beskrivelse": "<p>Sikker <strong>HTML</strong></p><script>nope()</script>"},
))
self.check("Beskrivelseshistorik", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/beskrivelse/history"),
"Historikken er tom",
))
self.check("Hent modulindstillinger", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/modules"
))
comment = self.check("Opret kommentar", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/kommentarer",
{"indhold": f"Kommentar {self.marker}", "er_intern": True},
expected=(200, 201),
))
self.check("Læs kommentarer", lambda: self.require(
any(self.marker in str(row.get("indhold", "")) for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/kommentarer"
)),
"Kommentaren blev ikke fundet",
))
self.check("Læs tidslinje", lambda: self.request(
"GET", f"/api/v1/sag/{parent_id}/timeline"
))
todo = self.check("Opret todo", lambda: self.request(
"POST", f"/api/v1/sag/{child_id}/todo-steps",
{"title": f"Næste handling {self.marker}", "description": "E2E", "due_date": None},
expected=(200, 201),
))
if todo:
todo_id = int(todo["id"])
self.check("Markér todo som næste", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_next": True}).get("is_next"),
"Todo er ikke markeret som næste",
))
self.check("Afslut todo", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/todo-steps/{todo_id}", {"is_done": True}).get("is_done"),
"Todo er ikke afsluttet",
))
self.check("Slet todo", lambda: self.request(
"DELETE", f"/api/v1/sag/todo-steps/{todo_id}"
))
relation = self.check("Opret logisk undersag-relation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(200, 201),
))
if relation:
relation_id = int(relation["id"])
self.check("Læs relationer", lambda: self.require(
any(int(row["id"]) == relation_id for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/relationer"
)),
"Relationen blev ikke fundet",
))
self.check("Afvis dubletrelation", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/relationer",
{"målsag_id": child_id, "relationstype": "undersag"},
expected=(409,),
))
self.check("Slet relation", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/relationer/{relation_id}"
))
tag = self.check("Opret tag", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/tags", {"tag_navn": self.marker},
expected=(200, 201),
))
if tag:
self.check("Læs tags", lambda: self.require(
any(row.get("tag_navn") == self.marker for row in self.request(
"GET", f"/api/v1/sag/{parent_id}/tags"
)), "Tag blev ikke fundet",
))
self.check("Slet tag", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/tags/{tag['id']}"
))
buzz = self.check("Opret buzzword", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/buzzwords",
{"buzzword": self.marker.lower()}, expected=(200, 201),
))
if buzz:
self.check("Global søgning finder buzzword", lambda: self.require(
any(int(row.get("id", -1)) == parent_id for row in self.request(
"GET", "/api/v1/search/sag?" + urlencode({"q": self.marker.lower()})
)), "Global søgning fandt ikke sagen via buzzword",
))
self.check("Slet buzzword-link", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/buzzwords/{buzz['buzzword_id']}"
))
item = self.check("Opret salgs-/ordrelinje", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/sale-items",
{"type": "sale", "description": self.marker, "quantity": 1,
"unit_price": 125, "amount": 125, "currency": "DKK", "status": "draft"},
expected=(200, 201),
))
if item:
item_id = int(item["id"])
self.check("Redigér salgs-/ordrelinje", lambda: self.require(
self.request("PATCH", f"/api/v1/sag/{parent_id}/sale-items/{item_id}",
{"status": "confirmed", "amount": 150}).get("status") == "confirmed",
"Linjen blev ikke bekræftet",
))
self.check("Slet salgs-/ordrelinje", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/sale-items/{item_id}"
))
# Minimal gyldig 1x1 PNG; PNG er blandt Sag-modulets tilladte filtyper.
file_content = bytes.fromhex(
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c489"
"0000000d49444154789c6360000000020001e221bc330000000049454e44ae426082"
)
body, content_type = self.multipart_file(
"files", f"{self.marker}.png", file_content, "image/png"
)
uploaded = self.check("Upload fil", lambda: self.request(
"POST", f"/api/v1/sag/{parent_id}/files", body,
headers={"Content-Type": content_type}, expected=(200, 201),
))
if uploaded:
file_id = int(uploaded[0]["id"])
self.check("Download fil og kontrollér indhold", lambda: self.require(
self.request("GET", f"/api/v1/sag/{parent_id}/files/{file_id}", raw=True) == file_content,
"Downloadet fil har forkert indhold",
))
self.check("Slet fil", lambda: self.request(
"DELETE", f"/api/v1/sag/{parent_id}/files/{file_id}"
))
for name, reason in (
("Mailafsendelse og mail-link", "kræver en rigtig mailkonto/mail-ID"),
("AnyDesk-forbindelse", "kræver installeret klient og gyldigt AnyDesk-ID"),
("Direkte labelprint", "kræver fysisk printer"),
("Arbejdsseddel og underskrift", "kræver aktiv work-order/token"),
("Faktura-/økonomisynkronisering", "må ikke skrive i eksternt økonomisystem fra E2E"),
):
self.skip(name, reason)
def cleanup_all(self) -> None:
if self.args.keep_data:
print("Beholder testdata (--keep-data).")
return
for case_id in reversed(self.created_cases):
try:
self.request("DELETE", f"/api/v1/sag/{case_id}", expected=(200, 204, 404))
print(f"CLEAN Sag #{case_id}")
except Exception as exc:
self.results.append(Result(f"Oprydning af sag #{case_id}", "FAIL", str(exc)))
print(f"FAIL Oprydning af sag #{case_id}: {exc}")
def write_report(self) -> None:
counts = {status: sum(r.status == status for r in self.results)
for status in ("PASS", "FAIL", "SKIP")}
report = {
"run_id": self.run_id,
"base_url": self.base,
"created_at": datetime.now(timezone.utc).isoformat(),
"summary": counts,
"results": [asdict(result) for result in self.results],
}
if self.args.json_report:
path = Path(self.args.json_report)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"Rapport: {path.resolve()}")
print(f"\nResultat: {counts['PASS']} PASS, {counts['FAIL']} FAIL, {counts['SKIP']} SKIP")
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Reversibel E2E-test af BMC Hub Sag-modulet")
parser.add_argument("--base-url", default=os.getenv("BMC_BASE_URL", "http://127.0.0.1:8001"))
parser.add_argument("--token", help="JWT; alternativt BMC_TEST_TOKEN")
parser.add_argument("--username", help="Loginbrugernavn")
parser.add_argument("--password", help="Frarådes i shellhistorik; brug BMC_TEST_PASSWORD")
parser.add_argument("--otp", help="2FA-kode hvis påkrævet")
parser.add_argument("--customer-id", type=int, help="Kunde til midlertidige testsager")
parser.add_argument("--timeout", type=float, default=20.0)
parser.add_argument("--keep-data", action="store_true", help="Behold testsager ved fejlsøgning")
parser.add_argument("--json-report", default="test-results/sag-e2e.json")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
suite = SagE2E(parse_args(argv))
print(f"Sag E2E {suite.run_id} mod {suite.base}")
try:
suite.run()
except KeyboardInterrupt:
print("\nAfbrudt.")
except Exception as exc:
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
print(f"FAIL Testkørsel: {exc}")
finally:
suite.cleanup_all()
suite.write_report()
return 1 if any(result.status == "FAIL" for result in suite.results) else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -40,13 +40,27 @@ class RelationService:
placeholders = ','.join(['%s'] * len(tree_ids)) placeholders = ','.join(['%s'] * len(tree_ids))
tree_cases_query = f""" tree_cases_query = f"""
SELECT SELECT
id, s.id,
titel, s.titel,
status, s.status,
template_key, s.template_key,
COALESCE(template_key, 'ticket') AS type COALESCE(s.template_key, 'ticket') AS type,
FROM sag_sager next_todo.title AS next_todo_title,
WHERE id IN ({placeholders}) next_todo.due_date AS next_todo_due_date
FROM sag_sager s
LEFT JOIN LATERAL (
SELECT t.title, t.due_date
FROM sag_todo_steps t
WHERE t.sag_id = s.id
AND t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
ORDER BY
COALESCE(t.is_next, FALSE) DESC,
t.due_date ASC NULLS LAST,
t.id ASC
LIMIT 1
) next_todo ON TRUE
WHERE s.id IN ({placeholders})
""" """
tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))} tree_cases = {c['id']: c for c in execute_query(tree_cases_query, tuple(tree_ids))}
@ -73,6 +87,10 @@ class RelationService:
return m, k # m is parent of k return m, k # m is parent of k
if rtype_lower in ['årsag til', 'cause of']: if rtype_lower in ['årsag til', 'cause of']:
return k, m # k is parent of m return k, m # k is parent of m
if rtype_lower in ['afhænger af', 'depends on']:
return m, k # dependency is parent of the dependent case
if rtype_lower in ['undersag', 'subcase']:
return k, m # source is the parent case
# Default: k is "related" to m, treat as child for visualization if k is current root context # Default: k is "related" to m, treat as child for visualization if k is current root context
# But here we build a directed graph. # But here we build a directed graph.
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A # If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A

View File

@ -1203,7 +1203,6 @@
customer_id: selectedCustomer ? selectedCustomer.id : null, customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null, assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
created_by_user_id: 1, // HARDCODED for now, should come from auth
deadline: document.getElementById('deadline').value || null deadline: document.getElementById('deadline').value || null
}; };

View File

@ -3859,7 +3859,7 @@
<div class="card-body"> <div class="card-body">
<!-- View mode --> <!-- View mode -->
<div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()"> <div id="beskrivelse-view" class="narrative-description" style="min-height: 120px; cursor: pointer;" ondblclick="startBeskrivelsEdit()">
<div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ case.beskrivelse or '' }}</div> <div id="beskrivelse-text" class="prose" style="white-space: pre-wrap;">{{ (case.beskrivelse or '')|safe_case_html|safe }}</div>
{% if not case.beskrivelse %} {% if not case.beskrivelse %}
<div id="beskrivelse-empty" class="text-center p-3"> <div id="beskrivelse-empty" class="text-center p-3">
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p> <p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
@ -4130,7 +4130,7 @@
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
data-bs-placement="right" data-bs-placement="right"
title="<strong>Hvad betyder relationstyper?</strong><br><br><strong>Relateret til</strong>: Faglig kobling uden direkte afhængighed.<br><strong>Afledt af</strong>: Denne sag er opstået på baggrund af en anden sag.<br><strong>Årsag til</strong>: Denne sag er årsagen til en anden sag.<br><strong>Blokkerer</strong>: Arbejde i en sag stopper fremdrift i den anden."></i> title="<strong>Vælg det, der beskriver forholdet bedst</strong><br><br><strong>Relateret</strong>: Samme emne uden afhængighed.<br><strong>Undersag</strong>: En mindre del af denne sag.<br><strong>Afhænger af</strong>: Denne sag kan ikke fortsætte før den anden.<br><strong>Blokerer</strong>: Den anden sag kan ikke fortsætte før denne.<br><strong>Duplikat</strong>: Samme problem er registreret to gange."></i>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()"> <button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
@ -4179,6 +4179,12 @@
<div class="relation-type-subtext">(Årsag til)</div> <div class="relation-type-subtext">(Årsag til)</div>
{% elif rel_key == 'blokkerer' %} {% elif rel_key == 'blokkerer' %}
<span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span> <span class="relation-type-pill is-block" title="Denne sag blokerer den anden, indtil den er løst.">Blokerer</span>
{% elif rel_key == 'undersag' %}
<span class="relation-type-pill is-derived" title="Denne sag er en mindre del af den overordnede sag.">Undersag</span>
{% elif rel_key == 'afhænger af' %}
<span class="relation-type-pill is-cause" title="Denne sag kan ikke fortsætte før den anden sag er klar.">Afhænger af</span>
{% elif rel_key == 'duplikat' %}
<span class="relation-type-pill is-related" title="Sagerne beskriver det samme problem.">Duplikat</span>
{% else %} {% else %}
<span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span> <span class="relation-type-pill is-related" title="Sagerne er fagligt koblet uden direkte afhængighed.">Koblet til</span>
<div class="relation-type-subtext">(Relateret til)</div> <div class="relation-type-subtext">(Relateret til)</div>
@ -4187,6 +4193,23 @@
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i> <i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
{% endif %} {% endif %}
</td> </td>
<td>
{% if node.case.next_todo_title %}
<div class="small fw-semibold">
<i class="bi bi-arrow-right-circle-fill text-warning me-1"></i>
{{ node.case.next_todo_title }}
</div>
{% if node.case.next_todo_due_date %}
<div class="small text-muted">
Frist {{ node.case.next_todo_due_date.strftime('%d.%m.%Y') }}
</div>
{% endif %}
{% elif not node.is_current %}
<span class="small text-muted">Ingen åben todo</span>
{% else %}
<span class="small text-muted"></span>
{% endif %}
</td>
<td class="text-end"> <td class="text-end">
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
{% if node.relation_id %} {% if node.relation_id %}
@ -4221,6 +4244,7 @@
<th style="width: 120px;">Status</th> <th style="width: 120px;">Status</th>
<th style="width: 130px;">Type</th> <th style="width: 130px;">Type</th>
<th style="width: 180px;">Sammenhæng</th> <th style="width: 180px;">Sammenhæng</th>
<th style="min-width: 220px;">Næste todo</th>
<th class="text-end" style="width: 130px;">Handling</th> <th class="text-end" style="width: 130px;">Handling</th>
</tr> </tr>
</thead> </thead>
@ -4352,13 +4376,14 @@
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">2. Vælg relationstype</label> <label class="form-label fw-bold">2. Hvordan forholder den valgte sag sig til denne sag?</label>
<select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();"> <select id="relationTypeSelect" class="form-control form-control-lg" onchange="updateAddRelationButton(); updateRelationTypeHint();">
<option value="">Vælg hvordan sagerne er relateret...</option> <option value="">Vælg forholdet mellem sagerne...</option>
<option value="Relateret til">🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed</option> <option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
<option value="Afledt af">↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden</option> <option value="undersag">↳ Den valgte sag er en undersag til denne</option>
<option value="Årsag til">➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden</option> <option value="afhænger af">⏳ Denne sag afhænger af den valgte</option>
<option value="Blokkerer">⛔ Blokerer - Den anden kan ikke videre før denne er løst</option> <option value="Blokkerer">⛔ Denne sag blokerer den valgte</option>
<option value="duplikat">⧉ Sagerne beskriver det samme problem</option>
</select> </select>
</div> </div>
@ -4366,10 +4391,7 @@
<div class="alert alert-light border small mb-3"> <div class="alert alert-light border small mb-3">
<div class="fw-semibold mb-1">Betydning i praksis</div> <div class="fw-semibold mb-1">Betydning i praksis</div>
<div><strong>Koblet til (Relateret)</strong>: Faglig sammenhæng, men ingen direkte afhængighed.</div> <div>Teksten i valget beskriver retningen direkte. Du skal altså ikke selv afkode “kilde” og “mål”.</div>
<div><strong>Kommer fra (Afledt af)</strong>: Sagen er opstået pga. en anden sag.</div>
<div><strong>Skaber følge-sag (Årsag til)</strong>: Sagen skaber behovet for en anden sag.</div>
<div><strong>Blokkerer</strong>: Bruges når løsning i én sag er nødvendig før den anden kan videre.</div>
</div> </div>
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;"> <div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
@ -5309,6 +5331,18 @@
'Blokkerer': { 'Blokkerer': {
icon: '⛔', icon: '⛔',
text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.' text: 'Arbejdet i denne sag stopper fremdrift i den anden sag, indtil blokeringen er løst.'
},
'undersag': {
icon: '↳',
text: 'Den valgte sag er en mindre del af denne sag.'
},
'afhænger af': {
icon: '⏳',
text: 'Denne sag kan ikke fortsætte, før den valgte sag er klar.'
},
'duplikat': {
icon: '⧉',
text: 'Sagerne beskriver det samme problem og bør normalt samles.'
} }
}; };
return map[type] || null; return map[type] || null;
@ -5336,20 +5370,24 @@
if (!select || !hint) return; if (!select || !hint) return;
const selected = select.value; const selected = select.value;
if (selected === 'Afledt af') { if (selected === 'undersag') {
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som kommer fra den nye sag.'; hint.innerHTML = '<strong>↳ Resultat:</strong> Den nye sag bliver en undersag til denne sag.';
return; return;
} }
if (selected === 'Årsag til') { if (selected === 'afhænger af') {
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som at den skaber den nye følge-sag.'; hint.innerHTML = '<strong>⏳ Resultat:</strong> Denne sag markeres som afhængig af den nye sag.';
return; return;
} }
if (selected === 'Blokkerer') { if (selected === 'Blokkerer') {
hint.innerHTML = '<strong>⛔ Effekt:</strong> Nuværende sag markeres som blokering for den nye sag.'; hint.innerHTML = '<strong>⛔ Resultat:</strong> Denne sag markeres som blokering for den nye sag.';
return;
}
if (selected === 'duplikat') {
hint.innerHTML = '<strong>⧉ Resultat:</strong> Den nye sag markeres som et duplikat af denne sag.';
return; return;
} }
hint.innerHTML = '<strong>🔗 Effekt:</strong> Sagerne kobles fagligt uden direkte afhængighed (Koblet til).'; hint.innerHTML = '<strong>🔗 Resultat:</strong> Sagerne kobles uden direkte afhængighed.';
} }
async function createRelatedCase() { async function createRelatedCase() {
@ -5766,6 +5804,74 @@
return div.innerHTML; return div.innerHTML;
} }
function sanitizeCaseEmailHtml(unsafeHtml) {
const input = String(unsafeHtml || '').trim();
if (!input) return '';
const allowedTags = new Set([
'a', 'b', 'strong', 'i', 'em', 'u', 's', 'br', 'p', 'div', 'span',
'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'hr',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'table', 'thead', 'tbody', 'tr', 'th', 'td'
]);
const allowedAttrs = {
a: new Set(['href', 'title', 'target', 'rel']),
th: new Set(['colspan', 'rowspan']),
td: new Set(['colspan', 'rowspan'])
};
const safeUrl = (value) => {
const normalized = String(value || '').trim().toLowerCase();
return normalized.startsWith('http://')
|| normalized.startsWith('https://')
|| normalized.startsWith('mailto:')
|| normalized.startsWith('tel:')
|| normalized.startsWith('/');
};
const doc = new DOMParser().parseFromString(input, 'text/html');
const cleanNode = (node) => {
if (node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent || '');
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return document.createTextNode('');
}
const tag = node.tagName.toLowerCase();
if (!allowedTags.has(tag)) {
const fragment = document.createDocumentFragment();
Array.from(node.childNodes).forEach((child) => fragment.appendChild(cleanNode(child)));
return fragment;
}
const element = document.createElement(tag);
const tagAttrs = allowedAttrs[tag] || new Set();
Array.from(node.attributes).forEach((attr) => {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (!tagAttrs.has(name)) return;
if (tag === 'a' && name === 'href') {
if (!safeUrl(value)) return;
element.setAttribute('href', value);
element.setAttribute('target', '_blank');
element.setAttribute('rel', 'noopener noreferrer');
return;
}
if (name === 'colspan' || name === 'rowspan') {
const number = Number(value);
if (!Number.isInteger(number) || number < 1 || number > 100) return;
element.setAttribute(name, String(number));
}
});
Array.from(node.childNodes).forEach((child) => element.appendChild(cleanNode(child)));
return element;
};
const wrapper = document.createElement('div');
Array.from(doc.body.childNodes).forEach((child) => wrapper.appendChild(cleanNode(child)));
return wrapper.innerHTML;
}
function selectRelationCase(caseIdValue, caseTitel, customerName, status) { function selectRelationCase(caseIdValue, caseTitel, customerName, status) {
selectedRelationCaseId = caseIdValue; selectedRelationCaseId = caseIdValue;
@ -11722,8 +11828,8 @@
<div class="list-group-item"> <div class="list-group-item">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div class="me-3"> <div class="me-3">
<div class="fw-bold">${reminder.title}</div> <div class="fw-bold">${escapeHtml(reminder.title)}</div>
<div class="text-muted small">${reminder.message || '-'} </div> <div class="text-muted small">${escapeHtml(reminder.message || '-')} </div>
<div class="small text-muted mt-1"> <div class="small text-muted mt-1">
Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type} Type: ${eventTypeLabels[reminder.event_type] || reminder.event_type || 'Reminder'} · Trigger: ${triggerLabels[reminder.trigger_type] || reminder.trigger_type} · Gentagelse: ${recurrenceLabels[reminder.recurrence_type] || reminder.recurrence_type}
</div> </div>
@ -12729,7 +12835,6 @@
solution_type: document.getElementById('sol_type').value, solution_type: document.getElementById('sol_type').value,
result: document.getElementById('sol_result').value, result: document.getElementById('sol_result').value,
description: document.getElementById('sol_desc').value, description: document.getElementById('sol_desc').value,
created_by_user_id: 1 // TODO: Get from auth
}; };
const addTime = document.getElementById('sol_add_time')?.checked; const addTime = document.getElementById('sol_add_time')?.checked;
const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0; const timeHours = parseInt(document.getElementById('sol_time_hours').value) || 0;
@ -13245,21 +13350,19 @@
<input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på..."> <input type="text" class="form-control" id="newCaseTitle" required placeholder="F.eks. Opfølgning på...">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Relationstype *</label> <label class="form-label">Hvordan forholder den nye sag sig til denne sag? *</label>
<select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()"> <select class="form-select" id="newCaseRelationType" onchange="updateNewCaseRelationTypeHint()">
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option> <option value="undersag">↳ Den nye sag er en undersag til denne</option>
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option> <option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option> <option value="afhænger af">⏳ Denne sag afhænger af den nye</option>
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option> <option value="Blokkerer">⛔ Denne sag blokerer den nye</option>
<option value="duplikat">⧉ Den nye sag er et duplikat af denne</option>
</select> </select>
</div> </div>
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div> <div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
<div class="alert alert-light border small"> <div class="alert alert-light border small">
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div> <div class="fw-semibold mb-1">Vælg ud fra sætningen</div>
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</div> <div>Hvert valg beskriver den nye sag i forhold til den sag, du står på nu.</div>
<div><strong>Afledt af</strong>: Den nye sag opstår fordi den nuværende sag findes.</div>
<div><strong>Årsag til</strong>: Den nuværende sag opstår fordi den nye sag findes.</div>
<div><strong>Blokkerer</strong>: Løsning i én sag er nødvendig før den anden kan afsluttes.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Beskrivelse</label> <label class="form-label">Beskrivelse</label>
@ -15041,7 +15144,9 @@
const sourceToken = String(f.source_token || '').toUpperCase(); const sourceToken = String(f.source_token || '').toUpperCase();
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-'); const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
const isScannerFile = sourceType === 'scanner_email'; const isScannerFile = sourceType === 'scanner_email';
const displayName = isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename; const displayName = escapeHtml(isWorkOrder ? `Arbejdsseddel: ${f.filename}` : f.filename);
const fileId = Number(f.id);
const downloadUrl = escapeHtml(String(f.download_url || ''));
const badgeHtml = isWorkOrder const badgeHtml = isWorkOrder
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>' ? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
: (isScannerFile : (isScannerFile
@ -15051,7 +15156,7 @@
<div class="list-group-item d-flex justify-content-between align-items-center"> <div class="list-group-item d-flex justify-content-between align-items-center">
<div class="ms-2 me-auto"> <div class="ms-2 me-auto">
<div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;"> <div class="fw-bold text-truncate d-flex align-items-center" style="max-width: 380px;">
<a href="javascript:void(0);" onclick="previewFile(${f.id}, '${f.filename.replace(/'/g, "\\'")}', '${f.content_type || ''}')" class="text-decoration-none text-dark"> <a href="javascript:void(0);" onclick="previewFileById(${fileId})" class="text-decoration-none text-dark">
<i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName} <i class="bi ${isWorkOrder ? 'bi-clipboard-check' : 'bi-file-earmark'} me-1"></i> ${displayName}
</a> </a>
${badgeHtml} ${badgeHtml}
@ -15059,10 +15164,10 @@
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small> <small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
</div> </div>
<div class="d-flex gap-1"> <div class="d-flex gap-1">
<a href="${f.download_url}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download"> <a href="${downloadUrl}?download=true" class="btn btn-sm btn-outline-primary border-0" title="Download">
<i class="bi bi-download"></i> <i class="bi bi-download"></i>
</a> </a>
<button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${f.id})" title="Slet"> <button class="btn btn-sm btn-outline-danger border-0" onclick="deleteFile(${fileId})" title="Slet">
<i class="bi bi-x-lg"></i> <i class="bi bi-x-lg"></i>
</button> </button>
</div> </div>
@ -15153,6 +15258,15 @@
} }
// File Preview // File Preview
function previewFileById(fileId) {
const file = sagFilesCache.find((item) => Number(item.id) === Number(fileId));
if (!file) {
alert('Filen kunne ikke findes.');
return;
}
previewFile(Number(file.id), String(file.filename || ''), String(file.content_type || ''));
}
function previewFile(fileId, filename, contentType) { function previewFile(fileId, filename, contentType) {
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal')); const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
const previewContent = document.getElementById('previewContent'); const previewContent = document.getElementById('previewContent');
@ -16122,7 +16236,7 @@
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-'; const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
const attachments = Array.isArray(email.attachments) ? email.attachments : []; const attachments = Array.isArray(email.attachments) ? email.attachments : [];
const bodyText = email.body_text || ''; const bodyText = email.body_text || '';
const bodyHtml = email.body_html || ''; const bodyHtml = sanitizeCaseEmailHtml(email.body_html || '');
selectedLinkedEmailDetail = email; selectedLinkedEmailDetail = email;
panel.innerHTML = ` panel.innerHTML = `
@ -16148,7 +16262,7 @@
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div> <div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
</div> </div>
<div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;"> <div class="p-3 overflow-auto" style="max-height: 45vh; white-space: normal;">
${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml ? bodyHtml : '<div class="text-muted">Ingen indhold</div>')} ${bodyText ? `<pre class="mb-0" style="white-space: pre-wrap; font-family: inherit;">${escapeHtml(bodyText)}</pre>` : (bodyHtml || '<div class="text-muted">Ingen indhold</div>')}
</div> </div>
`; `;
@ -18497,20 +18611,27 @@
const saveBtn = getRelQaPrimaryButton(); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; } if (saveBtn) { saveBtn.disabled = true; }
try { try {
const r = await fetch(`/api/v1/sag/${caseId}/todos`, { const r = await fetch(`/api/v1/sag/${caseId}/todo-steps`, {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify({ titel: title, frist: due, sag_id: caseId }) body: JSON.stringify({
title,
description: null,
due_date: due
})
}); });
if (r.ok) { if (r.ok) {
closeRelQaSurfaceAfterSave(); closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success'); if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
} else { } else {
const d = await r.json().catch(()=>({})); const d = await r.json().catch(()=>({}));
if (typeof showNotification === 'function') showNotification(d.detail || 'Opgave-endpoint ikke tilgængeligt endnu', 'warning'); if (typeof showNotification === 'function') showNotification(d.detail || 'Kunne ikke oprette opgaven', 'error');
if (saveBtn) saveBtn.disabled = false; if (saveBtn) saveBtn.disabled = false;
} }
} catch { if (saveBtn) saveBtn.disabled = false; } } catch {
if (typeof showNotification === 'function') showNotification('Kunne ikke kontakte serveren', 'error');
if (saveBtn) saveBtn.disabled = false;
}
}; };
// ── Quick Tildel sag modal ──────────────────────────────────────── // ── Quick Tildel sag modal ────────────────────────────────────────
@ -19123,7 +19244,7 @@
const data = await res.json(); const data = await res.json();
// Update view // Update view
const textEl = document.getElementById('beskrivelse-text'); const textEl = document.getElementById('beskrivelse-text');
textEl.innerText = data.beskrivelse || ''; textEl.innerHTML = sanitizeCaseEmailHtml(data.beskrivelse || '');
const emptyEl = document.getElementById('beskrivelse-empty'); const emptyEl = document.getElementById('beskrivelse-empty');
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : ''; if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
cancelBeskrivelsEdit(); cancelBeskrivelsEdit();

View File

@ -1598,7 +1598,7 @@ class EmailService:
# Prefer Graph send when Graph integration is enabled/configured. # Prefer Graph send when Graph integration is enabled/configured.
if self._graph_send_available(): if self._graph_send_available():
graph_ok, graph_message = await self._send_via_graph( graph_ok, graph_message, _graph_metadata = await self._send_via_graph(
to_addresses=to_addresses, to_addresses=to_addresses,
subject=subject, subject=subject,
body_text=body_text, body_text=body_text,

View File

@ -25,8 +25,38 @@ class ReminderNotificationService:
def __init__(self): def __init__(self):
self.email_service = EmailService() self.email_service = EmailService()
self.mattermost_service = MattermostNotification() self.mattermost_service = MattermostNotification()
# Reminder delivery has its own enable flag; backup notifications keep
# using the general MATTERMOST_ENABLED setting.
self.mattermost_service.enabled = settings.REMINDERS_MATTERMOST_ENABLED
self.dry_run = settings.REMINDERS_DRY_RUN self.dry_run = settings.REMINDERS_DRY_RUN
self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR self.max_per_hour = settings.REMINDERS_MAX_PER_USER_PER_HOUR
def _refresh_mattermost_settings(self) -> None:
"""Load runtime Mattermost configuration from the admin settings table."""
rows = execute_query(
"""
SELECT key, value
FROM settings
WHERE key IN ('mattermost_reminders_enabled', 'mattermost_webhook_url', 'mattermost_channel')
"""
) or []
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
enabled_value = values.get("mattermost_reminders_enabled")
enabled = (
enabled_value.lower() in {"1", "true", "yes", "on"}
if enabled_value is not None
else settings.REMINDERS_MATTERMOST_ENABLED
)
self.mattermost_service.enabled = bool(enabled and settings.REMINDERS_MATTERMOST_ENABLED)
self.mattermost_service.webhook_url = (
values.get("mattermost_webhook_url") or settings.MATTERMOST_WEBHOOK_URL
)
self.mattermost_service.channel = values.get("mattermost_channel") or settings.MATTERMOST_CHANNEL
@staticmethod
def _case_url(case_id: int) -> str:
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
return f"{base_url}/sag/{case_id}/v3"
async def send_reminder( async def send_reminder(
self, self,
@ -68,6 +98,7 @@ class ReminderNotificationService:
'rate_limited_users': [], 'rate_limited_users': [],
'logged_id': None 'logged_id': None
} }
self._refresh_mattermost_settings()
if self.dry_run: if self.dry_run:
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}") logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
@ -102,27 +133,46 @@ class ReminderNotificationService:
) )
# Get user email # Get user email
user_query = "SELECT email FROM users WHERE user_id = %s" user_query = "SELECT email, username FROM users WHERE user_id = %s"
user = execute_query(user_query, (user_id,)) user = execute_query(user_query, (user_id,))
user_email = user[0]['email'] if user else None user_email = user_prefs.get('email_override') or (user[0]['email'] if user else None)
mattermost_username = (
user_prefs.get('mattermost_username')
or (user[0].get('username') if user else None)
)
# Send via channels # Send via channels
for channel in channels: for channel in channels:
try: try:
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED: if channel == 'mattermost':
await self._send_mattermost( if not settings.REMINDERS_MATTERMOST_ENABLED:
result['errors'].append('Mattermost reminders are disabled')
continue
sent = await self._send_mattermost(
reminder_title, reminder_message, sag_id, case_title, reminder_title, reminder_message, sag_id, case_title,
priority, additional_info priority, additional_info, mattermost_username
) )
result['channels_used'].append('mattermost') if sent:
result['channels_used'].append('mattermost')
else:
result['errors'].append('Mattermost delivery failed')
elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email: elif channel == 'email':
await self._send_email( if not settings.REMINDERS_EMAIL_ENABLED:
result['errors'].append('Email reminders are disabled')
continue
if not user_email:
result['errors'].append(f'User {user_id} has no email address')
continue
sent = await self._send_email(
user_email, reminder_title, reminder_message, user_email, reminder_title, reminder_message,
sag_id, case_title, customer_name, priority, sag_id, case_title, customer_name, priority,
case_status, deadline, assigned_user, additional_info case_status, deadline, assigned_user, additional_info
) )
result['channels_used'].append('email') if sent:
result['channels_used'].append('email')
else:
result['errors'].append(f'Email delivery failed for {user_email}')
elif channel == 'frontend': elif channel == 'frontend':
# Frontend notifications are handled by polling, no action needed here # Frontend notifications are handled by polling, no action needed here
@ -154,12 +204,17 @@ class ReminderNotificationService:
for email_addr in recipient_emails: for email_addr in recipient_emails:
try: try:
if settings.REMINDERS_EMAIL_ENABLED: if settings.REMINDERS_EMAIL_ENABLED:
await self._send_email( sent = await self._send_email(
email_addr, reminder_title, reminder_message, email_addr, reminder_title, reminder_message,
sag_id, case_title, customer_name, priority, sag_id, case_title, customer_name, priority,
case_status, deadline, assigned_user, additional_info case_status, deadline, assigned_user, additional_info
) )
result['channels_used'].append('email') if sent:
result['channels_used'].append('email')
else:
result['errors'].append(f'Email delivery failed for {email_addr}')
else:
result['errors'].append('Email reminders are disabled')
except Exception as e: except Exception as e:
error = f"Failed to send email to {email_addr}: {str(e)}" error = f"Failed to send email to {email_addr}: {str(e)}"
@ -191,7 +246,8 @@ class ReminderNotificationService:
async def _get_user_preferences(self, user_id: int) -> Dict: async def _get_user_preferences(self, user_id: int) -> Dict:
"""Get user notification preferences""" """Get user notification preferences"""
query = """ query = """
SELECT notify_mattermost, notify_email, notify_frontend SELECT notify_mattermost, notify_email, notify_frontend,
email_override, mattermost_username
FROM user_notification_preferences FROM user_notification_preferences
WHERE user_id = %s WHERE user_id = %s
""" """
@ -200,15 +256,19 @@ class ReminderNotificationService:
if result: if result:
return { return {
'mattermost': result[0].get('notify_mattermost', True), 'mattermost': result[0].get('notify_mattermost', True),
'email': result[0].get('notify_email', False), 'email': result[0].get('notify_email', True),
'frontend': result[0].get('notify_frontend', True) 'frontend': result[0].get('notify_frontend', True),
'email_override': result[0].get('email_override')
, 'mattermost_username': result[0].get('mattermost_username')
} }
# Default preferences # Default preferences
return { return {
'mattermost': True, 'mattermost': True,
'email': False, 'email': True,
'frontend': True 'frontend': True,
'email_override': None
, 'mattermost_username': None
} }
def _determine_channels( def _determine_channels(
@ -221,6 +281,11 @@ class ReminderNotificationService:
) -> List[str]: ) -> List[str]:
"""Determine which channels to use (merge user prefs with reminder overrides)""" """Determine which channels to use (merge user prefs with reminder overrides)"""
channels = [] channels = []
if not override:
notify_mattermost = user_prefs.get('mattermost', True)
notify_email = user_prefs.get('email', True)
notify_frontend = user_prefs.get('frontend', True)
# Mattermost # Mattermost
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True) mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
@ -228,7 +293,7 @@ class ReminderNotificationService:
channels.append('mattermost') channels.append('mattermost')
# Email # Email
em = notify_email if notify_email is not None else user_prefs.get('email', False) em = notify_email if notify_email is not None else user_prefs.get('email', True)
if em: if em:
channels.append('email') channels.append('email')
@ -246,7 +311,8 @@ class ReminderNotificationService:
case_id: int, case_id: int,
case_title: str, case_title: str,
priority: str, priority: str,
additional_info: Optional[str] additional_info: Optional[str],
mattermost_username: Optional[str] = None,
) -> bool: ) -> bool:
"""Send reminder via Mattermost""" """Send reminder via Mattermost"""
if self.dry_run: if self.dry_run:
@ -260,12 +326,13 @@ class ReminderNotificationService:
'high': '#ffc107', 'high': '#ffc107',
'urgent': '#dc3545' 'urgent': '#dc3545'
} }
case_url = self._case_url(case_id)
payload = { payload = {
'text': f'🔔 **{title}**', 'text': f'🔔 **{title}**\n[Åbn sag #{case_id}]({case_url})',
'attachments': [{ 'attachments': [{
'title': case_title, 'title': case_title,
'title_link': f"http://localhost:8001/sag/{case_id}/v3", 'title_link': case_url,
'text': message or additional_info or 'Se reminder i systemet', 'text': message or additional_info or 'Se reminder i systemet',
'color': color_map.get(priority, color_map['normal']), 'color': color_map.get(priority, color_map['normal']),
'fields': [ 'fields': [
@ -279,17 +346,24 @@ class ReminderNotificationService:
'value': f'#{case_id}', 'value': f'#{case_id}',
'short': True 'short': True
} }
], ]
'actions': [{
'name': 'Åbn sag',
'type': 'button',
'text': 'Se mere',
'url': f"http://localhost:8001/sag/{case_id}/v3"
}]
}] }]
} }
if mattermost_username:
payload["channel"] = f"@{str(mattermost_username).strip().lstrip('@')}"
success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification') success, msg = await self.mattermost_service._send_webhook(payload, 'reminder_notification')
if not success and mattermost_username:
# Some Mattermost webhooks are locked to their configured
# channel and reject channel="@user". Fall back to an explicit
# mention in that channel so the recipient still gets notified.
username = str(mattermost_username).strip().lstrip("@")
payload.pop("channel", None)
payload["text"] = f"@{username} {payload['text']}"
success, msg = await self.mattermost_service._send_webhook(
payload,
'reminder_notification_mention',
)
if success: if success:
logger.info(f"✅ Mattermost reminder sent: {title}") logger.info(f"✅ Mattermost reminder sent: {title}")
else: else:
@ -337,7 +411,7 @@ class ReminderNotificationService:
'deadline': deadline, 'deadline': deadline,
'assigned_user': assigned_user or 'Ikke tildelt', 'assigned_user': assigned_user or 'Ikke tildelt',
'additional_info': additional_info or '', 'additional_info': additional_info or '',
'action_url': f"http://localhost:8001/sag/{case_id}/v3", 'action_url': self._case_url(case_id),
'footer_date': datetime.now().strftime("%d. %B %Y") 'footer_date': datetime.now().strftime("%d. %B %Y")
} }

View File

@ -2,19 +2,29 @@
Settings and User Management API Router Settings and User Management API Router
""" """
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request, Depends
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime from datetime import datetime
from app.core.database import execute_query from app.core.database import execute_query
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService
import argparse
import asyncio
import httpx import httpx
import time import time
import logging import logging
import json import json
import os
import re
import threading
from pathlib import Path
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
_sag_test_lock = threading.Lock()
_sag_test_report_dir = Path(__file__).resolve().parents[3] / "data" / "test-results"
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = ( DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
"{full_name}\n" "{full_name}\n"
@ -52,6 +62,29 @@ class SettingCreate(BaseModel):
is_public: Optional[bool] = False is_public: Optional[bool] = False
class MattermostTestRequest(BaseModel):
message: Optional[str] = "Test fra BMC Hub"
class SagTestRunRequest(BaseModel):
customer_id: Optional[int] = None
MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
("mattermost_channel", "", "Optional Mattermost channel override", "string"),
)
MASKED_SECRET = "********"
def _mask_setting(row: Dict) -> Dict:
item = dict(row)
if item.get("key") == "mattermost_webhook_url":
item["value"] = MASKED_SECRET if str(item.get("value") or "").strip() else ""
return item
class User(BaseModel): class User(BaseModel):
id: int id: int
username: str username: str
@ -75,6 +108,80 @@ class UserUpdate(BaseModel):
is_active: Optional[bool] = None is_active: Optional[bool] = None
def _load_sag_test_reports(limit: int = 20) -> List[Dict]:
if not _sag_test_report_dir.exists():
return []
reports = []
for path in sorted(_sag_test_report_dir.glob("sag-e2e-*.json"), reverse=True)[:limit]:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
payload["report_file"] = path.name
reports.append(payload)
except (OSError, json.JSONDecodeError):
logger.warning("Kunne ikke læse Sag-testrapport: %s", path)
return reports
def _execute_sag_test(current_user: Dict, customer_id: Optional[int]) -> Dict:
from app.modules.sag.scripts.sag_module_e2e import Result, SagE2E
_sag_test_report_dir.mkdir(parents=True, exist_ok=True)
report_path = _sag_test_report_dir / f"sag-e2e-{datetime.now():%Y%m%d-%H%M%S}.json"
token = AuthService.create_access_token(
user_id=int(current_user["id"]),
username=str(current_user["username"]),
is_superadmin=bool(current_user.get("is_superadmin")),
is_shadow_admin=bool(current_user.get("is_shadow_admin", False)),
)
args = argparse.Namespace(
base_url=os.getenv("SAG_TEST_BASE_URL", "http://127.0.0.1:8000"),
token=token,
username=None,
password=None,
otp=None,
customer_id=customer_id,
timeout=20.0,
keep_data=False,
json_report=str(report_path),
)
suite = SagE2E(args)
try:
suite.run()
except Exception as exc:
suite.results.append(Result("Testkørsel", "FAIL", str(exc)))
finally:
suite.cleanup_all()
suite.write_report()
payload = json.loads(report_path.read_text(encoding="utf-8"))
payload["report_file"] = report_path.name
return payload
@router.get("/settings/tests/sag", tags=["Settings Tests"])
async def get_sag_test_reports(
current_user: dict = Depends(require_superadmin),
):
reports = _load_sag_test_reports()
return {
"running": _sag_test_lock.locked(),
"latest": reports[0] if reports else None,
"history": reports,
}
@router.post("/settings/tests/sag/run", tags=["Settings Tests"])
async def run_sag_test(
payload: SagTestRunRequest,
current_user: dict = Depends(require_superadmin),
):
if not _sag_test_lock.acquire(blocking=False):
raise HTTPException(status_code=409, detail="Sag-testen kører allerede")
try:
return await asyncio.to_thread(_execute_sag_test, current_user, payload.customer_id)
finally:
_sag_test_lock.release()
# Settings Endpoints # Settings Endpoints
@router.get("/settings", response_model=List[Setting], tags=["Settings"]) @router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None): async def get_settings(category: Optional[str] = None):
@ -102,6 +209,16 @@ async def get_settings(category: Optional[str] = None):
True, True,
), ),
) )
execute_query(
"""
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, 'notifications', %s, %s, false),
(%s, %s, 'notifications', %s, %s, false),
(%s, %s, 'notifications', %s, %s, false)
ON CONFLICT (key) DO NOTHING
""",
tuple(value for item in MATTERMOST_SETTING_DEFAULTS for value in item),
)
query = "SELECT * FROM settings" query = "SELECT * FROM settings"
params = [] params = []
@ -112,7 +229,7 @@ async def get_settings(category: Optional[str] = None):
query += " ORDER BY category, key" query += " ORDER BY category, key"
result = execute_query(query, tuple(params) if params else None) result = execute_query(query, tuple(params) if params else None)
return result or [] return [_mask_setting(row) for row in (result or [])]
@router.post("/settings", response_model=Setting, tags=["Settings"]) @router.post("/settings", response_model=Setting, tags=["Settings"])
@ -147,7 +264,7 @@ async def create_setting(payload: SettingCreate):
) )
if not result: if not result:
raise HTTPException(status_code=500, detail="Failed to create setting") raise HTTPException(status_code=500, detail="Failed to create setting")
return result[0] return _mask_setting(result[0])
@router.get("/settings/{key}", response_model=Setting, tags=["Settings"]) @router.get("/settings/{key}", response_model=Setting, tags=["Settings"])
@ -231,12 +348,25 @@ async def get_setting(key: str):
if not result: if not result:
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
return result[0] return _mask_setting(result[0])
@router.put("/settings/{key}", response_model=Setting, tags=["Settings"]) @router.put("/settings/{key}", response_model=Setting, tags=["Settings"])
async def update_setting(key: str, setting: SettingUpdate): async def update_setting(key: str, setting: SettingUpdate):
"""Update a setting value""" """Update a setting value"""
if key == "mattermost_webhook_url" and setting.value == MASKED_SECRET:
current = execute_query("SELECT * FROM settings WHERE key = %s", (key,))
if not current:
raise HTTPException(status_code=404, detail="Setting not found")
return _mask_setting(current[0])
if key == "mattermost_channel":
channel = setting.value.strip()
if channel and not re.fullmatch(r"[a-z0-9_-]+", channel):
raise HTTPException(
status_code=400,
detail="Brug Mattermost-kanalens tekniske navn uden mellemrum, fx mollypim-logs",
)
query = """ query = """
UPDATE settings UPDATE settings
SET value = %s, updated_at = CURRENT_TIMESTAMP SET value = %s, updated_at = CURRENT_TIMESTAMP
@ -388,7 +518,62 @@ async def update_setting(key: str, setting: SettingUpdate):
raise HTTPException(status_code=404, detail="Setting not found") raise HTTPException(status_code=404, detail="Setting not found")
logger.info(f"✅ Updated setting: {key}") logger.info(f"✅ Updated setting: {key}")
return result[0] return _mask_setting(result[0])
@router.post("/settings/mattermost/test", tags=["Settings"])
async def test_mattermost_setting(payload: MattermostTestRequest):
rows = execute_query(
"SELECT key, value FROM settings WHERE key IN ('mattermost_webhook_url', 'mattermost_channel')"
) or []
values = {row["key"]: str(row.get("value") or "").strip() for row in rows}
webhook_url = values.get("mattermost_webhook_url", "")
if not webhook_url:
raise HTTPException(status_code=400, detail="Mattermost webhook URL mangler")
if not webhook_url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="Mattermost webhook URL skal starte med http:// eller https://")
message = str(payload.message or "Test fra BMC Hub").strip()[:500]
mattermost_payload = {"text": f"✅ **{message}**", "username": "BMC Hub"}
if values.get("mattermost_channel"):
mattermost_payload["channel"] = values["mattermost_channel"]
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(webhook_url, json=mattermost_payload)
response_text = response.text
if (
response.status_code in (400, 404, 415)
and (
"media type application/json" in response_text.lower()
or "incoming_webhook.general.app_error" in response_text.lower()
)
):
response = await client.post(
webhook_url,
data={"payload": json.dumps(mattermost_payload, ensure_ascii=False)},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code not in (200, 201, 204):
response_body = response.text[:500]
if "incoming_webhook.general.app_error" in response_body:
raise HTTPException(
status_code=400,
detail=(
"Webhooken blev afvist af Mattermost. Opret en ny integration under "
"Mattermost → Integrations → Incoming Webhooks, og kopiér hele URL'en "
"som Mattermost genererer. Et outgoing webhook-ID eller et deaktiveret "
"incoming webhook kan ikke bruges."
),
)
raise HTTPException(
status_code=502,
detail=f"Mattermost returnerede HTTP {response.status_code}: {response.text[:200]}",
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Kunne ikke kontakte Mattermost: {exc}") from exc
return {"success": True, "message": "Testbesked sendt til Mattermost"}
@router.get("/settings/categories/list", tags=["Settings"]) @router.get("/settings/categories/list", tags=["Settings"])

View File

@ -61,6 +61,26 @@
justify-content: center; justify-content: center;
font-weight: bold; font-weight: bold;
} }
.test-result-row {
display: grid;
grid-template-columns: 82px minmax(180px, 1fr) auto;
gap: 0.75rem;
align-items: start;
padding: 0.7rem 0;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,.08));
}
.test-result-row:last-child {
border-bottom: 0;
}
.test-result-detail {
grid-column: 2 / -1;
color: var(--text-secondary);
font-size: 0.82rem;
overflow-wrap: anywhere;
}
</style> </style>
{% endblock %} {% endblock %}
@ -122,6 +142,9 @@
<a class="nav-link" href="#mission" data-tab="mission"> <a class="nav-link" href="#mission" data-tab="mission">
<i class="bi bi-broadcast-pin me-2"></i>Mission <i class="bi bi-broadcast-pin me-2"></i>Mission
</a> </a>
<a class="nav-link" href="#tests" data-tab="tests">
<i class="bi bi-clipboard2-pulse me-2"></i>Tests
</a>
<a class="nav-link" href="#system" data-tab="system"> <a class="nav-link" href="#system" data-tab="system">
<i class="bi bi-gear me-2"></i>System <i class="bi bi-gear me-2"></i>System
</a> </a>
@ -469,7 +492,7 @@
<!-- Notifications --> <!-- Notifications -->
<div class="tab-pane fade" id="notifications"> <div class="tab-pane fade" id="notifications">
<div class="card p-4"> <div class="card p-4 mb-4">
<h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5> <h5 class="mb-4 fw-bold">Notifikation Indstillinger</h5>
<div id="notificationSettings"> <div id="notificationSettings">
<div class="text-center py-5"> <div class="text-center py-5">
@ -477,6 +500,41 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card p-4">
<div class="d-flex justify-content-between align-items-start gap-3 mb-4">
<div>
<h5 class="mb-1 fw-bold">Mattermost</h5>
<p class="text-muted mb-0">Send sagsreminders til en Mattermost incoming webhook.</p>
</div>
<span class="badge bg-secondary" id="mattermostSettingsState">Ikke konfigureret</span>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="mattermostRemindersEnabled">
<label class="form-check-label" for="mattermostRemindersEnabled">Aktivér Mattermost-reminders</label>
</div>
<div class="row g-3">
<div class="col-lg-8">
<label class="form-label" for="mattermostWebhookUrl">Incoming webhook URL</label>
<input type="password" class="form-control" id="mattermostWebhookUrl"
placeholder="https://mattermost.example/hooks/...">
<div class="form-text">En gemt webhook vises aldrig igen. Lad feltet være tomt for at beholde den nuværende.</div>
</div>
<div class="col-lg-4">
<label class="form-label" for="mattermostChannel">Kanal (valgfri)</label>
<input type="text" class="form-control" id="mattermostChannel" placeholder="fx mollypim-logs">
<div class="form-text">Brug kanalens tekniske navn/slug uden mellemrum. Tomt felt bruger webhookens standardkanal.</div>
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-4">
<button class="btn btn-primary" type="button" onclick="saveMattermostSettings()">
<i class="bi bi-save me-2"></i>Gem Mattermost
</button>
<button class="btn btn-outline-primary" type="button" onclick="testMattermostSettings()">
<i class="bi bi-send me-2"></i>Send test
</button>
</div>
<div class="small mt-3" id="mattermostSettingsFeedback"></div>
</div>
</div> </div>
<!-- Email Templates --> <!-- Email Templates -->
@ -1507,6 +1565,84 @@ async def scan_document(file_path: str):
</div> </div>
</div> </div>
<!-- Automated tests -->
<div class="tab-pane fade" id="tests">
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-start gap-3 flex-wrap">
<div>
<div class="d-flex align-items-center gap-2 mb-1">
<h5 class="fw-bold mb-0">Sag-modul komplet funktionstest</h5>
<span class="badge text-bg-secondary" id="sagTestState">Ikke kørt</span>
</div>
<p class="text-muted mb-0">
Tester sager, undersager, relationer, todos, kommentarer, tags,
buzzwords, søgning, ordrelinjer og filer. Testdata ryddes automatisk op.
</p>
</div>
<button class="btn btn-primary" type="button" id="runSagTestBtn" onclick="runSagModuleTest()">
<i class="bi bi-play-fill me-2"></i>Kør Sag-test
</button>
</div>
<div class="alert alert-warning py-2 mt-3 mb-0 small">
Kun superadmins kan starte testen. Mail, AnyDesk, printer og eksterne
økonomisystemer markeres som SKIP, fordi de kræver eksterne tjenester eller hardware.
</div>
</div>
<div class="row g-3 mb-4" id="sagTestSummary">
<div class="col-md-4">
<div class="card p-3 border-success">
<div class="small text-muted text-uppercase fw-semibold">Bestået</div>
<div class="fs-2 fw-bold text-success" id="sagTestPassCount"></div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border-danger">
<div class="small text-muted text-uppercase fw-semibold">Fejl</div>
<div class="fs-2 fw-bold text-danger" id="sagTestFailCount"></div>
</div>
</div>
<div class="col-md-4">
<div class="card p-3 border-secondary">
<div class="small text-muted text-uppercase fw-semibold">Sprunget over</div>
<div class="fs-2 fw-bold text-secondary" id="sagTestSkipCount"></div>
</div>
</div>
</div>
<div class="card p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="fw-bold mb-1">Seneste resultat</h6>
<div class="small text-muted" id="sagTestMeta">Ingen testrapport endnu.</div>
</div>
</div>
<div id="sagTestResults">
<div class="text-muted">Kør testen for at se resultater.</div>
</div>
</div>
<div class="card p-4">
<h6 class="fw-bold mb-3">Tidligere kørsler</h6>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead>
<tr>
<th>Tidspunkt</th>
<th>Bestået</th>
<th>Fejl</th>
<th>Skip</th>
</tr>
</thead>
<tbody id="sagTestHistory">
<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- System Settings --> <!-- System Settings -->
<div class="tab-pane fade" id="system"> <div class="tab-pane fade" id="system">
<div class="card p-4 mb-4"> <div class="card p-4 mb-4">
@ -1974,6 +2110,7 @@ let pipelineStagesCache = [];
let nextcloudInstancesCache = []; let nextcloudInstancesCache = [];
let customersCache = []; let customersCache = [];
let timeMultiplierPresetsCache = []; let timeMultiplierPresetsCache = [];
let sagTestReportsCache = [];
const DEFAULT_TIME_MULTIPLIER_PRESETS = [ const DEFAULT_TIME_MULTIPLIER_PRESETS = [
{ label: 'Haster', text: 'Haster', multiplier: 3 }, { label: 'Haster', text: 'Haster', multiplier: 3 },
@ -2672,6 +2809,7 @@ function displaySettingsByCategory() {
// Notification settings // Notification settings
displaySettings('notificationSettings', categories.notifications); displaySettings('notificationSettings', categories.notifications);
renderMattermostSettings();
// Email templates // Email templates
displaySettings('emailTemplatesInternal', [ displaySettings('emailTemplatesInternal', [
@ -2689,6 +2827,100 @@ function displaySettingsByCategory() {
displaySettings('systemSettings', categories.system); displaySettings('systemSettings', categories.system);
} }
function renderMattermostSettings() {
const enabled = allSettings.find(s => s.key === 'mattermost_reminders_enabled');
const webhook = allSettings.find(s => s.key === 'mattermost_webhook_url');
const channel = allSettings.find(s => s.key === 'mattermost_channel');
const enabledEl = document.getElementById('mattermostRemindersEnabled');
const webhookEl = document.getElementById('mattermostWebhookUrl');
const channelEl = document.getElementById('mattermostChannel');
const stateEl = document.getElementById('mattermostSettingsState');
if (!enabledEl || !webhookEl || !channelEl || !stateEl) return;
enabledEl.checked = enabled?.value === 'true';
webhookEl.value = '';
channelEl.value = channel?.value || '';
const configured = webhook?.value === '********';
webhookEl.placeholder = configured
? 'Webhook er gemt - indtast kun for at ændre'
: 'https://mattermost.example/hooks/...';
stateEl.className = `badge ${configured && enabledEl.checked ? 'bg-success' : 'bg-secondary'}`;
stateEl.textContent = configured
? (enabledEl.checked ? 'Aktiv' : 'Webhook gemt')
: 'Ikke konfigureret';
}
async function saveMattermostSettings(showToast = true) {
const enabled = document.getElementById('mattermostRemindersEnabled').checked;
const webhook = document.getElementById('mattermostWebhookUrl').value.trim();
const channel = document.getElementById('mattermostChannel').value.trim();
const feedback = document.getElementById('mattermostSettingsFeedback');
if (webhook && !/^https?:\/\//i.test(webhook)) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = 'Webhook URL skal starte med http:// eller https://';
return false;
}
if (channel && !/^[a-z0-9_-]+$/.test(channel)) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = 'Brug kanalens tekniske navn uden mellemrum, fx mollypim-logs.';
return false;
}
feedback.className = 'small mt-3 text-muted';
feedback.textContent = 'Gemmer Mattermost-indstillinger...';
const updates = [
['mattermost_reminders_enabled', enabled ? 'true' : 'false'],
['mattermost_channel', channel],
];
if (webhook) updates.push(['mattermost_webhook_url', webhook]);
try {
for (const [key, value] of updates) {
const response = await fetch(`/api/v1/settings/${encodeURIComponent(key)}`, {
method: 'PUT',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({value}),
});
if (!response.ok) throw new Error(await getErrorMessage(response, `Kunne ikke gemme ${key}`));
const saved = await response.json();
setOrAddSettingInCache(key, saved.value);
}
if (webhook) setOrAddSettingInCache('mattermost_webhook_url', '********');
renderMattermostSettings();
feedback.className = 'small mt-3 text-success';
feedback.textContent = 'Mattermost-indstillinger gemt.';
if (showToast) showNotification('Mattermost-indstillinger gemt', 'success');
return true;
} catch (error) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = error.message || 'Kunne ikke gemme Mattermost-indstillinger';
return false;
}
}
async function testMattermostSettings() {
const feedback = document.getElementById('mattermostSettingsFeedback');
if (!await saveMattermostSettings(false)) return;
feedback.className = 'small mt-3 text-muted';
feedback.textContent = 'Sender testbesked...';
try {
const response = await fetch('/api/v1/settings/mattermost/test', {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message: 'Mattermost virker fra BMC Hub'}),
});
if (!response.ok) throw new Error(await getErrorMessage(response, 'Testbeskeden fejlede'));
feedback.className = 'small mt-3 text-success';
feedback.textContent = 'Testbesked sendt til Mattermost.';
showNotification('Mattermost-test sendt', 'success');
} catch (error) {
feedback.className = 'small mt-3 text-danger';
feedback.textContent = error.message || 'Kunne ikke sende Mattermost-test';
}
}
async function loadAnydeskSettings() { async function loadAnydeskSettings() {
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run']; const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
try { try {
@ -4762,6 +4994,121 @@ function formatDate(dateString) {
}); });
} }
function sagTestEscape(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function renderSagTestReport(report) {
const state = document.getElementById('sagTestState');
const results = document.getElementById('sagTestResults');
if (!report) {
state.className = 'badge text-bg-secondary';
state.textContent = 'Ikke kørt';
document.getElementById('sagTestPassCount').textContent = '';
document.getElementById('sagTestFailCount').textContent = '';
document.getElementById('sagTestSkipCount').textContent = '';
document.getElementById('sagTestMeta').textContent = 'Ingen testrapport endnu.';
results.innerHTML = '<div class="text-muted">Kør testen for at se resultater.</div>';
return;
}
const summary = report.summary || {};
const failed = Number(summary.FAIL || 0);
state.className = `badge ${failed ? 'text-bg-danger' : 'text-bg-success'}`;
state.textContent = failed ? 'Fejl fundet' : 'Alle kernetests bestået';
document.getElementById('sagTestPassCount').textContent = summary.PASS ?? 0;
document.getElementById('sagTestFailCount').textContent = summary.FAIL ?? 0;
document.getElementById('sagTestSkipCount').textContent = summary.SKIP ?? 0;
document.getElementById('sagTestMeta').textContent =
`${formatDate(report.created_at)} · Kørsel ${report.run_id || 'ukendt'}`;
const badgeClass = { PASS: 'text-bg-success', FAIL: 'text-bg-danger', SKIP: 'text-bg-secondary' };
results.innerHTML = (report.results || []).map(item => `
<div class="test-result-row">
<span class="badge ${badgeClass[item.status] || 'text-bg-secondary'}">${sagTestEscape(item.status)}</span>
<div class="fw-semibold">${sagTestEscape(item.name)}</div>
<div class="small text-muted">${Number(item.duration_ms || 0)} ms</div>
${item.detail ? `<div class="test-result-detail">${sagTestEscape(item.detail)}</div>` : ''}
</div>
`).join('') || '<div class="text-muted">Rapporten indeholder ingen resultater.</div>';
}
function renderSagTestHistory(reports) {
sagTestReportsCache = Array.isArray(reports) ? reports : [];
const body = document.getElementById('sagTestHistory');
if (!sagTestReportsCache.length) {
body.innerHTML = '<tr><td colspan="4" class="text-muted">Ingen historik endnu.</td></tr>';
return;
}
body.innerHTML = sagTestReportsCache.map((report, index) => {
const summary = report.summary || {};
return `
<tr role="button" onclick="renderSagTestReport(sagTestReportsCache[${index}])">
<td>${sagTestEscape(formatDate(report.created_at))}</td>
<td><span class="text-success fw-semibold">${Number(summary.PASS || 0)}</span></td>
<td><span class="text-danger fw-semibold">${Number(summary.FAIL || 0)}</span></td>
<td><span class="text-secondary">${Number(summary.SKIP || 0)}</span></td>
</tr>
`;
}).join('');
}
async function loadSagModuleTests() {
const state = document.getElementById('sagTestState');
try {
const response = await fetch('/api/v1/settings/tests/sag', { credentials: 'include' });
if (!response.ok) throw new Error(await extractApiError(response, 'Kunne ikke hente tests'));
const data = await response.json();
renderSagTestReport(data.latest);
renderSagTestHistory(data.history);
if (data.running) {
state.className = 'badge text-bg-primary';
state.textContent = 'Kører…';
}
} catch (error) {
state.className = 'badge text-bg-danger';
state.textContent = 'Kan ikke hente';
document.getElementById('sagTestResults').innerHTML =
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
}
}
async function runSagModuleTest() {
const button = document.getElementById('runSagTestBtn');
const state = document.getElementById('sagTestState');
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Kører test…';
state.className = 'badge text-bg-primary';
state.textContent = 'Kører…';
document.getElementById('sagTestResults').innerHTML =
'<div class="d-flex align-items-center gap-2 text-muted"><span class="spinner-border spinner-border-sm"></span>Tester Sag-modulet og rydder testdata op…</div>';
try {
const response = await fetch('/api/v1/settings/tests/sag/run', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!response.ok) throw new Error(await extractApiError(response, 'Sag-testen fejlede'));
const report = await response.json();
renderSagTestReport(report);
await loadSagModuleTests();
} catch (error) {
state.className = 'badge text-bg-danger';
state.textContent = 'Kørsel fejlede';
document.getElementById('sagTestResults').innerHTML =
`<div class="alert alert-danger mb-0">${sagTestEscape(error.message)}</div>`;
} finally {
button.disabled = false;
button.innerHTML = '<i class="bi bi-play-fill me-2"></i>Kør Sag-test';
}
}
// Tab navigation // Tab navigation
document.querySelectorAll('.settings-nav .nav-link').forEach(link => { document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
link.addEventListener('click', (e) => { link.addEventListener('click', (e) => {
@ -4773,6 +5120,7 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
} }
e.preventDefault(); e.preventDefault();
history.replaceState(null, '', `#${tab}`);
// Update nav // Update nav
document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active')); document.querySelectorAll('.settings-nav .nav-link').forEach(l => l.classList.remove('active'));
@ -4800,6 +5148,8 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
loadAIPrompts(); loadAIPrompts();
} else if (tab === 'modules') { } else if (tab === 'modules') {
loadModules(); loadModules();
} else if (tab === 'tests') {
loadSagModuleTests();
} }
}); });
}); });
@ -6103,6 +6453,7 @@ const MENU_VISIBILITY_GROUPS = [
{ {
title: 'Data migration underpunkter', title: 'Data migration underpunkter',
items: [ items: [
{ key: 'menu-datamigration-migration-center', label: 'Migreringscenter' },
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' }, { key: 'menu-datamigration-dashboard', label: 'Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Registreringer' }, { key: 'menu-datamigration-registrations', label: 'Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' }, { key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
@ -6210,6 +6561,12 @@ document.addEventListener('DOMContentLoaded', () => {
loadPipelineStages(); loadPipelineStages();
loadMenuVisibilityPreferences(); loadMenuVisibilityPreferences();
const requestedTab = window.location.hash.replace('#', '');
const requestedLink = requestedTab
? document.querySelector(`.settings-nav .nav-link[data-tab="${CSS.escape(requestedTab)}"]`)
: null;
if (requestedLink) requestedLink.click();
const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn'); const saveMenuVisibilityBtn = document.getElementById('saveMenuVisibilityBtn');
if (saveMenuVisibilityBtn) { if (saveMenuVisibilityBtn) {
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences); saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);

View File

@ -1014,6 +1014,8 @@
<i class="bi bi-clock-history me-2"></i>Data migration <i class="bi bi-clock-history me-2"></i>Data migration
</a> </a>
<ul class="dropdown-menu dropdown-menu-end mt-2"> <ul class="dropdown-menu dropdown-menu-end mt-2">
<li data-menu-key="menu-datamigration-migration-center"><a class="dropdown-item py-2" href="/migration-center"><i class="bi bi-arrow-left-right me-2"></i>Migreringscenter</a></li>
<li><hr class="dropdown-divider"></li>
<li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li data-menu-key="menu-datamigration-dashboard"><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li> <li data-menu-key="menu-datamigration-registrations"><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li> <li data-menu-key="menu-datamigration-wizard"><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
@ -1137,6 +1139,19 @@
</div> </div>
</div> </div>
<!-- Case Results -->
<div id="caseResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-muted text-uppercase small fw-bold mb-0">
<i class="bi bi-card-checklist me-2"></i>Sager
</h6>
<a href="/sag" class="btn btn-sm btn-outline-primary">
<i class="bi bi-list-check me-1"></i>Alle sager
</a>
</div>
<div class="result-items"></div>
</div>
<!-- Email Results --> <!-- Email Results -->
<div id="emailResults" class="result-section mb-4" style="display: none;"> <div id="emailResults" class="result-section mb-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
@ -1740,6 +1755,7 @@ if (bmcOriginalFetch) {
document.getElementById('workflowActions').style.display = 'none'; document.getElementById('workflowActions').style.display = 'none';
document.getElementById('crmResults').style.display = 'none'; document.getElementById('crmResults').style.display = 'none';
document.getElementById('supportResults').style.display = 'none'; document.getElementById('supportResults').style.display = 'none';
if (document.getElementById('caseResults')) document.getElementById('caseResults').style.display = 'none';
if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none'; if (document.getElementById('emailResults')) document.getElementById('emailResults').style.display = 'none';
if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none'; if (document.getElementById('salesResults')) document.getElementById('salesResults').style.display = 'none';
if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none'; if (document.getElementById('financeResults')) document.getElementById('financeResults').style.display = 'none';
@ -2007,6 +2023,46 @@ if (bmcOriginalFetch) {
console.log('Contacts search not available'); console.log('Contacts search not available');
} }
// Search cases, including linked tags and buzzwords
try {
const casesResponse = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query)}`);
const cases = await casesResponse.json();
const caseResults = document.getElementById('caseResults');
if (Array.isArray(cases) && cases.length > 0) {
hasResults = true;
caseResults.style.display = 'block';
const caseList = caseResults.querySelector('.result-items');
caseList.innerHTML = cases.slice(0, 10).map(item => {
const buzzwords = Array.isArray(item.buzzwords)
? item.buzzwords.filter(Boolean)
: [];
const buzzwordHtml = buzzwords.length
? ` • <i class="bi bi-lightbulb ms-1"></i> ${buzzwords.map(word => escapeHtml(word)).join(', ')}`
: '';
return `
<div class="result-item" onclick="window.location.href='/sag/${Number(item.id)}/v3'" style="cursor: pointer;">
<div>
<div class="fw-bold">#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}</div>
<div class="small text-muted">
<i class="bi bi-card-checklist me-1"></i>${escapeHtml(item.status || '-')}
${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
${buzzwordHtml}
</div>
</div>
<i class="bi bi-arrow-right"></i>
</div>
`;
}).join('');
} else if (caseResults) {
caseResults.style.display = 'none';
}
} catch (e) {
console.log('Case and buzzword search not available');
const caseResults = document.getElementById('caseResults');
if (caseResults) caseResults.style.display = 'none';
}
// Search emails // Search emails
try { try {
const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`); const emailsResponse = await fetch(`/api/v1/emails?q=${encodeURIComponent(query)}&limit=5`);
@ -2351,6 +2407,11 @@ if (bmcOriginalFetch) {
<label class="form-label">Email override</label> <label class="form-label">Email override</label>
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk"> <input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
</div> </div>
<div class="mb-3">
<label class="form-label">Mattermost-brugernavn</label>
<input type="text" class="form-control" id="pref_mattermost_username" placeholder="Tomt = BMC Hub-brugernavn">
<div class="form-text">Bruges til direkte beskeder som @brugernavn.</div>
</div>
<button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button> <button class="btn btn-sm btn-primary" onclick="saveReminderPreferences()">Gem</button>
</div> </div>
</div> </div>
@ -2433,6 +2494,7 @@ if (bmcOriginalFetch) {
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' }, { key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' }, { key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
{ key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' }, { key: 'menu-okonomi-subscriptions', label: 'Økonomi: Abonnementer' },
{ key: 'menu-datamigration-migration-center', label: 'Data migration: Migreringscenter' },
{ key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' }, { key: 'menu-datamigration-dashboard', label: 'Data migration: Dashboard' },
{ key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' }, { key: 'menu-datamigration-registrations', label: 'Data migration: Registreringer' },
{ key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' }, { key: 'menu-datamigration-wizard', label: 'Data migration: Godkend Timer' },
@ -2545,6 +2607,7 @@ if (bmcOriginalFetch) {
document.getElementById('pref_notify_email').checked = !!prefs.notify_email; document.getElementById('pref_notify_email').checked = !!prefs.notify_email;
document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost; document.getElementById('pref_notify_mattermost').checked = !!prefs.notify_mattermost;
document.getElementById('pref_email_override').value = prefs.email_override || ''; document.getElementById('pref_email_override').value = prefs.email_override || '';
document.getElementById('pref_mattermost_username').value = prefs.mattermost_username || '';
} catch (e) { } catch (e) {
console.error('Failed to load reminder preferences', e); console.error('Failed to load reminder preferences', e);
} }
@ -2555,7 +2618,8 @@ if (bmcOriginalFetch) {
notify_frontend: document.getElementById('pref_notify_frontend').checked, notify_frontend: document.getElementById('pref_notify_frontend').checked,
notify_email: document.getElementById('pref_notify_email').checked, notify_email: document.getElementById('pref_notify_email').checked,
notify_mattermost: document.getElementById('pref_notify_mattermost').checked, notify_mattermost: document.getElementById('pref_notify_mattermost').checked,
email_override: document.getElementById('pref_email_override').value || null email_override: document.getElementById('pref_email_override').value || null,
mattermost_username: document.getElementById('pref_mattermost_username').value.trim().replace(/^@/, '') || null
}; };
try { try {

View File

@ -2,7 +2,7 @@
Subscriptions Frontend Views Subscriptions Frontend Views
""" """
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
import logging import logging
@ -25,3 +25,19 @@ async def subscriptions_simply_imports(request: Request):
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", { return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
"request": request "request": request
}) })
@router.get("/subscriptions/{subscription_id}")
async def subscription_detail_redirect(subscription_id: int):
"""Compatibility detail URL: subscriptions are edited on their associated case."""
from app.core.database import execute_query_single
subscription = execute_query_single(
"SELECT id, sag_id FROM sag_subscriptions WHERE id = %s",
(subscription_id,),
)
if not subscription:
return RedirectResponse(url="/subscriptions", status_code=303)
if subscription.get("sag_id"):
return RedirectResponse(url=f"/sag/{subscription['sag_id']}/v3", status_code=303)
return RedirectResponse(url="/subscriptions", status_code=303)

106
app/utils/safe_html.py Normal file
View File

@ -0,0 +1,106 @@
"""Small allow-list sanitizer for HTML rendered inside the BMC Hub UI."""
import html
from html.parser import HTMLParser
from typing import Optional
class _SafeHtmlSanitizer(HTMLParser):
_ALLOWED_TAGS = {
"a", "b", "strong", "i", "em", "u", "s",
"p", "div", "span", "br", "hr", "blockquote", "pre", "code",
"ul", "ol", "li",
"h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption",
}
_VOID_TAGS = {"br", "hr"}
_DROP_WITH_CONTENT = {"script", "style", "iframe", "object", "embed", "svg", "math", "head"}
_ALLOWED_ATTRS = {
"a": {"href", "title"},
"th": {"colspan", "rowspan"},
"td": {"colspan", "rowspan"},
}
def __init__(self):
super().__init__(convert_charrefs=True)
self._parts: list[str] = []
self._drop_depth = 0
def handle_starttag(self, tag, attrs):
tag = str(tag or "").lower()
if tag in self._DROP_WITH_CONTENT:
self._drop_depth += 1
return
if self._drop_depth or tag not in self._ALLOWED_TAGS:
return
safe_attrs: list[str] = []
for key, value in attrs or []:
key = str(key or "").lower()
if key not in self._ALLOWED_ATTRS.get(tag, set()):
continue
value = str(value or "").strip()
if key == "href":
normalized = value.lower()
if not normalized.startswith(("https://", "http://", "mailto:", "tel:", "/")):
continue
if key in {"colspan", "rowspan"}:
try:
number = int(value)
except (TypeError, ValueError):
continue
if number < 1 or number > 100:
continue
value = str(number)
safe_attrs.append(f'{key}="{html.escape(value, quote=True)}"')
attrs_html = f" {' '.join(safe_attrs)}" if safe_attrs else ""
if tag == "a":
attrs_html += ' target="_blank" rel="noopener noreferrer"'
self._parts.append(f"<{tag}{attrs_html}>")
def handle_startendtag(self, tag, attrs):
if str(tag or "").lower() in self._DROP_WITH_CONTENT:
return
self.handle_starttag(tag, attrs)
def handle_endtag(self, tag):
tag = str(tag or "").lower()
if tag in self._DROP_WITH_CONTENT:
self._drop_depth = max(0, self._drop_depth - 1)
return
if self._drop_depth or tag not in self._ALLOWED_TAGS or tag in self._VOID_TAGS:
return
self._parts.append(f"</{tag}>")
def handle_data(self, data):
if not self._drop_depth:
self._parts.append(html.escape(data or ""))
def handle_entityref(self, name):
if not self._drop_depth:
self._parts.append(f"&{name};")
def handle_charref(self, name):
if not self._drop_depth:
self._parts.append(f"&#{name};")
def get_html(self) -> str:
return "".join(self._parts).strip()
def sanitize_safe_html(value: Optional[str]) -> str:
"""Return safe renderable HTML while preserving ordinary plain text."""
raw = str(value or "").strip()
if not raw:
return ""
if "<" not in raw and ">" not in raw:
return html.escape(raw)
sanitizer = _SafeHtmlSanitizer()
try:
sanitizer.feed(raw)
sanitizer.close()
return sanitizer.get_html()
except Exception:
return html.escape(raw)

View File

@ -99,6 +99,70 @@ async def get_vendor(vendor_id: int):
return result[0] return result[0]
@router.get("/vendors/{vendor_id}/invoices", tags=["Vendors"])
async def get_vendor_invoices(vendor_id: int):
"""Return booked invoices together with invoices that only exist as extractions."""
if not execute_query_single("SELECT id FROM vendors WHERE id = %s", (vendor_id,)):
raise HTTPException(status_code=404, detail="Vendor not found")
rows = execute_query(
"""
WITH booked AS (
SELECT si.id, si.invoice_number, si.invoice_date, si.due_date,
si.total_amount, si.currency,
CASE
WHEN si.status IN ('cancelled', 'credited', 'rejected') THEN si.status
WHEN si.paid_date IS NOT NULL THEN 'paid'
WHEN si.due_date < CURRENT_DATE AND si.paid_date IS NULL THEN 'overdue'
ELSE si.status
END AS status,
'supplier_invoice'::text AS source_type,
si.extraction_id, e.file_id, si.created_at AS sort_date
FROM supplier_invoices si
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
WHERE si.vendor_id = %s
),
latest_extractions AS (
SELECT DISTINCT ON (TRIM(e.document_id))
NULL::integer AS id, e.document_id AS invoice_number,
e.document_date AS invoice_date, e.due_date, e.total_amount,
e.currency, COALESCE(run.status, e.status, file.status, 'extracted') AS status,
'extraction'::text AS source_type,
e.extraction_id, e.file_id,
COALESCE(run.processed_at, e.created_at) AS sort_date
FROM extractions e
LEFT JOIN incoming_files file ON file.file_id = e.file_id
LEFT JOIN LATERAL (
SELECT sync.status, sync.processed_at
FROM internet_connections_invoice_sync_runs sync
WHERE sync.extraction_id = e.extraction_id
OR (
sync.invoice_number = e.document_id
AND COALESCE(sync.vendor_name, '') = COALESCE(e.vendor_name, '')
)
ORDER BY sync.processed_at DESC, sync.id DESC
LIMIT 1
) run ON TRUE
WHERE e.vendor_matched_id = %s
AND NULLIF(TRIM(e.document_id), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM supplier_invoices si
WHERE si.vendor_id = %s
AND (si.extraction_id = e.extraction_id OR si.invoice_number = e.document_id)
)
ORDER BY TRIM(e.document_id), e.created_at DESC, e.extraction_id DESC
)
SELECT * FROM booked
UNION ALL
SELECT * FROM latest_extractions
ORDER BY sort_date DESC NULLS LAST, invoice_date DESC NULLS LAST
""",
(vendor_id, vendor_id, vendor_id),
)
return rows or []
@router.post("/vendors", response_model=Vendor, tags=["Vendors"]) @router.post("/vendors", response_model=Vendor, tags=["Vendors"])
async def create_vendor(vendor: VendorCreate): async def create_vendor(vendor: VendorCreate):
"""Create a new vendor""" """Create a new vendor"""

View File

@ -627,7 +627,7 @@ function displayVendor(vendor) {
async function loadVendorInvoices() { async function loadVendorInvoices() {
try { try {
const response = await fetch(`/api/v1/supplier-invoices?vendor_id=${vendorId}`); const response = await fetch(`/api/v1/vendors/${vendorId}/invoices`);
if (!response.ok) throw new Error('Failed to load invoices'); if (!response.ok) throw new Error('Failed to load invoices');
const invoices = await response.json(); const invoices = await response.json();
@ -673,7 +673,9 @@ function displayInvoices(invoices) {
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td> <td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
<td><span class="badge ${statusClass}">${statusText}</span></td> <td><span class="badge ${statusClass}">${statusText}</span></td>
<td class="text-end"> <td class="text-end">
<a href="/billing/supplier-invoices?invoice=${invoice.id}" class="btn btn-sm btn-outline-primary"> <a href="${invoice.source_type === 'supplier_invoice'
? `/billing/supplier-invoices?invoice=${invoice.id}`
: '/economy/internet-connections#invoice-sync'}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
</td> </td>
@ -688,7 +690,15 @@ function getInvoiceStatusClass(status) {
'paid': 'bg-success', 'paid': 'bg-success',
'overdue': 'bg-danger', 'overdue': 'bg-danger',
'cancelled': 'bg-secondary', 'cancelled': 'bg-secondary',
'pending': 'bg-info' 'credited': 'bg-secondary',
'rejected': 'bg-secondary',
'pending': 'bg-info',
'success': 'bg-success',
'warning': 'bg-warning text-dark',
'error': 'bg-danger',
'extracted': 'bg-info text-dark',
'ai_extracted': 'bg-info text-dark',
'processed': 'bg-success'
}; };
return classes[status] || 'bg-secondary'; return classes[status] || 'bg-secondary';
} }
@ -699,7 +709,15 @@ function getInvoiceStatusText(status) {
'paid': 'Betalt', 'paid': 'Betalt',
'overdue': 'Forfalden', 'overdue': 'Forfalden',
'cancelled': 'Annulleret', 'cancelled': 'Annulleret',
'pending': 'Afventer' 'credited': 'Krediteret',
'rejected': 'Afvist',
'pending': 'Afventer',
'success': 'Behandlet',
'warning': 'Kræver kontrol',
'error': 'Fejl',
'extracted': 'Udtrukket',
'ai_extracted': 'Udtrukket',
'processed': 'Behandlet'
}; };
return texts[status] || status; return texts[status] || status;
} }

View File

@ -149,6 +149,8 @@ from app.modules.internet_connections.backend import router as internet_connecti
from app.modules.internet_connections.frontend import views as internet_connections_views from app.modules.internet_connections.frontend import views as internet_connections_views
from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api from app.modules.invoice_error_finder.backend import router as invoice_error_finder_api
from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views from app.modules.invoice_error_finder.frontend import views as invoice_error_finder_views
from app.modules.migration_center.backend import router as migration_center_api
from app.modules.migration_center.frontend import views as migration_center_views
from app.bug_reports.backend import router as bug_reports_api from app.bug_reports.backend import router as bug_reports_api
# Configure logging # Configure logging
@ -500,6 +502,7 @@ app.include_router(task_templates_api.router, prefix="/api/v1", tags=["Task Temp
app.include_router(drift_api, prefix="/api/v1", tags=["Drift"]) app.include_router(drift_api, prefix="/api/v1", tags=["Drift"])
app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"]) app.include_router(internet_connections_api.router, prefix="/api/v1", tags=["Internetforbindelser"])
app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"]) app.include_router(invoice_error_finder_api.router, prefix="/api/v1/invoice-error-finder", tags=["Invoice Error Finder"])
app.include_router(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.backend import router as links_api from app.modules.links.backend import router as links_api
@ -539,6 +542,7 @@ app.include_router(manual_views.router, tags=["Frontend"])
app.include_router(drift_views.router, tags=["Frontend"]) app.include_router(drift_views.router, tags=["Frontend"])
app.include_router(internet_connections_views.router, tags=["Frontend"]) app.include_router(internet_connections_views.router, tags=["Frontend"])
app.include_router(invoice_error_finder_views.router, tags=["Frontend"]) app.include_router(invoice_error_finder_views.router, tags=["Frontend"])
app.include_router(migration_center_views.router, tags=["Frontend"])
if settings.LINKS_MODULE_ENABLED: if settings.LINKS_MODULE_ENABLED:
from app.modules.links.frontend import views as links_views from app.modules.links.frontend import views as links_views

View File

@ -0,0 +1,7 @@
-- Mark wall outlets and their related patch/switch ports as WAN connections.
ALTER TABLE locations_wall_outlets
ADD COLUMN IF NOT EXISTS is_wan BOOLEAN NOT NULL DEFAULT FALSE;
CREATE INDEX IF NOT EXISTS idx_wall_outlets_is_wan
ON locations_wall_outlets(is_wan)
WHERE deleted_at IS NULL AND is_wan = TRUE;

View File

@ -0,0 +1,31 @@
-- Permanent audit trail for supplier invoices processed into internet connections.
CREATE TABLE IF NOT EXISTS internet_connections_invoice_sync_runs (
id BIGSERIAL PRIMARY KEY,
file_id INTEGER REFERENCES incoming_files(file_id) ON DELETE SET NULL,
extraction_id INTEGER REFERENCES extractions(extraction_id) ON DELETE SET NULL,
supplier_invoice_id INTEGER REFERENCES supplier_invoices(id) ON DELETE SET NULL,
invoice_number VARCHAR(100),
vendor_name VARCHAR(255),
invoice_date DATE,
status VARCHAR(20) NOT NULL
CHECK (status IN ('success', 'warning', 'skipped', 'error')),
connections_synced INTEGER NOT NULL DEFAULT 0,
connections_created INTEGER NOT NULL DEFAULT 0,
connections_updated INTEGER NOT NULL DEFAULT 0,
ip_ranges_synced INTEGER NOT NULL DEFAULT 0,
total_lines INTEGER NOT NULL DEFAULT 0,
actionable_lines INTEGER NOT NULL DEFAULT 0,
skipped_lines INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
result_json JSONB NOT NULL DEFAULT '{}'::jsonb,
processed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_processed
ON internet_connections_invoice_sync_runs(processed_at DESC);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_status
ON internet_connections_invoice_sync_runs(status, processed_at DESC);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_sync_runs_invoice
ON internet_connections_invoice_sync_runs(invoice_number, vendor_name);

View File

@ -0,0 +1,15 @@
CREATE TABLE IF NOT EXISTS internet_connections_invoice_review_decisions (
id BIGSERIAL PRIMARY KEY,
run_id BIGINT NOT NULL REFERENCES internet_connections_invoice_sync_runs(id) ON DELETE CASCADE,
line_number INTEGER NOT NULL,
action VARCHAR(30) NOT NULL
CHECK (action IN ('ignore', 'link_existing', 'create_separate')),
connection_id INTEGER REFERENCES internet_connections_connections(id) ON DELETE SET NULL,
note TEXT,
resolved_by_user_id INTEGER,
resolved_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (run_id, line_number)
);
CREATE INDEX IF NOT EXISTS idx_internet_invoice_review_decisions_run
ON internet_connections_invoice_review_decisions(run_id, line_number);

View File

@ -0,0 +1,6 @@
INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES
('mattermost_reminders_enabled', 'false', 'notifications', 'Send reminders to Mattermost', 'boolean', false),
('mattermost_webhook_url', '', 'notifications', 'Mattermost incoming webhook URL', 'string', false),
('mattermost_channel', '', 'notifications', 'Optional Mattermost channel override', 'string', false)
ON CONFLICT (key) DO NOTHING;

View File

@ -0,0 +1,2 @@
ALTER TABLE user_notification_preferences
ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);

View File

@ -0,0 +1,24 @@
-- Dedicated permissions for the Sag module.
-- Existing group access is preserved by copying equivalent ticket permissions.
INSERT INTO permissions (code, description, category) VALUES
('cases.view', 'Se sager', 'cases'),
('cases.create', 'Opret sager', 'cases'),
('cases.edit', 'Redigér sager, relationer, filer og reminders', 'cases'),
('cases.delete', 'Slet sager', 'cases')
ON CONFLICT (code) DO NOTHING;
WITH permission_map(case_code, ticket_code) AS (
VALUES
('cases.view', 'tickets.view'),
('cases.create', 'tickets.create'),
('cases.edit', 'tickets.edit'),
('cases.delete', 'tickets.delete')
)
INSERT INTO group_permissions (group_id, permission_id)
SELECT DISTINCT gp.group_id, case_permission.id
FROM group_permissions gp
JOIN permissions ticket_permission ON ticket_permission.id = gp.permission_id
JOIN permission_map mapping ON mapping.ticket_code = ticket_permission.code
JOIN permissions case_permission ON case_permission.code = mapping.case_code
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,185 @@
-- Migration 228: Manual subscription and invoice migration centre.
-- e-conomic data is deliberately referenced from Invoice Error Finder and never copied back.
CREATE TABLE IF NOT EXISTS migration_center_sessions (
id SERIAL PRIMARY KEY,
name VARCHAR(160) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active'
CHECK (status IN ('draft', 'active', 'completed', 'archived')),
economic_import_run_id INTEGER REFERENCES invoice_error_finder_import_runs(id) ON DELETE RESTRICT,
economic_snapshot_at TIMESTAMP,
read_only BOOLEAN NOT NULL DEFAULT FALSE,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS migration_center_source_customers (
id BIGSERIAL PRIMARY KEY,
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
source_customer_id VARCHAR(120) NOT NULL,
customer_no VARCHAR(80),
customer_name VARCHAR(255) NOT NULL,
cvr VARCHAR(32),
email VARCHAR(255),
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
snapshot_hash CHAR(64) NOT NULL,
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_system, source_customer_id)
);
CREATE TABLE IF NOT EXISTS migration_center_source_subscriptions (
id BIGSERIAL PRIMARY KEY,
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply')),
source_record_id VARCHAR(120) NOT NULL,
source_customer_id VARCHAR(120),
customer_no VARCHAR(80),
customer_name VARCHAR(255),
product_code VARCHAR(100),
product_name VARCHAR(500) NOT NULL,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
billing_frequency VARCHAR(40),
start_date DATE,
end_date DATE,
active BOOLEAN NOT NULL DEFAULT TRUE,
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
snapshot_hash CHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_system, source_record_id)
);
CREATE TABLE IF NOT EXISTS migration_center_session_items (
id BIGSERIAL PRIMARY KEY,
session_id INTEGER NOT NULL REFERENCES migration_center_sessions(id) ON DELETE CASCADE,
entity_type VARCHAR(20) NOT NULL CHECK (entity_type IN ('subscription', 'invoice_line')),
source_system VARCHAR(30) NOT NULL CHECK (source_system IN ('vtiger', 'simply', 'economic')),
source_record_id VARCHAR(180) NOT NULL,
source_customer_id VARCHAR(120),
customer_no VARCHAR(80),
customer_name VARCHAR(255),
product_code VARCHAR(100),
product_name VARCHAR(500) NOT NULL,
amount NUMERIC(14,2) NOT NULL DEFAULT 0,
quantity NUMERIC(14,4) NOT NULL DEFAULT 1,
billing_frequency VARCHAR(40),
period_from DATE,
period_to DATE,
invoice_no VARCHAR(80),
invoice_date DATE,
source_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
source_hash CHAR(64) NOT NULL,
previous_source_payload JSONB,
match_status VARCHAR(30) NOT NULL DEFAULT 'new'
CHECK (match_status IN ('new', 'match_found', 'no_match', 'conflict', 'manual_review', 'source_changed')),
approval_status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (approval_status IN ('pending', 'approved', 'rejected', 'verified', 'ignored')),
hub_status VARCHAR(30) NOT NULL DEFAULT 'not_created'
CHECK (hub_status IN ('not_created', 'ready_for_creation', 'created_in_hub', 'linked_to_existing', 'verified')),
lock_status VARCHAR(25) NOT NULL DEFAULT 'unlocked'
CHECK (lock_status IN ('unlocked', 'locking_pending', 'locked', 'lock_failed')),
match_confidence NUMERIC(5,4),
match_explanation JSONB NOT NULL DEFAULT '[]'::jsonb,
hub_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
hub_sag_id INTEGER REFERENCES sag_sager(id) ON DELETE SET NULL,
hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
suggested_hub_record_id INTEGER REFERENCES sag_subscriptions(id) ON DELETE SET NULL,
creation_idempotency_key VARCHAR(120),
ignore_reason TEXT,
manual_note TEXT,
verified_at TIMESTAMP,
verified_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
locked_at TIMESTAMP,
locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (session_id, entity_type, source_system, source_record_id)
);
ALTER TABLE migration_center_session_items
ADD COLUMN IF NOT EXISTS creation_idempotency_key VARCHAR(120);
CREATE INDEX IF NOT EXISTS idx_mc_items_session_type ON migration_center_session_items(session_id, entity_type);
CREATE INDEX IF NOT EXISTS idx_mc_items_work_queue ON migration_center_session_items(session_id, lock_status, approval_status);
CREATE INDEX IF NOT EXISTS idx_mc_items_customer ON migration_center_session_items(session_id, hub_customer_id);
CREATE UNIQUE INDEX IF NOT EXISTS uq_mc_items_creation_idempotency
ON migration_center_session_items(session_id, creation_idempotency_key)
WHERE creation_idempotency_key IS NOT NULL;
CREATE TABLE IF NOT EXISTS migration_center_matches (
id BIGSERIAL PRIMARY KEY,
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
matched_entity_type VARCHAR(30) NOT NULL,
matched_hub_id INTEGER NOT NULL,
confidence NUMERIC(5,4) NOT NULL,
rules JSONB NOT NULL DEFAULT '[]'::jsonb,
approved BOOLEAN,
approved_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
approved_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (session_item_id, matched_entity_type, matched_hub_id)
);
CREATE TABLE IF NOT EXISTS migration_center_lock_operations (
id BIGSERIAL PRIMARY KEY,
session_item_id BIGINT NOT NULL REFERENCES migration_center_session_items(id) ON DELETE CASCADE,
status VARCHAR(25) NOT NULL CHECK (status IN ('pending', 'succeeded', 'failed')),
attempt_no INTEGER NOT NULL DEFAULT 1,
external_system VARCHAR(30),
request_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
response_payload JSONB,
error_message TEXT,
requested_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS migration_center_audit_log (
id BIGSERIAL PRIMARY KEY,
session_id INTEGER REFERENCES migration_center_sessions(id) ON DELETE SET NULL,
session_item_id BIGINT REFERENCES migration_center_session_items(id) ON DELETE SET NULL,
entity_type VARCHAR(40) NOT NULL,
entity_id VARCHAR(180),
action VARCHAR(80) NOT NULL,
old_value JSONB,
new_value JSONB,
source_hash CHAR(64),
performed_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
ip_address VARCHAR(45),
success BOOLEAN NOT NULL DEFAULT TRUE,
error_message TEXT,
performed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_mc_audit_session_time ON migration_center_audit_log(session_id, performed_at DESC);
ALTER TABLE sag_subscriptions
ADD COLUMN IF NOT EXISTS migration_locked BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS migration_locked_at TIMESTAMP,
ADD COLUMN IF NOT EXISTS migration_locked_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS migration_source_item_id BIGINT;
CREATE UNIQUE INDEX IF NOT EXISTS uq_sag_subscription_migration_source
ON sag_subscriptions(migration_source_item_id)
WHERE migration_source_item_id IS NOT NULL;
INSERT INTO permissions (code, description, category) VALUES
('migration_center.view', 'View migration centre', 'migration_center'),
('migration_center.sessions', 'Create and manage migration sessions', 'migration_center'),
('migration_center.import', 'Import CRM subscription snapshots', 'migration_center'),
('migration_center.create', 'Create customers, cases and subscriptions from migration centre', 'migration_center'),
('migration_center.review', 'Link, verify and ignore migration records', 'migration_center'),
('migration_center.lock', 'Lock migration records', 'migration_center'),
('migration_center.retry', 'Retry failed external locks', 'migration_center'),
('migration_center.export', 'Export migration control reports', 'migration_center')
ON CONFLICT (code) DO NOTHING;
INSERT INTO group_permissions (group_id, permission_id)
SELECT g.id, p.id
FROM groups g
JOIN permissions p ON p.category = 'migration_center'
WHERE g.name = 'Administrators'
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,11 @@
-- Speed up the subscription-like invoice line classifier and company filters.
CREATE INDEX IF NOT EXISTS idx_mc_items_subscription_visibility
ON migration_center_session_items
(session_id, source_system, entity_type, customer_no, product_code, invoice_date);
CREATE INDEX IF NOT EXISTS idx_mc_items_company_name
ON migration_center_session_items (session_id, LOWER(customer_name));
CREATE INDEX IF NOT EXISTS idx_sag_subscriptions_customer_price
ON sag_subscriptions (customer_id, price)
WHERE status <> 'cancelled';

View File

@ -0,0 +1,6 @@
ALTER TABLE migration_center_session_items
ADD COLUMN IF NOT EXISTS subscription_like BOOLEAN NOT NULL DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS subscription_relevance_reason VARCHAR(40);
CREATE INDEX IF NOT EXISTS idx_mc_items_visible
ON migration_center_session_items (session_id, subscription_like, entity_type, invoice_date DESC);

View File

@ -0,0 +1,122 @@
from datetime import datetime, timezone
from decimal import Decimal
import jwt
import pytest
from fastapi import HTTPException
from app.modules.migration_center.backend import router
from app.modules.migration_center.backend.service import (
EconomicSnapshotRepository,
_resolve_source_customer,
preflight_token,
snapshot_hash,
verify_preflight,
)
def test_snapshot_hash_is_stable_for_key_order_and_decimal():
first = snapshot_hash({"amount": Decimal("10.00"), "nested": {"b": 2, "a": 1}})
second = snapshot_hash({"nested": {"a": 1, "b": 2}, "amount": Decimal("10.00")})
assert first == second
assert len(first) == 64
def test_normalizes_vtiger_subscription():
item = router._normalized_crm_record(
"vtiger",
{
"id": "72x123",
"account_id": "3x44",
"accountname": "Kunde A/S",
"subject": "Driftsaftale",
"total": "1.250",
"startdate": "2026-01-01",
"subscriptionstatus": "active",
},
)
assert item["entity_type"] == "subscription"
assert item["source_record_id"] == "72x123"
assert item["source_customer_id"] == "3x44"
assert item["product_name"] == "Driftsaftale"
assert item["period_from"].isoformat() == "2026-01-01"
assert len(item["source_hash"]) == 64
def test_source_frequency_is_normalized_to_hub_interval():
assert router._hub_interval("monthly_3_last_day") == "monthly"
assert router._hub_interval("Quarterly") == "quarterly"
assert router._hub_interval("Annual") == "yearly"
def test_economic_repository_is_read_only_query(monkeypatch):
captured = {}
def fake_query(sql, params=None):
captured["sql"] = sql
captured["params"] = params
return []
monkeypatch.setattr(
"app.modules.migration_center.backend.service.execute_query",
fake_query,
)
assert EconomicSnapshotRepository.lines(42) == []
normalized = " ".join(captured["sql"].split()).upper()
assert normalized.startswith("SELECT")
assert all(keyword not in normalized for keyword in ("INSERT INTO", "UPDATE ", "DELETE FROM"))
assert captured["params"] == (42,)
assert "DATE_TRUNC('MONTH', CURRENT_DATE) - INTERVAL '12 MONTHS'" in normalized
def test_preflight_token_is_bound_to_item_hash_and_user(monkeypatch):
monkeypatch.setattr(
"app.modules.migration_center.backend.service.settings.JWT_SECRET_KEY",
"test-migration-secret",
)
item = {"id": 9, "source_hash": "a" * 64}
user = {"id": 7}
token = preflight_token(item, user)
verify_preflight(token, item, user)
with pytest.raises(HTTPException) as error:
verify_preflight(token, {**item, "source_hash": "b" * 64}, user)
assert error.value.status_code == 409
def test_economic_line_text_is_bounded_for_staging_columns():
item = __import__(
"app.modules.migration_center.backend.service",
fromlist=["_normalize_economic_line"],
)._normalize_economic_line(
{
"source_type": "booked", "source_invoice_number": "1", "invoice_id": 1,
"line_number": 1, "invoice_line_id": 2, "customer_number": 3,
"customer_name": "K" * 300, "description": "P" * 800,
"product_number": "X" * 150, "line_net_amount": 100, "quantity": 1,
}
)
assert len(item["customer_name"]) == 255
assert len(item["product_name"]) == 500
assert len(item["product_code"]) == 100
def test_customer_lock_blocker_states_are_explicit():
assert {"unlocked", "lock_failed"} == router.MUTABLE_LOCK_STATES
def test_simply_customer_resolution_reuses_existing_staging_mapping(monkeypatch):
monkeypatch.setattr(
"app.modules.migration_center.backend.service.execute_query_single",
lambda sql, params: {
"customer_name": "Korrekt Firma A/S",
"cvr": "12345678",
"hub_customer_id": 44,
},
)
result = _resolve_source_customer(
{"source_system": "simply", "source_customer_id": "11x123"}
)
assert result["hub_customer_id"] == 44
assert result["customer_name"] == "Korrekt Firma A/S"
assert result["rule"] == "Eksisterende Simply-kundemapping"

View File

@ -1,86 +1,199 @@
import sys from datetime import datetime
from io import BytesIO
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) import asyncio
import sys
import types
import pytest import pytest
from fastapi.testclient import TestClient from fastapi import HTTPException, UploadFile, Request
from app.main import app
from app.core.database import execute_query
client = TestClient(app) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@pytest.fixture(scope="function", autouse=True) from app.utils.safe_html import sanitize_safe_html
def setup_and_teardown():
"""Setup and teardown for each test."""
# Setup: Clear the database tables before each test
execute_query("DELETE FROM sag_tags;")
execute_query("DELETE FROM sag_relationer;")
execute_query("DELETE FROM sag_sager;")
yield
# Teardown: Clear the database tables after each test
execute_query("DELETE FROM sag_tags;")
execute_query("DELETE FROM sag_relationer;")
execute_query("DELETE FROM sag_sager;")
def test_create_case(): # Keep these focused unit tests independent of optional auth runtime packages.
"""Test creating a new case.""" auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
response = client.post("/api/v1/cases", json={
"titel": "Test Case",
"beskrivelse": "This is a test case.",
"template_key": "ticket",
"status": "åben",
"customer_id": 1,
"ansvarlig_bruger_id": 2,
"created_by_user_id": 3,
"deadline": "2026-02-01T12:00:00"
})
assert response.status_code == 200
data = response.json()
assert data["titel"] == "Test Case"
assert data["status"] == "åben"
def test_list_cases():
"""Test listing cases."""
# Create a case
client.post("/api/v1/cases", json={
"titel": "Test Case",
"beskrivelse": "This is a test case.",
"template_key": "ticket",
"status": "åben",
"customer_id": 1,
"ansvarlig_bruger_id": 2,
"created_by_user_id": 3,
"deadline": "2026-02-01T12:00:00"
})
# List cases def _allow_test_user(*_permissions):
response = client.get("/api/v1/cases") async def dependency(_request: Request):
assert response.status_code == 200 return {"id": 1, "username": "test", "permissions": []}
data = response.json()
assert len(data) == 1
assert data[0]["titel"] == "Test Case"
def test_soft_delete_case(): return dependency
"""Test soft-deleting a case."""
# Create a case
response = client.post("/api/v1/cases", json={
"titel": "Test Case",
"beskrivelse": "This is a test case.",
"template_key": "ticket",
"status": "åben",
"customer_id": 1,
"ansvarlig_bruger_id": 2,
"created_by_user_id": 3,
"deadline": "2026-02-01T12:00:00"
})
case_id = response.json()["id"]
# Soft-delete the case
delete_response = client.delete(f"/api/v1/cases/{case_id}")
assert delete_response.status_code == 200
# Verify the case is soft-deleted auth_dependencies_stub.require_any_permission = _allow_test_user
list_response = client.get("/api/v1/cases")
assert list_response.status_code == 200
data = list_response.json() async def _get_test_user(_request: Request):
assert len(data) == 0 return {
"id": 1,
"username": "test",
"permissions": ["cases.view", "cases.create", "cases.edit", "cases.delete"],
}
auth_dependencies_stub.get_current_user = _get_test_user
sys.modules.setdefault("app.core.auth_dependencies", auth_dependencies_stub)
from app.modules.sag.backend import router as sag_router
def test_normalize_timestamp_converts_offset_to_utc():
value = sag_router._normalize_optional_timestamp(
"2026-07-27T12:00:00+02:00",
"deadline",
)
assert value == "2026-07-27 10:00:00"
def test_normalize_timestamp_accepts_naive_datetime():
value = sag_router._normalize_optional_timestamp(
datetime(2026, 7, 27, 12, 30),
"deadline",
)
assert value == "2026-07-27 12:30:00"
def test_normalize_timestamp_rejects_invalid_value():
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_optional_timestamp("not-a-date", "deadline")
assert exc_info.value.status_code == 400
def test_attachment_path_rejects_escape_from_upload_root():
with pytest.raises(HTTPException) as exc_info:
sag_router._resolve_attachment_path("../../outside.txt")
assert exc_info.value.status_code == 400
def test_attachment_path_accepts_case_subdirectory():
path = sag_router._resolve_attachment_path("sag_files/example.txt")
path.relative_to(sag_router.UPLOAD_BASE_PATH)
def test_upload_rejects_disallowed_extension():
upload = UploadFile(filename="payload.html", file=BytesIO(b"<script>alert(1)</script>"))
with pytest.raises(HTTPException) as exc_info:
sag_router._store_upload_file(upload, sag_router.SAG_FILE_SUBDIR)
assert exc_info.value.status_code == 400
assert "not allowed" in str(exc_info.value.detail)
def test_relation_input_normalizes_valid_relation():
target_id, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": "11", "relationstype": " Blokkerer "},
)
assert target_id == 11
assert relation_type == "Blokkerer"
@pytest.mark.parametrize(
("raw_type", "expected"),
[
("Relateret til", "Relateret til"),
("Afledt af", "Afledt af"),
("Årsag til", "Årsag til"),
("afledt_af", "Afledt af"),
("afhænger af", "afhænger af"),
("undersag", "undersag"),
("duplikat", "duplikat"),
],
)
def test_relation_input_accepts_ui_and_existing_database_types(raw_type, expected):
_, relation_type = sag_router._normalize_relation_input(
10,
{"målsag_id": 11, "relationstype": raw_type},
)
assert relation_type == expected
def test_relation_quick_task_uses_todo_steps_api():
template = Path("app/modules/sag/templates/detail_v3.html").read_text()
assert "fetch(`/api/v1/sag/${caseId}/todo-steps`" in template
assert "fetch(`/api/v1/sag/${caseId}/todos`" not in template
assert "due_date: due" in template
def test_safe_case_html_renders_formatting_and_drops_executable_code():
result = sanitize_safe_html(
'<style>body{display:none}</style>'
'<p onclick="steal()">Hej <strong>verden</strong></p>'
'<script>alert("xss")</script>'
'<a href="javascript:alert(1)">farligt link</a>'
)
assert result == (
'<p>Hej <strong>verden</strong></p>'
'<a target="_blank" rel="noopener noreferrer">farligt link</a>'
)
assert "display:none" not in result
assert "alert" not in result
assert "onclick" not in result
@pytest.mark.parametrize(
"payload",
[
{"målsag_id": 10, "relationstype": "barn"},
{"målsag_id": 11, "relationstype": "ukendt"},
{"målsag_id": "ikke-et-tal", "relationstype": "barn"},
],
)
def test_relation_input_rejects_invalid_relation(payload):
with pytest.raises(HTTPException) as exc_info:
sag_router._normalize_relation_input(10, payload)
assert exc_info.value.status_code == 400
def _request(method: str, path: str) -> Request:
return Request(
{
"type": "http",
"method": method,
"path": path,
"raw_path": path.encode(),
"query_string": b"",
"headers": [],
"scheme": "https",
"server": ("testserver", 443),
"client": ("127.0.0.1", 1234),
}
)
def test_case_route_access_allows_view_permission_for_get():
user = {"permissions": ["cases.view"]}
result = asyncio.run(
sag_router.case_route_access(_request("GET", "/api/v1/sag/10"), user)
)
assert result is user
def test_case_route_access_requires_edit_for_nested_post():
user = {"permissions": ["cases.view", "cases.create"]}
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
sag_router.case_route_access(
_request("POST", "/api/v1/sag/10/tags"),
user,
)
)
assert exc_info.value.status_code == 403