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:
parent
428ad21132
commit
d81a8f41b4
@ -2,6 +2,7 @@
|
||||
# POSTGRESQL DATABASE - Local Development
|
||||
# =====================================================
|
||||
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
||||
HUB_BASE_URL=https://hub.bmcnetworks.dk
|
||||
|
||||
# Database credentials (bruges af docker-compose)
|
||||
POSTGRES_USER=bmc_hub
|
||||
|
||||
@ -24,6 +24,7 @@ GITHUB_REPO=ct/bmc_hub
|
||||
# POSTGRESQL DATABASE - Production
|
||||
# =====================================================
|
||||
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)
|
||||
POSTGRES_USER=bmc_hub_prod
|
||||
|
||||
@ -5,6 +5,7 @@ Sends rich formatted notifications to Mattermost webhook
|
||||
|
||||
import logging
|
||||
import aiohttp
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional, List
|
||||
from app.core.config import settings
|
||||
@ -346,7 +347,7 @@ class MattermostNotification:
|
||||
"""
|
||||
if not self.enabled or not self.webhook_url:
|
||||
logger.info("📢 Notification (disabled): %s - job_id=%s", event_type, job_id)
|
||||
return
|
||||
return False, "Mattermost is disabled or webhook URL is missing"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
@ -355,21 +356,63 @@ class MattermostNotification:
|
||||
logger.info("📢 Notification sent: %s - job_id=%s", event_type, job_id)
|
||||
|
||||
# Log to database
|
||||
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', ''), str(payload))
|
||||
(job_id, event_type, payload.get('text', ''), json.dumps(payload, ensure_ascii=False))
|
||||
)
|
||||
return True, "Mattermost notification sent"
|
||||
else:
|
||||
error_text = await response.text()
|
||||
should_retry_form = (
|
||||
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:
|
||||
logger.error("❌ Notification connection error: %s", str(e))
|
||||
return False, f"Mattermost connection error: {e}"
|
||||
except Exception as e:
|
||||
logger.error("❌ Notification error: %s", str(e))
|
||||
return False, f"Mattermost notification error: {e}"
|
||||
|
||||
def _should_send_notification(self, event_type: str) -> bool:
|
||||
"""Check if notification should be sent based on settings"""
|
||||
@ -386,8 +429,7 @@ class MattermostNotification:
|
||||
|
||||
def _get_hub_url(self) -> str:
|
||||
"""Get BMC Hub base URL for action buttons"""
|
||||
# TODO: Add HUB_BASE_URL to config
|
||||
return "http://localhost:8000" # Fallback
|
||||
return str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").strip().rstrip("/")
|
||||
|
||||
|
||||
# Singleton instance
|
||||
|
||||
@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
_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")
|
||||
|
||||
@ -256,7 +267,15 @@ def _ensure_internet_change_case(
|
||||
owner_customer_id: Optional[int],
|
||||
changes: Dict[str, Dict[str, object]],
|
||||
) -> 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
|
||||
|
||||
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
|
||||
@ -287,7 +306,7 @@ def _ensure_internet_change_case(
|
||||
return None
|
||||
change_lines = "\n".join(
|
||||
f"- {field}: {change.get('from')} -> {change.get('to')}"
|
||||
for field, change in changes.items()
|
||||
for field, change in relevant_changes.items()
|
||||
)
|
||||
description = (
|
||||
"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)
|
||||
address_parts = [part.strip().upper() for part in re.split(r"[, ]+", service_address) if part.strip()]
|
||||
|
||||
best_match = None
|
||||
best_matches = []
|
||||
best_score = 0
|
||||
for customer in customers:
|
||||
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:
|
||||
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:
|
||||
@ -1166,7 +1193,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
)
|
||||
primary_line = sorted_lines[0]
|
||||
display_reference = str(primary_line.get("provider_reference") or primary_line.get("circuit_id") or reference).strip()
|
||||
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
|
||||
service_address = _build_service_address(primary_line)
|
||||
if not service_address:
|
||||
logger.warning(
|
||||
@ -1175,9 +1201,40 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
invoice_number,
|
||||
)
|
||||
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)
|
||||
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
|
||||
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
|
||||
connection_name = (
|
||||
@ -1194,21 +1251,6 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
else:
|
||||
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
|
||||
|
||||
existing_resolution = _resolve_existing_globalconnect_connection(reference, service_address)
|
||||
existing = existing_resolution.get("row")
|
||||
conflict_reason = existing_resolution.get("conflict_reason")
|
||||
if conflict_reason:
|
||||
logger.warning(
|
||||
"Skipping GlobalConnect connection %s from invoice %s because %s",
|
||||
display_reference or reference,
|
||||
invoice_number,
|
||||
conflict_reason,
|
||||
)
|
||||
return None
|
||||
merge_ids = [int(item) for item in (existing_resolution.get("merge_ids") or [])]
|
||||
if existing and merge_ids:
|
||||
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
|
||||
|
||||
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
|
||||
target_status = "active" if (is_confident or internal_owner) else "pending"
|
||||
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
|
||||
@ -1540,13 +1582,76 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
|
||||
)
|
||||
if not row:
|
||||
return False
|
||||
if str(row.get("allocation_model") or "").lower() == "shared":
|
||||
return True
|
||||
if not service_address:
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
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:
|
||||
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"]
|
||||
@ -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):
|
||||
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:
|
||||
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:
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
service_address = _build_service_address(line)
|
||||
connection_id = connection_map.get(reference)
|
||||
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
|
||||
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):
|
||||
connection_id = None
|
||||
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
|
||||
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 (
|
||||
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
|
||||
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]:
|
||||
sku_value = str(sku or "").strip()
|
||||
desc_value = str(description or "").strip()
|
||||
@ -3078,7 +3354,7 @@ async def sync_extraction_to_internet(file_id: int):
|
||||
if not extraction:
|
||||
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 {
|
||||
"status": "success",
|
||||
"file_id": file_id,
|
||||
@ -4813,6 +5089,17 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
if vendor:
|
||||
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
|
||||
|
||||
except HTTPException:
|
||||
|
||||
@ -20,6 +20,7 @@ class Settings(BaseSettings):
|
||||
API_PORT: int = 8000
|
||||
API_RELOAD: bool = False
|
||||
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
||||
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
|
||||
|
||||
# Elnet supplier lookup
|
||||
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
||||
@ -68,6 +69,12 @@ class Settings(BaseSettings):
|
||||
ECONOMIC_READ_ONLY: 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_READ_ONLY: bool = True
|
||||
NEXTCLOUD_DRY_RUN: bool = True
|
||||
|
||||
@ -121,6 +121,10 @@ class MissionProjectLinkCasePayload(BaseModel):
|
||||
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]:
|
||||
for name in names:
|
||||
value = request.query_params.get(name)
|
||||
@ -351,6 +355,66 @@ async def get_mission_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")
|
||||
async def get_mission_projects(limit: int = Query(120, ge=1, le=500)):
|
||||
return {
|
||||
|
||||
@ -224,6 +224,52 @@ class MissionService:
|
||||
)
|
||||
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
|
||||
def get_active_alerts() -> list[Dict[str, Any]]:
|
||||
if not MissionService._table_exists("mission_uptime_alerts"):
|
||||
@ -412,6 +458,13 @@ class MissionService:
|
||||
0 AS score,
|
||||
s.start_date AS started_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,
|
||||
0 AS active_milestones,
|
||||
0 AS overdue_milestones,
|
||||
@ -426,6 +479,17 @@ class MissionService:
|
||||
ELSE 0
|
||||
END AS overdue_tasks
|
||||
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
|
||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
||||
AND (
|
||||
@ -448,8 +512,15 @@ class MissionService:
|
||||
|
||||
@staticmethod
|
||||
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"):
|
||||
return MissionService._get_projects_from_cases(limit)
|
||||
return []
|
||||
|
||||
rows = execute_query(
|
||||
"""
|
||||
@ -462,6 +533,12 @@ class MissionService:
|
||||
p.started_at,
|
||||
p.ended_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 (
|
||||
WHERE m.status NOT IN ('completed', 'cancelled')
|
||||
) AS active_milestones,
|
||||
@ -490,10 +567,49 @@ class MissionService:
|
||||
AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed')
|
||||
) AS overdue_tasks
|
||||
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_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
|
||||
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
|
||||
LIMIT %s
|
||||
""",
|
||||
@ -506,14 +622,11 @@ class MissionService:
|
||||
item.update(MissionService._compute_project_risk(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
|
||||
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
rows = MissionService.get_projects(limit=200)
|
||||
@ -556,15 +669,19 @@ class MissionService:
|
||||
s.titel,
|
||||
s.status,
|
||||
s.priority,
|
||||
s.start_date,
|
||||
s.deadline,
|
||||
s.ansvarlig_bruger_id,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
|
||||
s.project_milestone_id,
|
||||
s.is_project_blocker,
|
||||
s.project_task_type,
|
||||
s.created_at,
|
||||
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
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
@ -582,6 +699,24 @@ class MissionService:
|
||||
),
|
||||
NULL
|
||||
) 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
|
||||
WHERE t.sag_id = s.id
|
||||
) ts ON TRUE
|
||||
@ -626,8 +761,8 @@ class MissionService:
|
||||
if isinstance(titles_raw, list):
|
||||
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.
|
||||
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
|
||||
# Case-backed projects store their actual subcases as directed `undersag`
|
||||
# relations. Other relation types must not appear as project subcases.
|
||||
if not tasks and MissionService._table_exists("sag_relationer"):
|
||||
tasks = execute_query(
|
||||
"""
|
||||
@ -638,31 +773,27 @@ class MissionService:
|
||||
FROM sag_relationer sr
|
||||
WHERE sr.deleted_at IS NULL
|
||||
AND sr.kilde_sag_id = %s
|
||||
|
||||
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
|
||||
AND LOWER(TRIM(sr.relationstype)) IN ('undersag', 'barn')
|
||||
)
|
||||
SELECT
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.priority,
|
||||
s.start_date,
|
||||
s.deadline,
|
||||
s.ansvarlig_bruger_id,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name,
|
||||
s.project_milestone_id,
|
||||
s.is_project_blocker,
|
||||
COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type,
|
||||
s.created_at,
|
||||
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
|
||||
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 (
|
||||
SELECT
|
||||
COUNT(*) FILTER (
|
||||
@ -679,7 +810,24 @@ class MissionService:
|
||||
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
|
||||
),
|
||||
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
|
||||
WHERE t.sag_id = s.id
|
||||
) ts ON TRUE
|
||||
@ -690,7 +838,7 @@ class MissionService:
|
||||
s.created_at DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
(project_id, project_id, project_id),
|
||||
(project_id, project_id),
|
||||
) or []
|
||||
|
||||
return {
|
||||
|
||||
@ -190,7 +190,16 @@ async def search_sag(q: str):
|
||||
s.status,
|
||||
s.created_at,
|
||||
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
|
||||
LEFT JOIN customers c ON s.customer_id = c.id
|
||||
WHERE s.deleted_at IS NULL
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -9,12 +9,11 @@ from typing import List, Optional, Dict, Any
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
import unicodedata
|
||||
import html
|
||||
import re
|
||||
from html.parser import HTMLParser
|
||||
|
||||
from app.core.config import settings
|
||||
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_workflow_service import email_workflow_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
|
||||
|
||||
|
||||
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:
|
||||
raw = str(value or "").strip()
|
||||
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)
|
||||
return sanitize_safe_html(value)
|
||||
|
||||
|
||||
def _extract_domain_from_email(email: Optional[str]) -> str:
|
||||
|
||||
@ -1008,14 +1008,19 @@
|
||||
|
||||
/* Email Upload Drop Zone */
|
||||
.email-upload-zone {
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border-bottom: 1px solid rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.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);
|
||||
padding: 1.5rem 1rem;
|
||||
padding: 0.48rem 0.65rem;
|
||||
text-align: center;
|
||||
background: rgba(15, 76, 117, 0.03);
|
||||
cursor: pointer;
|
||||
@ -1036,15 +1041,24 @@
|
||||
}
|
||||
|
||||
.drop-zone i {
|
||||
font-size: 2rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.drop-zone p {
|
||||
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 {
|
||||
@ -1122,7 +1136,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<i class="bi bi-cloud-upload"></i>
|
||||
<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>
|
||||
</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">
|
||||
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
|
||||
@ -5278,16 +5287,6 @@ function showNotification(message, type = 'info') {
|
||||
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
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
@ -5398,7 +5397,6 @@ async function uploadEmailFiles(files) {
|
||||
console.log('✅ Email list and stats reloaded');
|
||||
uploadProgress.style.display = 'none';
|
||||
fileInput.value = '';
|
||||
toggleUploadZone(); // Close upload zone after successful upload
|
||||
}, 2000);
|
||||
} else {
|
||||
console.log('ℹ️ No new emails uploaded, not reloading');
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Email v2 - BMC Hub{% endblock %}
|
||||
{% block title %}Email - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
@ -47,6 +47,45 @@
|
||||
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 {
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@ -357,19 +396,25 @@
|
||||
<div class="emails-v2-shell">
|
||||
<section class="emails-v2-panel">
|
||||
<div class="emails-v2-header">
|
||||
<h1 class="emails-v2-title">Email v2</h1>
|
||||
<div class="emails-v2-version-nav">
|
||||
<a href="/emails/v1" class="btn btn-sm btn-outline-secondary">Gå til v1</a>
|
||||
<a href="/emails/v2" class="btn btn-sm btn-primary" aria-current="page">v2</a>
|
||||
</div>
|
||||
<h1 class="emails-v2-title"><i class="bi bi-inbox me-2"></i>Email</h1>
|
||||
<button id="v2Refresh" class="btn btn-sm btn-outline-primary" title="Opdater (R)">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 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="v2ListStatus" class="emails-v2-status">Klar</div>
|
||||
@ -378,10 +423,7 @@
|
||||
<section class="emails-v2-panel">
|
||||
<div class="emails-v2-header">
|
||||
<h2 class="emails-v2-title">Detalje</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<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>
|
||||
<span class="emails-shortcuts"><kbd>J</kbd>/<kbd>K</kbd> næste/forrige</span>
|
||||
</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">
|
||||
<div class="emails-v2-header">
|
||||
<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 id="v2SideActions" class="emails-v2-detail-empty">Vælg en email for handlinger</div>
|
||||
@ -408,8 +450,6 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(() => {
|
||||
const TEST_MAILBOX_FOLDER = 'BMC_TEST';
|
||||
|
||||
const FILTERS = [
|
||||
{ key: 'active', label: 'Aktive' },
|
||||
{ key: 'awaiting_user_action', label: 'Afventer handling' },
|
||||
@ -424,7 +464,7 @@
|
||||
workflowPreview: null,
|
||||
filter: 'active',
|
||||
query: '',
|
||||
folder: TEST_MAILBOX_FOLDER,
|
||||
folder: '',
|
||||
searchTimer: null,
|
||||
sagSearchTimer: null,
|
||||
vendorSuggestion: null,
|
||||
@ -667,13 +707,15 @@
|
||||
state.emails = emails;
|
||||
renderFilters();
|
||||
renderList();
|
||||
setListStatus(`${emails.length} emails vist (mappe: ${state.folder || 'alle'})`);
|
||||
setListStatus(`${emails.length} emails`);
|
||||
|
||||
if (state.selectedEmailId) {
|
||||
const stillExists = emails.some((e) => Number(e.id) === Number(state.selectedEmailId));
|
||||
if (stillExists) {
|
||||
await selectEmail(state.selectedEmailId, { silentListRefresh: true });
|
||||
}
|
||||
} else if (emails.length) {
|
||||
await selectEmail(Number(emails[0].id), { silentListRefresh: true });
|
||||
}
|
||||
} catch (error) {
|
||||
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() {
|
||||
if (!state.selectedEmailId || !state.selectedEmail) return;
|
||||
|
||||
@ -1153,53 +1182,69 @@
|
||||
|
||||
sideActions.className = 'emails-v2-actions-pane';
|
||||
sideActions.innerHTML = `
|
||||
<div class="emails-v2-card">
|
||||
<h6>Hurtighandlinger</h6>
|
||||
<div class="emails-v2-actions">
|
||||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">${email.is_read ? 'Marker som ulæst' : 'Marker som læst'}</button>
|
||||
<button id="v2Archive" class="btn btn-sm btn-outline-primary">Arkivér</button>
|
||||
<button id="v2Processed" class="btn btn-sm btn-outline-success">Markér behandlet</button>
|
||||
<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-primary">Auto parse tråd/sag</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<h6>Workflow match-preview</h6>
|
||||
<div id="v2WorkflowPreview"><div class="small text-muted">Henter preview...</div></div>
|
||||
</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>
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<h6>Opret ny sag fra email</h6>
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<input id="v2CaseTitle" class="form-control form-control-sm" value="${escapeHtml(email.subject || '')}" placeholder="Sags titel">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
${email.linked_case_id ? `
|
||||
<div class="emails-v2-card emails-primary-action">
|
||||
<h6>Allerede knyttet til sag</h6>
|
||||
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||||
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||
</a>
|
||||
<button id="v2Processed" class="btn btn-sm btn-outline-success w-100 mt-2">
|
||||
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
|
||||
</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">Helhedsopgave</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 class="emails-v2-right-actions mt-2">
|
||||
<button id="v2CreateCase" class="btn btn-sm btn-primary">Opret sag</button>
|
||||
</div>
|
||||
</div>`}
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
|
||||
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
|
||||
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<h6>Leverandør faktura</h6>
|
||||
<h6>Hurtige mailhandlinger</h6>
|
||||
<div class="emails-v2-actions mb-0">
|
||||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-envelope${email.is_read ? '' : '-open'} me-1"></i>${email.is_read ? 'Ulæst' : 'Læst'}
|
||||
</button>
|
||||
<button id="v2Archive" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-archive me-1"></i>Arkivér
|
||||
</button>
|
||||
${email.linked_case_id ? '' : `
|
||||
<button id="v2Processed" class="btn btn-sm btn-outline-success">
|
||||
<i class="bi bi-check2 me-1"></i>Behandlet
|
||||
</button>`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<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">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>
|
||||
@ -1210,16 +1255,14 @@
|
||||
</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>
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<h6>Auto match til kunde/sag</h6>
|
||||
<hr>
|
||||
<h6>Automatisk kundematch</h6>
|
||||
<div class="emails-v2-right-actions">
|
||||
<button id="v2DomainSuggestionBtn" class="btn btn-sm btn-outline-secondary">Hent domæne-kundeforslag</button>
|
||||
</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>
|
||||
</details>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<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() {
|
||||
document.getElementById('v2Search')?.addEventListener('input', (event) => {
|
||||
const value = String(event.target.value || '').trim();
|
||||
@ -1273,7 +1411,8 @@
|
||||
});
|
||||
|
||||
document.getElementById('v2Refresh')?.addEventListener('click', () => loadEmails());
|
||||
document.getElementById('v2FetchTest')?.addEventListener('click', fetchFromTestFolder);
|
||||
setupUploadZone();
|
||||
setupKeyboardShortcuts();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
|
||||
@ -17,10 +17,10 @@ templates = Jinja2Templates(directory="app")
|
||||
|
||||
@router.get("/emails", response_class=HTMLResponse)
|
||||
async def emails_page(request: Request):
|
||||
"""Email management UI - 3-column modern email interface"""
|
||||
"""Email management UI - fast, simplified workflow."""
|
||||
return templates.TemplateResponse(
|
||||
"emails/frontend/emails.html",
|
||||
{"request": request, "email_ui_version": "v1"}
|
||||
"emails/frontend/emails_v2.html",
|
||||
{"request": request, "email_ui_version": "v2"}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -338,6 +338,12 @@
|
||||
<span class="info-label">Mærke/Model</span>
|
||||
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
|
||||
</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 %}
|
||||
<div class="info-row">
|
||||
<span class="info-label">ESET UUID</span>
|
||||
@ -717,6 +723,7 @@
|
||||
{% if uisp_device %}
|
||||
{% set overview = uisp_device.overview or {} %}
|
||||
<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">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>
|
||||
|
||||
@ -97,7 +97,7 @@
|
||||
border-top: 1px solid rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.btn {
|
||||
.form-container .btn {
|
||||
padding: 0.75rem 2rem;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
@ -108,23 +108,23 @@
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
.form-container .btn-primary {
|
||||
background-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
.form-container .btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(15, 76, 117, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
.form-container .btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
.form-container .btn-secondary:hover {
|
||||
background-color: #5a6268;
|
||||
}
|
||||
|
||||
@ -141,7 +141,7 @@
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.btn {
|
||||
.form-container .btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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:
|
||||
base = Path(settings.UPLOAD_DIR).resolve()
|
||||
target = base / "internet_customer_docs"
|
||||
@ -1247,6 +1260,231 @@ async def internet_connections_health():
|
||||
return {"status": "healthy", "service": "internet-connections-module"}
|
||||
|
||||
|
||||
@router.get("/internet-connections/invoice-sync-runs")
|
||||
async def list_internet_invoice_sync_runs(
|
||||
status: Optional[str] = Query(None),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
):
|
||||
"""Show GlobalConnect invoices and their latest internet-sync outcome."""
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH raw_invoice_keys AS (
|
||||
SELECT si.id AS supplier_invoice_id, si.extraction_id, e.file_id,
|
||||
si.invoice_number, COALESCE(si.vendor_name, e.vendor_name) AS vendor_name,
|
||||
si.invoice_date, si.total_amount, si.currency, si.created_at
|
||||
FROM supplier_invoices si
|
||||
LEFT JOIN extractions e ON e.extraction_id = si.extraction_id
|
||||
WHERE COALESCE(si.vendor_name, e.vendor_name, '') ILIKE '%%GlobalConnect%%'
|
||||
UNION ALL
|
||||
SELECT run.supplier_invoice_id, run.extraction_id, run.file_id, run.invoice_number,
|
||||
run.vendor_name, run.invoice_date, NULL::numeric, NULL::varchar, run.processed_at
|
||||
FROM internet_connections_invoice_sync_runs run
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM supplier_invoices si
|
||||
WHERE si.id = run.supplier_invoice_id
|
||||
OR (run.supplier_invoice_id IS NULL AND si.invoice_number = run.invoice_number
|
||||
AND COALESCE(si.vendor_name, '') = COALESCE(run.vendor_name, ''))
|
||||
)
|
||||
),
|
||||
invoice_keys AS (
|
||||
SELECT DISTINCT ON (
|
||||
TRIM(COALESCE(invoice_number, '')),
|
||||
LOWER(TRIM(COALESCE(vendor_name, '')))
|
||||
) *
|
||||
FROM raw_invoice_keys
|
||||
ORDER BY
|
||||
TRIM(COALESCE(invoice_number, '')),
|
||||
LOWER(TRIM(COALESCE(vendor_name, ''))),
|
||||
supplier_invoice_id NULLS LAST,
|
||||
created_at DESC
|
||||
)
|
||||
SELECT key.*,
|
||||
COALESCE(
|
||||
latest.status,
|
||||
CASE WHEN file.status IN ('failed', 'error') OR NULLIF(TRIM(file.error_message), '') IS NOT NULL THEN 'error' END,
|
||||
CASE WHEN legacy.connection_count > 0 THEN 'legacy_success' ELSE 'not_logged' END
|
||||
) AS processing_status,
|
||||
latest.id AS run_id, latest.connections_synced, latest.connections_created,
|
||||
latest.connections_updated, latest.ip_ranges_synced, latest.total_lines,
|
||||
latest.actionable_lines, latest.skipped_lines,
|
||||
COALESCE(latest.error_message, file.error_message) AS error_message,
|
||||
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])
|
||||
async def list_connections(
|
||||
q: Optional[str] = Query(None),
|
||||
@ -1486,6 +1724,99 @@ async def get_connection(connection_id: int):
|
||||
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])
|
||||
async def list_connection_children(connection_id: int, bmcnet_only: bool = Query(False)):
|
||||
where_parts = [" AND ic.parent_id = %s "]
|
||||
|
||||
@ -556,6 +556,30 @@
|
||||
<div id="pricingHistoryList"></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">
|
||||
<h5 class="mb-3">Historik</h5>
|
||||
<div id="historyList"></div>
|
||||
@ -1296,6 +1320,7 @@
|
||||
pricingHistoryResponse,
|
||||
contractsResponse,
|
||||
historyResponse,
|
||||
crossFieldPortsResponse,
|
||||
] = await Promise.all([
|
||||
fetch(`/api/v1/internet-connections/${connectionId}`),
|
||||
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/contracts'),
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/history`),
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
|
||||
]);
|
||||
|
||||
if (!connectionResponse.ok) {
|
||||
@ -1321,6 +1347,7 @@
|
||||
const pricingHistory = await pricingHistoryResponse.json();
|
||||
const contracts = await contractsResponse.json();
|
||||
const history = await historyResponse.json();
|
||||
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
|
||||
|
||||
currentConnection = connection;
|
||||
currentAddresses = Array.isArray(addresses) ? addresses : [];
|
||||
@ -1337,6 +1364,44 @@
|
||||
renderRelationGrid(connection);
|
||||
renderBmcnetChildren(connection, currentBmcnetChildren);
|
||||
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) {
|
||||
|
||||
@ -137,6 +137,24 @@
|
||||
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) {
|
||||
.internet-toolbar {
|
||||
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>
|
||||
<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()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</button>
|
||||
@ -327,10 +348,58 @@
|
||||
</table>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
let allConnections = [];
|
||||
let invoiceSyncItems = [];
|
||||
let activeTab = 'all';
|
||||
let subscriptionOptions = [];
|
||||
|
||||
@ -405,6 +474,165 @@
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function escapeInvoiceText(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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() {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
const provider = document.getElementById('providerFilter').value.trim();
|
||||
@ -626,7 +854,7 @@
|
||||
});
|
||||
toggleCreateValueFields();
|
||||
await loadSubscriptionOptions();
|
||||
await loadInternetPage();
|
||||
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
||||
});
|
||||
</script>
|
||||
<datalist id="subscriptionLookupList"></datalist>
|
||||
|
||||
@ -866,9 +866,9 @@ async def create_wall_outlet(data: WallOutletCreate):
|
||||
try:
|
||||
rows = execute_query(
|
||||
"""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)
|
||||
VALUES (%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),
|
||||
(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, %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.is_wan, data.status, data.notes, data.is_active),
|
||||
) or []
|
||||
except Exception as exc:
|
||||
if 'unique' in str(exc).lower():
|
||||
|
||||
@ -659,7 +659,8 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
|
||||
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
|
||||
WHERE location_id = %s AND deleted_at IS NULL
|
||||
ORDER BY outlet_number
|
||||
@ -780,7 +781,9 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
for cross_field in cross_fields or []:
|
||||
cross_field["ports"] = execute_query(
|
||||
"""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
|
||||
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
|
||||
@ -788,6 +791,17 @@ def detail_location_view(id: int = Path(..., gt=0)):
|
||||
WHERE p.cross_field_id = %s ORDER BY p.port_order""",
|
||||
(cross_field["id"],),
|
||||
) 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(
|
||||
"""
|
||||
|
||||
@ -121,6 +121,7 @@ class WallOutletBase(BaseModel):
|
||||
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||
switch_name: Optional[str] = Field(None, max_length=255)
|
||||
switch_port: Optional[str] = Field(None, max_length=100)
|
||||
is_wan: bool = False
|
||||
status: str = Field('unknown')
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
@ -147,6 +148,7 @@ class WallOutletUpdate(BaseModel):
|
||||
switch_hardware_id: Optional[int] = Field(None, ge=1)
|
||||
switch_name: Optional[str] = Field(None, max_length=255)
|
||||
switch_port: Optional[str] = Field(None, max_length=100)
|
||||
is_wan: Optional[bool] = None
|
||||
status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
@ -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-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%; }
|
||||
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.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.faulty { background: #dc3545; border-color: #b02a37; color:#fff; }
|
||||
.patch-port.unknown { background: #6c757d; border-color: #565e64; color:#fff; }
|
||||
@ -810,7 +817,7 @@
|
||||
{% 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>
|
||||
{% 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 %}
|
||||
</tbody></table></div>
|
||||
{% 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>
|
||||
<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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
@ -887,7 +907,11 @@
|
||||
<div class="list-group-item">
|
||||
<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 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>
|
||||
{% if hw.uisp_device %}
|
||||
{% set uisp = hw.uisp_device %}
|
||||
@ -895,6 +919,7 @@
|
||||
<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="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">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>
|
||||
@ -909,7 +934,21 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% 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 %}
|
||||
</div>
|
||||
{% 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="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-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">
|
||||
<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-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-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-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-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">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>
|
||||
</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 locationHardware = {{ location.hardware | tojson }};
|
||||
const locationWallOutlets = {{ location.wall_outlets | tojson }};
|
||||
const locationCrossFields = {{ location.cross_fields | tojson }};
|
||||
const existingContactSearchInput = document.getElementById('existingContactSearch');
|
||||
const existingContactResultsContainer = document.getElementById('existingContactResults');
|
||||
const existingContactIdInput = document.getElementById('existingContactId');
|
||||
@ -1498,6 +1560,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const outletValue = (id) => document.getElementById(id).value.trim() || null;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@ -1510,6 +1574,125 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
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() {
|
||||
const selectedName = document.getElementById('outletSwitch').value;
|
||||
const match = (locationHardware || []).find(item => switchDisplayName(item) === selectedName);
|
||||
@ -1587,20 +1770,46 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
async function loadOutletCustomers(selectedCustomerId = null) {
|
||||
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>';
|
||||
(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');
|
||||
option.value = String(customer.id);
|
||||
option.textContent = customer.name || customer.navn || `Kunde #${customer.id}`;
|
||||
option.selected = String(customer.id) === String(selectedCustomerId || '');
|
||||
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) {
|
||||
if (!outletModal) return;
|
||||
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('outletNumber').value = outlet?.number || '';
|
||||
document.getElementById('outletCategory').value = outlet?.category || '';
|
||||
@ -1608,7 +1817,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('outletPatchPort').value = outlet?.patchPort || '';
|
||||
loadSwitchChoices(outlet?.switchName || '', outlet?.switchPort || '', outlet?.id || null);
|
||||
document.getElementById('outletStatus').value = outlet?.status || 'unknown';
|
||||
document.getElementById('outletIsWan').checked = Boolean(outlet?.isWan);
|
||||
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) {
|
||||
const portSelect = document.getElementById('outletCrossFieldPort');
|
||||
portSelect.value = String(selectedPort.id);
|
||||
@ -1618,16 +1841,52 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
document.getElementById('outletPatchPanel').value = selectedPort.fieldName;
|
||||
document.getElementById('outletPatchPort').value = selectedPort.portNumber;
|
||||
}
|
||||
document.getElementById('outletModalTitle').textContent = outlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||||
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !outlet);
|
||||
outletModal.show();
|
||||
document.getElementById('outletModalTitle').textContent = isExistingOutlet ? 'Rediger vægstik' : 'Tilføj vægstik';
|
||||
document.getElementById('deleteOutletBtn').classList.toggle('d-none', !isExistingOutlet);
|
||||
}
|
||||
|
||||
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({
|
||||
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
|
||||
})));
|
||||
|
||||
@ -1639,6 +1898,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
patch_panel: outletValue('outletPatchPanel'), patch_port: outletValue('outletPatchPort'),
|
||||
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'),
|
||||
is_wan: document.getElementById('outletIsWan').checked,
|
||||
status: document.getElementById('outletStatus').value, notes: outletValue('outletNotes')
|
||||
};
|
||||
const selectedSwitch = (locationHardware || []).find(item => Number(item.id) === selectedSwitchHardwareId());
|
||||
|
||||
1
app/modules/migration_center/__init__.py
Normal file
1
app/modules/migration_center/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Manual migration centre for CRM subscriptions and imported invoice lines."""
|
||||
1
app/modules/migration_center/backend/__init__.py
Normal file
1
app/modules/migration_center/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Migration centre backend."""
|
||||
1415
app/modules/migration_center/backend/router.py
Normal file
1415
app/modules/migration_center/backend/router.py
Normal file
File diff suppressed because it is too large
Load Diff
641
app/modules/migration_center/backend/service.py
Normal file
641
app/modules/migration_center/backend/service.py
Normal 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()
|
||||
1
app/modules/migration_center/frontend/__init__.py
Normal file
1
app/modules/migration_center/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Migration centre frontend."""
|
||||
20
app/modules/migration_center/frontend/views.py
Normal file
20
app/modules/migration_center/frontend/views.py
Normal 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},
|
||||
)
|
||||
11
app/modules/migration_center/module.json
Normal file
11
app/modules/migration_center/module.json
Normal 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"]
|
||||
}
|
||||
349
app/modules/migration_center/templates/index.html
Normal file
349
app/modules/migration_center/templates/index.html
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
@ -10,11 +10,14 @@ from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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):
|
||||
"""User notification preferences"""
|
||||
notify_mattermost: bool = True
|
||||
notify_email: bool = False
|
||||
notify_email: bool = True
|
||||
notify_frontend: bool = True
|
||||
email_override: Optional[str] = None
|
||||
mattermost_username: Optional[str] = None
|
||||
quiet_hours_enabled: bool = False
|
||||
quiet_hours_start: 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 = """
|
||||
SELECT
|
||||
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
|
||||
WHERE user_id = %s
|
||||
"""
|
||||
@ -165,9 +170,10 @@ async def get_user_notification_preferences(request: Request):
|
||||
r = result[0]
|
||||
return UserNotificationPreferences(
|
||||
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),
|
||||
email_override=r.get('email_override'),
|
||||
mattermost_username=r.get('mattermost_username'),
|
||||
quiet_hours_enabled=r.get('quiet_hours_enabled', False),
|
||||
quiet_hours_start=r.get('quiet_hours_start'),
|
||||
quiet_hours_end=r.get('quiet_hours_end')
|
||||
@ -198,6 +204,7 @@ async def update_user_notification_preferences(
|
||||
notify_email = %s,
|
||||
notify_frontend = %s,
|
||||
email_override = %s,
|
||||
mattermost_username = %s,
|
||||
quiet_hours_enabled = %s,
|
||||
quiet_hours_start = %s,
|
||||
quiet_hours_end = %s,
|
||||
@ -211,6 +218,7 @@ async def update_user_notification_preferences(
|
||||
preferences.notify_email,
|
||||
preferences.notify_frontend,
|
||||
preferences.email_override,
|
||||
preferences.mattermost_username,
|
||||
preferences.quiet_hours_enabled,
|
||||
preferences.quiet_hours_start,
|
||||
preferences.quiet_hours_end,
|
||||
@ -221,9 +229,10 @@ async def update_user_notification_preferences(
|
||||
query = """
|
||||
INSERT INTO user_notification_preferences (
|
||||
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
|
||||
"""
|
||||
|
||||
@ -233,6 +242,7 @@ async def update_user_notification_preferences(
|
||||
preferences.notify_email,
|
||||
preferences.notify_frontend,
|
||||
preferences.email_override,
|
||||
preferences.mattermost_username,
|
||||
preferences.quiet_hours_enabled,
|
||||
preferences.quiet_hours_start,
|
||||
preferences.quiet_hours_end
|
||||
@ -250,7 +260,11 @@ async def update_user_notification_preferences(
|
||||
# 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):
|
||||
"""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):
|
||||
"""Create a new reminder for a case"""
|
||||
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))
|
||||
|
||||
|
||||
@router.patch("/api/v1/sag/reminders/{reminder_id}")
|
||||
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
||||
@router.patch("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
|
||||
async def update_sag_reminder(reminder_id: int, update: ReminderUpdate, request: Request):
|
||||
"""Update a reminder"""
|
||||
user_id = _get_user_id_from_request(request)
|
||||
|
||||
# Build update query dynamically
|
||||
updates = []
|
||||
@ -472,10 +491,10 @@ async def update_sag_reminder(reminder_id: int, update: ReminderUpdate):
|
||||
query = f"""
|
||||
UPDATE sag_reminders
|
||||
SET {', '.join(updates)}
|
||||
WHERE id = %s
|
||||
WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL
|
||||
RETURNING id
|
||||
"""
|
||||
|
||||
params.append(user_id)
|
||||
result = execute_insert(query, tuple(params))
|
||||
if not result:
|
||||
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")
|
||||
return {"success": True, "message": "Reminder updated"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error updating reminder: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/v1/sag/reminders/{reminder_id}")
|
||||
async def delete_sag_reminder(reminder_id: int):
|
||||
@router.delete("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)])
|
||||
async def delete_sag_reminder(reminder_id: int, request: Request):
|
||||
"""Soft-delete a reminder"""
|
||||
user_id = _get_user_id_from_request(request)
|
||||
|
||||
try:
|
||||
query = """
|
||||
UPDATE sag_reminders
|
||||
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
|
||||
"""
|
||||
|
||||
result = execute_insert(query, (reminder_id,))
|
||||
result = execute_insert(query, (reminder_id, user_id))
|
||||
if not result:
|
||||
raise HTTPException(status_code=404, detail="Reminder not found")
|
||||
|
||||
logger.info(f"✅ Reminder {reminder_id} deleted")
|
||||
return {"success": True, "message": "Reminder deleted"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error deleting reminder: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@ -11,13 +11,14 @@ from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Dict
|
||||
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 pydantic import BaseModel, Field
|
||||
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 app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
|
||||
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.case_analysis_service import CaseAnalysisService
|
||||
from app.services.ollama_service import ollama_service
|
||||
@ -31,7 +32,38 @@ import email
|
||||
from email.header import decode_header
|
||||
|
||||
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:
|
||||
@ -142,7 +174,7 @@ def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Opti
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
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")
|
||||
except ValueError:
|
||||
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)
|
||||
|
||||
|
||||
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(
|
||||
deferred_until_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)
|
||||
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
||||
|
||||
@router.post("/sag")
|
||||
async def create_sag(data: dict):
|
||||
@router.post("/sag", dependencies=[Depends(case_create_access)])
|
||||
async def create_sag(request: Request, data: dict):
|
||||
"""Create a case and its optional pipeline/order data atomically."""
|
||||
try:
|
||||
if not data.get("titel"):
|
||||
@ -1025,7 +1089,7 @@ async def create_sag(data: dict):
|
||||
RETURNING *
|
||||
""",
|
||||
(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"]),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
@ -1471,7 +1535,7 @@ async def delete_todo_step(step_id: int):
|
||||
logger.error("❌ Error deleting todo step: %s", e)
|
||||
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(...)):
|
||||
"""Update a case."""
|
||||
try:
|
||||
@ -1680,7 +1744,7 @@ class BeskrivelsePatch(BaseModel):
|
||||
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):
|
||||
"""Update case description and store a change history entry."""
|
||||
try:
|
||||
@ -1759,7 +1823,7 @@ class PipelineUpdate(BaseModel):
|
||||
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):
|
||||
"""Update pipeline fields for a case."""
|
||||
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)
|
||||
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):
|
||||
"""Soft-delete a case."""
|
||||
try:
|
||||
@ -1870,15 +1934,14 @@ async def get_relationer(sag_id: int):
|
||||
logger.error("❌ Error getting relations: %s", e)
|
||||
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):
|
||||
"""Add a relation to another case."""
|
||||
try:
|
||||
if not data.get("målsag_id") or not data.get("relationstype"):
|
||||
raise HTTPException(status_code=400, detail="målsag_id and relationstype required")
|
||||
|
||||
målsag_id = data.get("målsag_id")
|
||||
relationstype = data.get("relationstype")
|
||||
målsag_id, relationstype = _normalize_relation_input(sag_id, data)
|
||||
|
||||
# Validate both cases exist
|
||||
check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||||
@ -1887,6 +1950,20 @@ async def create_relation(sag_id: int, data: dict):
|
||||
if not check1 or not check2:
|
||||
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 = """
|
||||
INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype)
|
||||
VALUES (%s, %s, %s)
|
||||
@ -1904,7 +1981,7 @@ async def create_relation(sag_id: int, data: dict):
|
||||
logger.error("❌ Error creating relation: %s", e)
|
||||
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):
|
||||
"""Soft-delete a relation."""
|
||||
try:
|
||||
@ -1958,7 +2035,7 @@ async def get_tags(sag_id: int):
|
||||
logger.error("❌ Error getting tags: %s", e)
|
||||
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):
|
||||
"""Add a tag to a case."""
|
||||
try:
|
||||
@ -1986,7 +2063,7 @@ async def add_tag(sag_id: int, data: dict):
|
||||
logger.error("❌ Error adding tag: %s", e)
|
||||
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):
|
||||
"""Soft-delete a tag."""
|
||||
try:
|
||||
@ -4407,15 +4484,20 @@ def _generate_stored_name(filename: str, subdir: str) -> str:
|
||||
return f"{subdir}/{unique}"
|
||||
|
||||
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):
|
||||
if not upload_file.filename:
|
||||
raise HTTPException(400, detail="Filename missing")
|
||||
|
||||
ext = Path(upload_file.filename).suffix.lower().lstrip(".")
|
||||
# Basic check - allow more types for generic files?
|
||||
# if ext not in ALLOWED_EXTENSIONS: ...
|
||||
if not ext or 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)
|
||||
size = upload_file.file.tell()
|
||||
@ -4462,7 +4544,7 @@ async def list_sag_files(sag_id: int):
|
||||
logger.error("❌ Error listing files: %s", e)
|
||||
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(...)):
|
||||
"""Upload files to a case."""
|
||||
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")
|
||||
|
||||
saved_files = []
|
||||
errors = []
|
||||
|
||||
for file in files:
|
||||
try:
|
||||
@ -4488,12 +4571,17 @@ async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)):
|
||||
saved = result[0]
|
||||
saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}"
|
||||
saved_files.append(saved)
|
||||
except HTTPException:
|
||||
continue # Skip invalid
|
||||
except HTTPException as exc:
|
||||
errors.append({"filename": file.filename, "detail": exc.detail})
|
||||
except Exception as 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
|
||||
|
||||
@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)
|
||||
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):
|
||||
"""Delete a file."""
|
||||
if not _table_exists("sag_files"):
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import execute_query
|
||||
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
||||
from app.core.auth_dependencies import require_any_permission
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||
|
||||
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
||||
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)
|
||||
raise HTTPException(status_code=500, detail="Failed to get solution")
|
||||
|
||||
@router.post("/sag/{sag_id}/solution", response_model=Solution)
|
||||
async def create_solution(sag_id: int, solution: SolutionCreate):
|
||||
@router.post(
|
||||
"/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."""
|
||||
try:
|
||||
# Check if case exists
|
||||
@ -47,7 +53,7 @@ async def create_solution(sag_id: int, solution: SolutionCreate):
|
||||
solution.description,
|
||||
solution.solution_type,
|
||||
solution.result,
|
||||
solution.created_by_user_id
|
||||
getattr(request.state, "user_id", None)
|
||||
)
|
||||
|
||||
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)
|
||||
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):
|
||||
"""Update a solution."""
|
||||
try:
|
||||
|
||||
@ -7,6 +7,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pathlib import Path
|
||||
from app.core.database import execute_query
|
||||
from app.utils.safe_html import sanitize_safe_html
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -76,6 +77,7 @@ def _is_deadline_overdue(deadline_value) -> bool:
|
||||
|
||||
# Setup template directory
|
||||
templates = Jinja2Templates(directory="app")
|
||||
templates.env.filters["safe_case_html"] = sanitize_safe_html
|
||||
|
||||
|
||||
def _fetch_assignment_users():
|
||||
|
||||
418
app/modules/sag/scripts/sag_module_e2e.py
Normal file
418
app/modules/sag/scripts/sag_module_e2e.py
Normal 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())
|
||||
@ -40,13 +40,27 @@ class RelationService:
|
||||
placeholders = ','.join(['%s'] * len(tree_ids))
|
||||
tree_cases_query = f"""
|
||||
SELECT
|
||||
id,
|
||||
titel,
|
||||
status,
|
||||
template_key,
|
||||
COALESCE(template_key, 'ticket') AS type
|
||||
FROM sag_sager
|
||||
WHERE id IN ({placeholders})
|
||||
s.id,
|
||||
s.titel,
|
||||
s.status,
|
||||
s.template_key,
|
||||
COALESCE(s.template_key, 'ticket') AS type,
|
||||
next_todo.title AS next_todo_title,
|
||||
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))}
|
||||
|
||||
@ -73,6 +87,10 @@ class RelationService:
|
||||
return m, k # m is parent of k
|
||||
if rtype_lower in ['årsag til', 'cause of']:
|
||||
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
|
||||
# But here we build a directed graph.
|
||||
# If relation is symmetric (Relateret til), we must be careful not to create cycle A->B->A
|
||||
|
||||
@ -1203,7 +1203,6 @@
|
||||
customer_id: selectedCustomer ? selectedCustomer.id : 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,
|
||||
created_by_user_id: 1, // HARDCODED for now, should come from auth
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
|
||||
@ -3859,7 +3859,7 @@
|
||||
<div class="card-body">
|
||||
<!-- View mode -->
|
||||
<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 %}
|
||||
<div id="beskrivelse-empty" class="text-center p-3">
|
||||
<p class="text-muted fst-italic mb-2">Ingen opgavebeskrivelse tilføjet endnu.</p>
|
||||
@ -4130,7 +4130,7 @@
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-html="true"
|
||||
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 class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="showRelationModal()">
|
||||
@ -4179,6 +4179,12 @@
|
||||
<div class="relation-type-subtext">(Årsag til)</div>
|
||||
{% elif rel_key == 'blokkerer' %}
|
||||
<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 %}
|
||||
<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>
|
||||
@ -4187,6 +4193,23 @@
|
||||
<i class="bi bi-arrow-repeat text-muted ms-1" title="Vises flere steder i relationstræet"></i>
|
||||
{% endif %}
|
||||
</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">
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
{% if node.relation_id %}
|
||||
@ -4221,6 +4244,7 @@
|
||||
<th style="width: 120px;">Status</th>
|
||||
<th style="width: 130px;">Type</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>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -4352,13 +4376,14 @@
|
||||
</div>
|
||||
|
||||
<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();">
|
||||
<option value="">Vælg hvordan sagerne er relateret...</option>
|
||||
<option value="Relateret til">🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed</option>
|
||||
<option value="Afledt af">↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden</option>
|
||||
<option value="Årsag til">➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden</option>
|
||||
<option value="Blokkerer">⛔ Blokerer - Den anden kan ikke videre før denne er løst</option>
|
||||
<option value="">Vælg forholdet mellem sagerne...</option>
|
||||
<option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
|
||||
<option value="undersag">↳ Den valgte sag er en undersag til denne</option>
|
||||
<option value="afhænger af">⏳ Denne sag afhænger af den valgte</option>
|
||||
<option value="Blokkerer">⛔ Denne sag blokerer den valgte</option>
|
||||
<option value="duplikat">⧉ Sagerne beskriver det samme problem</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@ -4366,10 +4391,7 @@
|
||||
|
||||
<div class="alert alert-light border small mb-3">
|
||||
<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><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>Teksten i valget beskriver retningen direkte. Du skal altså ikke selv afkode “kilde” og “mål”.</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light d-flex align-items-center" style="font-size: 0.9rem;">
|
||||
@ -5309,6 +5331,18 @@
|
||||
'Blokkerer': {
|
||||
icon: '⛔',
|
||||
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;
|
||||
@ -5336,20 +5370,24 @@
|
||||
if (!select || !hint) return;
|
||||
|
||||
const selected = select.value;
|
||||
if (selected === 'Afledt af') {
|
||||
hint.innerHTML = '<strong>↪ Effekt:</strong> Nuværende sag markeres som kommer fra den nye sag.';
|
||||
if (selected === 'undersag') {
|
||||
hint.innerHTML = '<strong>↳ Resultat:</strong> Den nye sag bliver en undersag til denne sag.';
|
||||
return;
|
||||
}
|
||||
if (selected === 'Årsag til') {
|
||||
hint.innerHTML = '<strong>➡ Effekt:</strong> Nuværende sag markeres som at den skaber den nye følge-sag.';
|
||||
if (selected === 'afhænger af') {
|
||||
hint.innerHTML = '<strong>⏳ Resultat:</strong> Denne sag markeres som afhængig af den nye sag.';
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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() {
|
||||
@ -5766,6 +5804,74 @@
|
||||
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) {
|
||||
selectedRelationCaseId = caseIdValue;
|
||||
|
||||
@ -11722,8 +11828,8 @@
|
||||
<div class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="me-3">
|
||||
<div class="fw-bold">${reminder.title}</div>
|
||||
<div class="text-muted small">${reminder.message || '-'} </div>
|
||||
<div class="fw-bold">${escapeHtml(reminder.title)}</div>
|
||||
<div class="text-muted small">${escapeHtml(reminder.message || '-')} </div>
|
||||
<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}
|
||||
</div>
|
||||
@ -12729,7 +12835,6 @@
|
||||
solution_type: document.getElementById('sol_type').value,
|
||||
result: document.getElementById('sol_result').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 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å...">
|
||||
</div>
|
||||
<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()">
|
||||
<option value="Relateret til">Relateret til (Ingen direkte afhængighed)</option>
|
||||
<option value="Afledt af">Afledt af (Nuværende sag er afledt af den nye)</option>
|
||||
<option value="Årsag til">Årsag til (Nuværende sag er årsag til den nye)</option>
|
||||
<option value="Blokkerer">Blokkerer (Nuværende sag blokerer den nye)</option>
|
||||
<option value="undersag">↳ Den nye sag er en undersag til denne</option>
|
||||
<option value="Relateret til">🔗 Kun relateret — ingen afhængighed</option>
|
||||
<option value="afhænger af">⏳ Denne sag afhænger af 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>
|
||||
</div>
|
||||
<div id="newCaseRelationTypeHint" class="alert alert-info small mb-3"></div>
|
||||
<div class="alert alert-light border small">
|
||||
<div class="fw-semibold mb-1">Sådan vælger du korrekt relation</div>
|
||||
<div><strong>Relateret til</strong>: Samme emne/område, men ingen direkte afhængighed.</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 class="fw-semibold mb-1">Vælg ud fra sætningen</div>
|
||||
<div>Hvert valg beskriver den nye sag i forhold til den sag, du står på nu.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Beskrivelse</label>
|
||||
@ -15041,7 +15144,9 @@
|
||||
const sourceToken = String(f.source_token || '').toUpperCase();
|
||||
const isWorkOrder = sourceType === 'scanner_email' && sourceToken.includes('BMCSCAN-WO-');
|
||||
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
|
||||
? '<span class="badge rounded-pill text-bg-warning ms-2"><i class="bi bi-clipboard-check me-1"></i>Arbejdsseddel</span>'
|
||||
: (isScannerFile
|
||||
@ -15051,7 +15156,7 @@
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div class="ms-2 me-auto">
|
||||
<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}
|
||||
</a>
|
||||
${badgeHtml}
|
||||
@ -15059,10 +15164,10 @@
|
||||
<small class="text-muted">${size} • ${new Date(f.created_at).toLocaleDateString()}</small>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
</button>
|
||||
</div>
|
||||
@ -15153,6 +15258,15 @@
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const modal = new bootstrap.Modal(document.getElementById('filePreviewModal'));
|
||||
const previewContent = document.getElementById('previewContent');
|
||||
@ -16122,7 +16236,7 @@
|
||||
const received = email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-';
|
||||
const attachments = Array.isArray(email.attachments) ? email.attachments : [];
|
||||
const bodyText = email.body_text || '';
|
||||
const bodyHtml = email.body_html || '';
|
||||
const bodyHtml = sanitizeCaseEmailHtml(email.body_html || '');
|
||||
selectedLinkedEmailDetail = email;
|
||||
|
||||
panel.innerHTML = `
|
||||
@ -16148,7 +16262,7 @@
|
||||
<div id="email-attachments-list" class="d-flex flex-wrap gap-2"></div>
|
||||
</div>
|
||||
<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>
|
||||
`;
|
||||
|
||||
@ -18497,20 +18611,27 @@
|
||||
const saveBtn = getRelQaPrimaryButton();
|
||||
if (saveBtn) { saveBtn.disabled = true; }
|
||||
try {
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/todos`, {
|
||||
const r = await fetch(`/api/v1/sag/${caseId}/todo-steps`, {
|
||||
method: 'POST', credentials: 'include',
|
||||
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) {
|
||||
closeRelQaSurfaceAfterSave();
|
||||
if (typeof showNotification === 'function') showNotification('Opgave oprettet ✓', 'success');
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
if (typeof showNotification === 'function') showNotification('Kunne ikke kontakte serveren', 'error');
|
||||
if (saveBtn) saveBtn.disabled = false;
|
||||
}
|
||||
} catch { if (saveBtn) saveBtn.disabled = false; }
|
||||
};
|
||||
|
||||
// ── Quick Tildel sag modal ────────────────────────────────────────
|
||||
@ -19123,7 +19244,7 @@
|
||||
const data = await res.json();
|
||||
// Update view
|
||||
const textEl = document.getElementById('beskrivelse-text');
|
||||
textEl.innerText = data.beskrivelse || '';
|
||||
textEl.innerHTML = sanitizeCaseEmailHtml(data.beskrivelse || '');
|
||||
const emptyEl = document.getElementById('beskrivelse-empty');
|
||||
if (emptyEl) emptyEl.style.display = data.beskrivelse ? 'none' : '';
|
||||
cancelBeskrivelsEdit();
|
||||
|
||||
@ -1598,7 +1598,7 @@ class EmailService:
|
||||
|
||||
# Prefer Graph send when Graph integration is enabled/configured.
|
||||
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,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
|
||||
@ -25,9 +25,39 @@ class ReminderNotificationService:
|
||||
def __init__(self):
|
||||
self.email_service = EmailService()
|
||||
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.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(
|
||||
self,
|
||||
reminder_id: int,
|
||||
@ -68,6 +98,7 @@ class ReminderNotificationService:
|
||||
'rate_limited_users': [],
|
||||
'logged_id': None
|
||||
}
|
||||
self._refresh_mattermost_settings()
|
||||
|
||||
if self.dry_run:
|
||||
logger.warning(f"🔒 DRY RUN: Would send reminder '{reminder_title}' for case #{sag_id}")
|
||||
@ -102,27 +133,46 @@ class ReminderNotificationService:
|
||||
)
|
||||
|
||||
# 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_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
|
||||
for channel in channels:
|
||||
try:
|
||||
if channel == 'mattermost' and settings.REMINDERS_MATTERMOST_ENABLED:
|
||||
await self._send_mattermost(
|
||||
if channel == '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,
|
||||
priority, additional_info
|
||||
priority, additional_info, mattermost_username
|
||||
)
|
||||
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:
|
||||
await self._send_email(
|
||||
elif channel == '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,
|
||||
sag_id, case_title, customer_name, priority,
|
||||
case_status, deadline, assigned_user, additional_info
|
||||
)
|
||||
if sent:
|
||||
result['channels_used'].append('email')
|
||||
else:
|
||||
result['errors'].append(f'Email delivery failed for {user_email}')
|
||||
|
||||
elif channel == 'frontend':
|
||||
# Frontend notifications are handled by polling, no action needed here
|
||||
@ -154,12 +204,17 @@ class ReminderNotificationService:
|
||||
for email_addr in recipient_emails:
|
||||
try:
|
||||
if settings.REMINDERS_EMAIL_ENABLED:
|
||||
await self._send_email(
|
||||
sent = await self._send_email(
|
||||
email_addr, reminder_title, reminder_message,
|
||||
sag_id, case_title, customer_name, priority,
|
||||
case_status, deadline, assigned_user, additional_info
|
||||
)
|
||||
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:
|
||||
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:
|
||||
"""Get user notification preferences"""
|
||||
query = """
|
||||
SELECT notify_mattermost, notify_email, notify_frontend
|
||||
SELECT notify_mattermost, notify_email, notify_frontend,
|
||||
email_override, mattermost_username
|
||||
FROM user_notification_preferences
|
||||
WHERE user_id = %s
|
||||
"""
|
||||
@ -200,15 +256,19 @@ class ReminderNotificationService:
|
||||
if result:
|
||||
return {
|
||||
'mattermost': result[0].get('notify_mattermost', True),
|
||||
'email': result[0].get('notify_email', False),
|
||||
'frontend': result[0].get('notify_frontend', True)
|
||||
'email': result[0].get('notify_email', True),
|
||||
'frontend': result[0].get('notify_frontend', True),
|
||||
'email_override': result[0].get('email_override')
|
||||
, 'mattermost_username': result[0].get('mattermost_username')
|
||||
}
|
||||
|
||||
# Default preferences
|
||||
return {
|
||||
'mattermost': True,
|
||||
'email': False,
|
||||
'frontend': True
|
||||
'email': True,
|
||||
'frontend': True,
|
||||
'email_override': None
|
||||
, 'mattermost_username': None
|
||||
}
|
||||
|
||||
def _determine_channels(
|
||||
@ -222,13 +282,18 @@ class ReminderNotificationService:
|
||||
"""Determine which channels to use (merge user prefs with reminder overrides)"""
|
||||
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
|
||||
mm = notify_mattermost if notify_mattermost is not None else user_prefs.get('mattermost', True)
|
||||
if mm:
|
||||
channels.append('mattermost')
|
||||
|
||||
# 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:
|
||||
channels.append('email')
|
||||
|
||||
@ -246,7 +311,8 @@ class ReminderNotificationService:
|
||||
case_id: int,
|
||||
case_title: str,
|
||||
priority: str,
|
||||
additional_info: Optional[str]
|
||||
additional_info: Optional[str],
|
||||
mattermost_username: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Send reminder via Mattermost"""
|
||||
if self.dry_run:
|
||||
@ -260,12 +326,13 @@ class ReminderNotificationService:
|
||||
'high': '#ffc107',
|
||||
'urgent': '#dc3545'
|
||||
}
|
||||
case_url = self._case_url(case_id)
|
||||
|
||||
payload = {
|
||||
'text': f'🔔 **{title}**',
|
||||
'text': f'🔔 **{title}**\n[Åbn sag #{case_id}]({case_url})',
|
||||
'attachments': [{
|
||||
'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',
|
||||
'color': color_map.get(priority, color_map['normal']),
|
||||
'fields': [
|
||||
@ -279,17 +346,24 @@ class ReminderNotificationService:
|
||||
'value': f'#{case_id}',
|
||||
'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')
|
||||
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:
|
||||
logger.info(f"✅ Mattermost reminder sent: {title}")
|
||||
else:
|
||||
@ -337,7 +411,7 @@ class ReminderNotificationService:
|
||||
'deadline': deadline,
|
||||
'assigned_user': assigned_user or 'Ikke tildelt',
|
||||
'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")
|
||||
}
|
||||
|
||||
|
||||
@ -2,19 +2,29 @@
|
||||
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 pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from app.core.database import execute_query
|
||||
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 time
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
_sag_test_lock = threading.Lock()
|
||||
_sag_test_report_dir = Path(__file__).resolve().parents[3] / "data" / "test-results"
|
||||
|
||||
DEFAULT_EMAIL_SIGNATURE_TEMPLATE = (
|
||||
"{full_name}\n"
|
||||
@ -52,6 +62,29 @@ class SettingCreate(BaseModel):
|
||||
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):
|
||||
id: int
|
||||
username: str
|
||||
@ -75,6 +108,80 @@ class UserUpdate(BaseModel):
|
||||
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
|
||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||
async def get_settings(category: Optional[str] = None):
|
||||
@ -102,6 +209,16 @@ async def get_settings(category: Optional[str] = None):
|
||||
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"
|
||||
params = []
|
||||
@ -112,7 +229,7 @@ async def get_settings(category: Optional[str] = None):
|
||||
|
||||
query += " ORDER BY category, key"
|
||||
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"])
|
||||
@ -147,7 +264,7 @@ async def create_setting(payload: SettingCreate):
|
||||
)
|
||||
if not result:
|
||||
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"])
|
||||
@ -231,12 +348,25 @@ async def get_setting(key: str):
|
||||
if not result:
|
||||
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"])
|
||||
async def update_setting(key: str, setting: SettingUpdate):
|
||||
"""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 = """
|
||||
UPDATE settings
|
||||
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")
|
||||
|
||||
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"])
|
||||
|
||||
@ -61,6 +61,26 @@
|
||||
justify-content: center;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -122,6 +142,9 @@
|
||||
<a class="nav-link" href="#mission" data-tab="mission">
|
||||
<i class="bi bi-broadcast-pin me-2"></i>Mission
|
||||
</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">
|
||||
<i class="bi bi-gear me-2"></i>System
|
||||
</a>
|
||||
@ -469,7 +492,7 @@
|
||||
|
||||
<!-- 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>
|
||||
<div id="notificationSettings">
|
||||
<div class="text-center py-5">
|
||||
@ -477,6 +500,41 @@
|
||||
</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>
|
||||
|
||||
<!-- Email Templates -->
|
||||
@ -1507,6 +1565,84 @@ async def scan_document(file_path: str):
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="system">
|
||||
<div class="card p-4 mb-4">
|
||||
@ -1974,6 +2110,7 @@ let pipelineStagesCache = [];
|
||||
let nextcloudInstancesCache = [];
|
||||
let customersCache = [];
|
||||
let timeMultiplierPresetsCache = [];
|
||||
let sagTestReportsCache = [];
|
||||
|
||||
const DEFAULT_TIME_MULTIPLIER_PRESETS = [
|
||||
{ label: 'Haster', text: 'Haster', multiplier: 3 },
|
||||
@ -2672,6 +2809,7 @@ function displaySettingsByCategory() {
|
||||
|
||||
// Notification settings
|
||||
displaySettings('notificationSettings', categories.notifications);
|
||||
renderMattermostSettings();
|
||||
|
||||
// Email templates
|
||||
displaySettings('emailTemplatesInternal', [
|
||||
@ -2689,6 +2827,100 @@ function displaySettingsByCategory() {
|
||||
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() {
|
||||
const keys = ['anydesk_api_token', 'anydesk_license_id', 'anydesk_read_only', 'anydesk_dry_run'];
|
||||
try {
|
||||
@ -4762,6 +4994,121 @@ function formatDate(dateString) {
|
||||
});
|
||||
}
|
||||
|
||||
function sagTestEscape(value) {
|
||||
return String(value ?? '')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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
|
||||
document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
@ -4773,6 +5120,7 @@ document.querySelectorAll('.settings-nav .nav-link').forEach(link => {
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
history.replaceState(null, '', `#${tab}`);
|
||||
|
||||
// Update nav
|
||||
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();
|
||||
} else if (tab === 'modules') {
|
||||
loadModules();
|
||||
} else if (tab === 'tests') {
|
||||
loadSagModuleTests();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -6103,6 +6453,7 @@ const MENU_VISIBILITY_GROUPS = [
|
||||
{
|
||||
title: 'Data migration underpunkter',
|
||||
items: [
|
||||
{ key: 'menu-datamigration-migration-center', label: 'Migreringscenter' },
|
||||
{ key: 'menu-datamigration-dashboard', label: 'Dashboard' },
|
||||
{ key: 'menu-datamigration-registrations', label: 'Registreringer' },
|
||||
{ key: 'menu-datamigration-wizard', label: 'Godkend Timer' },
|
||||
@ -6210,6 +6561,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadPipelineStages();
|
||||
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');
|
||||
if (saveMenuVisibilityBtn) {
|
||||
saveMenuVisibilityBtn.addEventListener('click', saveMenuVisibilityPreferences);
|
||||
|
||||
@ -1014,6 +1014,8 @@
|
||||
<i class="bi bi-clock-history me-2"></i>Data migration
|
||||
</a>
|
||||
<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-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>
|
||||
@ -1137,6 +1139,19 @@
|
||||
</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 -->
|
||||
<div id="emailResults" class="result-section mb-4" style="display: none;">
|
||||
<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('crmResults').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('salesResults')) document.getElementById('salesResults').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');
|
||||
}
|
||||
|
||||
// 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
|
||||
try {
|
||||
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>
|
||||
<input type="email" class="form-control" id="pref_email_override" placeholder="f.eks. navn@firma.dk">
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@ -2433,6 +2494,7 @@ if (bmcOriginalFetch) {
|
||||
{ key: 'menu-okonomi-prepaid', label: 'Økonomi: Prepaid Cards' },
|
||||
{ key: 'menu-okonomi-fixed-price', label: 'Økonomi: Fastpris Aftaler' },
|
||||
{ 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-registrations', label: 'Data migration: Registreringer' },
|
||||
{ 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_mattermost').checked = !!prefs.notify_mattermost;
|
||||
document.getElementById('pref_email_override').value = prefs.email_override || '';
|
||||
document.getElementById('pref_mattermost_username').value = prefs.mattermost_username || '';
|
||||
} catch (e) {
|
||||
console.error('Failed to load reminder preferences', e);
|
||||
}
|
||||
@ -2555,7 +2618,8 @@ if (bmcOriginalFetch) {
|
||||
notify_frontend: document.getElementById('pref_notify_frontend').checked,
|
||||
notify_email: document.getElementById('pref_notify_email').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 {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
Subscriptions Frontend Views
|
||||
"""
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import logging
|
||||
|
||||
@ -25,3 +25,19 @@ async def subscriptions_simply_imports(request: Request):
|
||||
return templates.TemplateResponse("subscriptions/frontend/simply_imports.html", {
|
||||
"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
106
app/utils/safe_html.py
Normal 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)
|
||||
64
app/vendors/backend/router.py
vendored
64
app/vendors/backend/router.py
vendored
@ -99,6 +99,70 @@ async def get_vendor(vendor_id: int):
|
||||
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"])
|
||||
async def create_vendor(vendor: VendorCreate):
|
||||
"""Create a new vendor"""
|
||||
|
||||
26
app/vendors/frontend/vendor_detail.html
vendored
26
app/vendors/frontend/vendor_detail.html
vendored
@ -627,7 +627,7 @@ function displayVendor(vendor) {
|
||||
|
||||
async function loadVendorInvoices() {
|
||||
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');
|
||||
|
||||
const invoices = await response.json();
|
||||
@ -673,7 +673,9 @@ function displayInvoices(invoices) {
|
||||
<td><strong>${formatCurrency(invoice.total_amount, invoice.currency)}</strong></td>
|
||||
<td><span class="badge ${statusClass}">${statusText}</span></td>
|
||||
<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>
|
||||
</a>
|
||||
</td>
|
||||
@ -688,7 +690,15 @@ function getInvoiceStatusClass(status) {
|
||||
'paid': 'bg-success',
|
||||
'overdue': 'bg-danger',
|
||||
'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';
|
||||
}
|
||||
@ -699,7 +709,15 @@ function getInvoiceStatusText(status) {
|
||||
'paid': 'Betalt',
|
||||
'overdue': 'Forfalden',
|
||||
'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;
|
||||
}
|
||||
|
||||
4
main.py
4
main.py
@ -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.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.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
|
||||
|
||||
# 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(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(migration_center_api.router, prefix="/api/v1/migration-center", tags=["Migration Center"])
|
||||
|
||||
if settings.LINKS_MODULE_ENABLED:
|
||||
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(internet_connections_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:
|
||||
from app.modules.links.frontend import views as links_views
|
||||
|
||||
7
migrations/1008_wall_outlet_wan.sql
Normal file
7
migrations/1008_wall_outlet_wan.sql
Normal 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;
|
||||
31
migrations/1009_internet_invoice_sync_runs.sql
Normal file
31
migrations/1009_internet_invoice_sync_runs.sql
Normal 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);
|
||||
15
migrations/1010_internet_invoice_review_decisions.sql
Normal file
15
migrations/1010_internet_invoice_review_decisions.sql
Normal 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);
|
||||
6
migrations/1011_mattermost_notification_settings.sql
Normal file
6
migrations/1011_mattermost_notification_settings.sql
Normal 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;
|
||||
2
migrations/1012_user_mattermost_username.sql
Normal file
2
migrations/1012_user_mattermost_username.sql
Normal file
@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);
|
||||
24
migrations/1013_sag_permissions.sql
Normal file
24
migrations/1013_sag_permissions.sql
Normal 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;
|
||||
185
migrations/228_migration_center.sql
Normal file
185
migrations/228_migration_center.sql
Normal 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;
|
||||
@ -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';
|
||||
@ -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);
|
||||
122
tests/test_migration_center.py
Normal file
122
tests/test_migration_center.py
Normal 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"
|
||||
@ -1,86 +1,199 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
import asyncio
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from app.main import app
|
||||
from app.core.database import execute_query
|
||||
from fastapi import HTTPException, UploadFile, Request
|
||||
|
||||
client = TestClient(app)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
@pytest.fixture(scope="function", autouse=True)
|
||||
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;")
|
||||
from app.utils.safe_html import sanitize_safe_html
|
||||
|
||||
def test_create_case():
|
||||
"""Test creating a new 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"
|
||||
})
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["titel"] == "Test Case"
|
||||
assert data["status"] == "åben"
|
||||
# Keep these focused unit tests independent of optional auth runtime packages.
|
||||
auth_dependencies_stub = types.ModuleType("app.core.auth_dependencies")
|
||||
|
||||
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
|
||||
response = client.get("/api/v1/cases")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["titel"] == "Test Case"
|
||||
def _allow_test_user(*_permissions):
|
||||
async def dependency(_request: Request):
|
||||
return {"id": 1, "username": "test", "permissions": []}
|
||||
|
||||
def test_soft_delete_case():
|
||||
"""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"]
|
||||
return dependency
|
||||
|
||||
# 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
|
||||
list_response = client.get("/api/v1/cases")
|
||||
assert list_response.status_code == 200
|
||||
data = list_response.json()
|
||||
assert len(data) == 0
|
||||
auth_dependencies_stub.require_any_permission = _allow_test_user
|
||||
|
||||
|
||||
async def _get_test_user(_request: Request):
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user