@@ -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
+ ? `
${count} forbindelser: ${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 => `
${field.name} · ${field.port_count} porte `).join('');
+ const switches = (locationHardware || []).filter(item => String(item.asset_type || '').toLowerCase() === 'netværk');
+ bulkSwitchSelect.innerHTML = switches.map(item => `
${switchDisplayName(item)} · ${switchPortCount(item)} porte `).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 = '
Ingen specifik kunde ' + customers.map(customer => `
${customer.name || customer.navn || `Kunde #${customer.id}`} `).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 = '
Ingen specifik kunde / brug lokationens kunde ';
- (customers || []).forEach(customer => {
+ const response = await fetch('/api/v1/customers?limit=1000&offset=0');
+ if (!response.ok) {
+ select.insertAdjacentHTML('beforeend', '
Kunne ikke indlæse kunder ');
+ 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', '
Ingen kunder fundet ');
+ }
}
+ 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;
- await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
+ const isExistingOutlet = Boolean(outlet?.id);
+ outletModal.show();
+ try {
+ await Promise.all([loadCrossFieldPorts(), loadOutletLocations(), loadOutletCustomers(outlet?.customerId || null)]);
+ } catch (error) {
+ console.error('Kunne ikke indlæse vægstik-data', error);
+ }
document.getElementById('outletId').value = outlet?.id || '';
document.getElementById('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', `
Tilknyttet krydsfelt-port `);
+ }
+ }
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());
diff --git a/app/modules/migration_center/__init__.py b/app/modules/migration_center/__init__.py
new file mode 100644
index 0000000..652cb7b
--- /dev/null
+++ b/app/modules/migration_center/__init__.py
@@ -0,0 +1 @@
+"""Manual migration centre for CRM subscriptions and imported invoice lines."""
diff --git a/app/modules/migration_center/backend/__init__.py b/app/modules/migration_center/backend/__init__.py
new file mode 100644
index 0000000..704e52f
--- /dev/null
+++ b/app/modules/migration_center/backend/__init__.py
@@ -0,0 +1 @@
+"""Migration centre backend."""
diff --git a/app/modules/migration_center/backend/router.py b/app/modules/migration_center/backend/router.py
new file mode 100644
index 0000000..a00fbe9
--- /dev/null
+++ b/app/modules/migration_center/backend/router.py
@@ -0,0 +1,1415 @@
+"""HTTP API for the manual migration centre."""
+from __future__ import annotations
+
+import json
+from datetime import date
+from decimal import Decimal
+from typing import Any, Dict, Optional
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.responses import Response
+from pydantic import BaseModel, Field
+from psycopg2.extras import Json, RealDictCursor
+
+from app.core.auth_dependencies import require_any_permission, require_permission
+from app.core.config import settings
+from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
+from app.modules.migration_center.backend.service import (
+ MUTABLE_LOCK_STATES,
+ audit,
+ create_session,
+ ensure_writable,
+ json_value,
+ match_item,
+ preflight_token,
+ report_csv,
+ user_id,
+ verify_preflight,
+ snapshot_hash,
+ attach_economic_snapshot,
+ EconomicSnapshotRepository,
+ subscription_like_item_sql,
+ refresh_subscription_relevance,
+)
+from app.services.simplycrm_service import SimplyCRMService
+from app.services.vtiger_service import get_vtiger_service
+
+router = APIRouter()
+
+EXCLUDED_INVOICE_LINE_SQL = """
+NOT (
+ source_system='economic' AND (
+ LOWER(COALESCE(product_name,'')) LIKE '%%gebyr%%'
+ OR LOWER(COALESCE(product_name,'')) LIKE '%%fragt%%'
+ OR LOWER(COALESCE(product_name,'')) LIKE '%%porto%%'
+ )
+)
+"""
+
+
+class SessionCreate(BaseModel):
+ name: str = Field(min_length=2, max_length=160)
+
+
+class SessionUpdate(BaseModel):
+ status: Optional[str] = None
+ read_only: Optional[bool] = None
+
+
+class CustomerCreate(BaseModel):
+ name: str = Field(min_length=1, max_length=255)
+ cvr_number: Optional[str] = None
+ email: Optional[str] = None
+ customer_no: Optional[str] = None
+
+
+class CaseCreate(BaseModel):
+ title: str = Field(min_length=1, max_length=255)
+ description: str = ""
+
+
+class LinkPayload(BaseModel):
+ hub_id: int
+
+
+class ReasonPayload(BaseModel):
+ reason: str = Field(min_length=2, max_length=2000)
+
+
+class NotePayload(BaseModel):
+ note: str = Field(max_length=5000)
+
+
+class CreatePayload(BaseModel):
+ preflight_token: str
+ idempotency_key: str = Field(min_length=8, max_length=120)
+
+
+def _safe_date(value: Any) -> Optional[date]:
+ if not value:
+ return None
+ if isinstance(value, date):
+ return value
+ try:
+ return date.fromisoformat(str(value)[:10])
+ except ValueError:
+ return None
+
+
+def _amount(value: Any) -> Decimal:
+ try:
+ return Decimal(str(value or 0).replace(",", "."))
+ except Exception:
+ return Decimal("0")
+
+
+def _hub_interval(value: Any) -> str:
+ normalized = str(value or "").strip().lower()
+ if normalized in {"daily", "biweekly", "monthly", "quarterly", "yearly"}:
+ return normalized
+ if "quarter" in normalized or "kvart" in normalized or normalized.startswith("3_month"):
+ return "quarterly"
+ if "year" in normalized or "annual" in normalized or "årlig" in normalized:
+ return "yearly"
+ if "week" in normalized or "uge" in normalized:
+ return "biweekly"
+ return "monthly"
+
+
+def _normalized_crm_record(source: str, raw: Dict[str, Any], service=None) -> Dict[str, Any]:
+ if source == "simply" and service:
+ data = service.extract_subscription_data(raw)
+ record_id = str(data.get("simplycrm_id") or raw.get("id") or "")
+ customer_id = str(data.get("account_id") or "")
+ product_name = data.get("name") or raw.get("subject") or "Simply abonnement"
+ amount = data.get("total_amount") or data.get("subtotal") or 0
+ frequency = data.get("billing_frequency")
+ start = _safe_date(data.get("start_date"))
+ end = _safe_date(data.get("end_date"))
+ active = data.get("status") != "cancelled"
+ else:
+ record_id = str(raw.get("id") or raw.get("subscriptionid") or "")
+ customer_id = str(raw.get("account_id") or raw.get("accountid") or "")
+ product_name = raw.get("subject") or raw.get("subscriptionname") or "Vtiger abonnement"
+ amount = raw.get("total") or raw.get("hdnGrandTotal") or raw.get("amount") or 0
+ frequency = str(raw.get("generateinvoiceevery") or raw.get("frequency") or "monthly").lower()
+ start = _safe_date(raw.get("startdate") or raw.get("start_period"))
+ end = _safe_date(raw.get("enddate") or raw.get("end_period"))
+ active = str(raw.get("subscriptionstatus") or "active").lower() not in {"cancelled", "inactive", "expired"}
+ normalized = {
+ "entity_type": "subscription", "source_system": source, "source_record_id": record_id,
+ "source_customer_id": customer_id, "customer_no": raw.get("customer_no") or raw.get("account_no"),
+ "customer_name": raw.get("accountname") or raw.get("customer_name") or customer_id,
+ "product_code": raw.get("product_code") or raw.get("productid"),
+ "product_name": product_name, "amount": _amount(amount), "quantity": _amount(raw.get("quantity") or 1),
+ "billing_frequency": frequency, "period_from": start, "period_to": end,
+ "invoice_no": None, "invoice_date": None, "source_payload": raw, "active": active,
+ }
+ normalized["source_hash"] = snapshot_hash(normalized)
+ return normalized
+
+
+def _store_crm_snapshot(session_id: int, item: Dict[str, Any]) -> str:
+ if not item["source_record_id"]:
+ return "skipped"
+ if item.get("source_customer_id"):
+ customer_payload = {
+ "source_customer_id": item["source_customer_id"],
+ "customer_no": item.get("customer_no"),
+ "customer_name": item.get("customer_name") or item["source_customer_id"],
+ }
+ execute_query(
+ """
+ INSERT INTO migration_center_source_customers
+ (source_system, source_customer_id, customer_no, customer_name, raw_payload, snapshot_hash)
+ VALUES (%s,%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_system"], item["source_customer_id"], item.get("customer_no"),
+ customer_payload["customer_name"], Json(customer_payload), snapshot_hash(customer_payload),
+ ), fetch=False,
+ )
+ existing = execute_query_single(
+ """
+ SELECT id, source_hash, source_payload, lock_status
+ FROM migration_center_session_items
+ WHERE session_id=%s AND entity_type='subscription' AND source_system=%s AND source_record_id=%s
+ """,
+ (session_id, item["source_system"], item["source_record_id"]),
+ )
+ execute_query(
+ """
+ INSERT INTO migration_center_source_subscriptions
+ (source_system, source_record_id, source_customer_id, customer_no, customer_name,
+ product_code, product_name, amount, quantity, billing_frequency, start_date, end_date,
+ active, raw_payload, snapshot_hash)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+ ON CONFLICT (source_system, source_record_id) DO UPDATE SET
+ source_customer_id=EXCLUDED.source_customer_id, customer_no=EXCLUDED.customer_no,
+ customer_name=EXCLUDED.customer_name, product_code=EXCLUDED.product_code,
+ product_name=EXCLUDED.product_name, amount=EXCLUDED.amount, quantity=EXCLUDED.quantity,
+ billing_frequency=EXCLUDED.billing_frequency, start_date=EXCLUDED.start_date,
+ end_date=EXCLUDED.end_date, active=EXCLUDED.active, raw_payload=EXCLUDED.raw_payload,
+ snapshot_hash=EXCLUDED.snapshot_hash, updated_at=CURRENT_TIMESTAMP
+ """,
+ (
+ 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["active"], Json(json_value(item["source_payload"])), item["source_hash"],
+ ), fetch=False,
+ )
+ if not existing:
+ execute_query(
+ """
+ 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, source_payload, source_hash)
+ VALUES (%s,'subscription',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
+ """,
+ (
+ session_id, 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"], Json(json_value(item["source_payload"])), item["source_hash"],
+ ), fetch=False,
+ )
+ return "created"
+ if existing["source_hash"] != item["source_hash"] and existing["lock_status"] != "locked":
+ execute_query(
+ """
+ UPDATE migration_center_session_items SET
+ previous_source_payload=source_payload, source_payload=%s, source_hash=%s,
+ customer_no=%s, customer_name=%s, product_code=%s, product_name=%s, amount=%s,
+ quantity=%s, billing_frequency=%s, period_from=%s, period_to=%s,
+ match_status='source_changed', approval_status='pending',
+ verified_at=NULL, verified_by_user_id=NULL, updated_at=CURRENT_TIMESTAMP
+ WHERE id=%s
+ """,
+ (
+ Json(json_value(item["source_payload"])), item["source_hash"], 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"],
+ existing["id"],
+ ), fetch=False,
+ )
+ return "changed"
+ return "unchanged"
+
+
+def _load_item(item_id: int, *, for_update: bool = False, cursor=None) -> Dict[str, Any]:
+ suffix = " FOR UPDATE" if for_update else ""
+ query = f"SELECT * FROM migration_center_session_items WHERE id = %s{suffix}"
+ if cursor:
+ cursor.execute(query, (item_id,))
+ row = cursor.fetchone()
+ else:
+ row = execute_query_single(query, (item_id,))
+ if not row:
+ raise HTTPException(status_code=404, detail="Post blev ikke fundet")
+ return dict(row)
+
+
+def _ensure_item_mutable(item: Dict[str, Any]) -> None:
+ ensure_writable(int(item["session_id"]))
+ if item["lock_status"] not in MUTABLE_LOCK_STATES:
+ raise HTTPException(status_code=423, detail="Posten er låst eller ved at blive låst")
+
+
+@router.get("/sessions")
+async def list_sessions(current_user: dict = Depends(require_permission("migration_center.view"))):
+ return execute_query(
+ """
+ SELECT s.*,
+ COUNT(i.id) AS item_count,
+ COUNT(i.id) FILTER (WHERE i.approval_status IN ('verified','ignored')) AS treated_count,
+ COUNT(i.id) FILTER (WHERE i.lock_status = 'locked') AS locked_count,
+ COUNT(i.id) FILTER (WHERE i.match_status IN ('conflict','source_changed')) AS conflict_count
+ FROM migration_center_sessions s
+ LEFT JOIN migration_center_session_items i ON i.session_id = s.id
+ GROUP BY s.id ORDER BY s.created_at DESC
+ """
+ ) or []
+
+
+@router.post("/sessions")
+async def add_session(
+ payload: SessionCreate,
+ request: Request,
+ current_user: dict = Depends(require_permission("migration_center.sessions")),
+):
+ return create_session(payload.name, current_user, request)
+
+
+@router.patch("/sessions/{session_id}")
+async def update_session(
+ session_id: int,
+ payload: SessionUpdate,
+ request: Request,
+ current_user: dict = Depends(require_permission("migration_center.sessions")),
+):
+ ensure_writable()
+ if payload.status and payload.status not in {"draft", "active", "completed", "archived"}:
+ raise HTTPException(status_code=400, detail="Ugyldig sessionsstatus")
+ existing = execute_query_single("SELECT * FROM migration_center_sessions WHERE id=%s", (session_id,))
+ if not existing:
+ raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
+ status = payload.status or existing["status"]
+ read_only = payload.read_only if payload.read_only is not None else existing["read_only"]
+ if status in {"completed", "archived"}:
+ read_only = True
+ result = execute_query_single(
+ """
+ UPDATE migration_center_sessions SET status=%s, read_only=%s, updated_at=CURRENT_TIMESTAMP
+ WHERE id=%s RETURNING *
+ """,
+ (status, read_only, session_id),
+ )
+ audit(
+ request=request, current_user=current_user, action="session_updated", entity_type="session",
+ entity_id=session_id, session_id=session_id, old_value=existing, new_value=result,
+ )
+ return result
+
+
+@router.post("/sessions/{session_id}/crm-import")
+async def import_crm_snapshots(
+ session_id: int,
+ request: Request,
+ source: str = Query(..., pattern="^(vtiger|simply)$"),
+ mock: bool = False,
+ current_user: dict = Depends(require_permission("migration_center.import")),
+):
+ ensure_writable(session_id)
+ if mock:
+ records = [{
+ "id": f"mock-{source}-1", "account_id": "mock-customer-1",
+ "accountname": "Eksempel Kunde A/S", "subject": "Microsoft 365 Business Premium",
+ "hdnGrandTotal": "1295.00", "quantity": 5, "recurring_frequency": "Monthly",
+ "start_period": date.today().replace(day=1).isoformat(), "enable_recurring": "1",
+ }]
+ service = SimplyCRMService() if source == "simply" else None
+ elif source == "simply":
+ async with SimplyCRMService() as service:
+ records = await service.fetch_active_subscriptions()
+ else:
+ service = None
+ records = await get_vtiger_service().query("SELECT * FROM Subscription;")
+ counts = {"created": 0, "changed": 0, "unchanged": 0, "skipped": 0}
+ item_ids = []
+ for raw in records:
+ item = _normalized_crm_record(source, dict(raw), service)
+ outcome = _store_crm_snapshot(session_id, item)
+ counts[outcome] += 1
+ row = execute_query_single(
+ """
+ SELECT id FROM migration_center_session_items
+ WHERE session_id=%s AND entity_type='subscription' AND source_system=%s AND source_record_id=%s
+ """,
+ (session_id, source, item["source_record_id"]),
+ )
+ if row:
+ item_ids.append(row["id"])
+ for item_id in item_ids:
+ row = _load_item(item_id)
+ if row["match_status"] != "source_changed":
+ match_item(item_id)
+ relevance = refresh_subscription_relevance(session_id)
+ audit(
+ request=request, current_user=current_user, action="crm_imported", entity_type="session",
+ entity_id=session_id, session_id=session_id, new_value={"source": source, **counts},
+ )
+ return {"source": source, "records": len(records), **counts, **relevance}
+
+
+@router.post("/sessions/{session_id}/economic-snapshot")
+async def load_economic_snapshot(
+ session_id: int,
+ request: Request,
+ current_user: dict = Depends(require_permission("migration_center.import")),
+):
+ ensure_writable(session_id)
+ run = EconomicSnapshotRepository.latest_run()
+ if not run:
+ raise HTTPException(
+ status_code=409,
+ detail="Faktura-fejl-finder har ingen anvendelig import med fakturaer",
+ )
+ counts = attach_economic_snapshot(session_id, run)
+ execute_query(
+ """
+ UPDATE migration_center_session_items i
+ SET hub_customer_id=c.id,
+ customer_name=COALESCE(NULLIF(i.customer_name,''),c.name),
+ hub_status=CASE WHEN i.hub_status='not_created' THEN 'ready_for_creation' ELSE i.hub_status END,
+ match_explanation=CASE
+ WHEN NOT (i.match_explanation ? 'e-conomic-kundenummer stemmer')
+ THEN i.match_explanation || '["e-conomic-kundenummer stemmer"]'::jsonb
+ ELSE i.match_explanation
+ END,
+ updated_at=CURRENT_TIMESTAMP
+ FROM customers c
+ WHERE i.session_id=%s AND i.source_system='economic'
+ AND i.lock_status IN ('unlocked','lock_failed')
+ AND NULLIF(i.customer_no,'') IS NOT NULL
+ AND c.economic_customer_number::text=i.customer_no
+ AND c.deleted_at IS NULL
+ """,
+ (session_id,),
+ fetch=False,
+ )
+ mapped_row = execute_query_single(
+ """
+ SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE hub_customer_id IS NOT NULL) AS mapped
+ FROM migration_center_session_items WHERE session_id=%s AND source_system='economic'
+ """,
+ (session_id,),
+ )
+ relevance = refresh_subscription_relevance(session_id)
+ result = {
+ **counts, "processed": int(mapped_row["total"]), "customers_mapped": int(mapped_row["mapped"]),
+ "import_run_id": run["id"], "snapshot_at": run["completed_at"], **relevance,
+ }
+ audit(
+ request=request, current_user=current_user, action="economic_snapshot_attached",
+ entity_type="session", entity_id=session_id, session_id=session_id, new_value=result,
+ )
+ return result
+
+
+@router.get("/sessions/{session_id}/dashboard")
+async def dashboard(
+ session_id: int,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ session = execute_query_single(
+ """
+ SELECT s.*, r.status AS economic_import_status, r.records_imported, r.records_failed
+ FROM migration_center_sessions s
+ LEFT JOIN invoice_error_finder_import_runs r ON r.id = s.economic_import_run_id
+ WHERE s.id = %s
+ """,
+ (session_id,),
+ )
+ if not session:
+ raise HTTPException(status_code=404, detail="Kontrolsession blev ikke fundet")
+ counts = execute_query_single(
+ f"""
+ SELECT COUNT(*) AS total,
+ COUNT(*) FILTER (WHERE entity_type='subscription') AS subscriptions,
+ COUNT(*) FILTER (WHERE entity_type='invoice_line') AS invoice_lines,
+ COUNT(*) FILTER (WHERE hub_status='ready_for_creation') AS ready,
+ COUNT(*) FILTER (WHERE hub_status='created_in_hub') AS created,
+ COUNT(*) FILTER (WHERE approval_status='ignored') AS ignored,
+ COUNT(*) FILTER (WHERE match_status IN ('conflict','source_changed')) AS conflicts,
+ COUNT(*) FILTER (WHERE match_status='source_changed') AS source_changed,
+ COUNT(*) FILTER (WHERE lock_status='locked') AS locked
+ FROM migration_center_session_items
+ WHERE session_id=%s
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql()}
+ """,
+ (session_id,),
+ )
+ return {
+ "session": session,
+ "counts": counts or {},
+ "read_only": bool(getattr(settings, "MIGRATION_CENTER_READ_ONLY", False) or session["read_only"]),
+ "stale_after_days": int(getattr(settings, "MIGRATION_CENTER_STALE_AFTER_DAYS", 7)),
+ }
+
+
+@router.get("/sessions/{session_id}/company-options")
+async def company_options(
+ session_id: int,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ return execute_query(
+ f"""
+ SELECT customer_name, MAX(hub_customer_id) AS hub_customer_id, COUNT(*) AS item_count
+ FROM migration_center_session_items
+ WHERE session_id=%s AND NULLIF(TRIM(customer_name),'') IS NOT NULL
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql()}
+ GROUP BY customer_name
+ ORDER BY LOWER(customer_name)
+ """,
+ (session_id,),
+ ) or []
+
+
+@router.get("/sessions/{session_id}/invoice-history")
+async def invoice_history(
+ session_id: int,
+ only_subscription_candidates: bool = False,
+ company_name: Optional[str] = None,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ candidate_filter = """
+ WHERE NOT has_hub_subscription AND invoice_count >= 1 AND invoiced_months >= 1
+ AND max_amount > 0 AND LOWER(product_name) NOT LIKE '%%gebyr%%'
+ """ if only_subscription_candidates else ""
+ rows = execute_query(
+ f"""
+ WITH grouped AS (
+ SELECT
+ COALESCE(NULLIF(customer_no,''), source_customer_id, customer_name) AS customer_key,
+ MAX(customer_name) AS customer_name,
+ MAX(customer_no) AS customer_no,
+ MAX(hub_customer_id) AS hub_customer_id,
+ COALESCE(NULLIF(product_code,''), LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g'))) AS product_key,
+ MAX(product_code) AS product_code,
+ MAX(product_name) AS product_name,
+ COUNT(DISTINCT invoice_no) AS invoice_count,
+ COUNT(DISTINCT DATE_TRUNC('month',invoice_date)) AS invoiced_months,
+ MIN(invoice_date) AS first_invoice_date,
+ MAX(invoice_date) AS last_invoice_date,
+ MIN(amount) AS min_amount,
+ MAX(amount) AS max_amount,
+ SUM(amount) AS total_amount,
+ BOOL_OR(hub_record_id IS NOT NULL OR suggested_hub_record_id IS NOT NULL) AS explicitly_linked,
+ MIN(id) AS representative_item_id
+ FROM migration_center_session_items mci
+ WHERE session_id=%s AND source_system='economic' AND entity_type='invoice_line'
+ AND invoice_date >= (DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '12 months')::date
+ AND invoice_date <= CURRENT_DATE
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql('mci')}
+ AND (%s IS NULL OR LOWER(customer_name)=LOWER(%s))
+ GROUP BY
+ COALESCE(NULLIF(customer_no,''), source_customer_id, customer_name),
+ COALESCE(NULLIF(product_code,''), LOWER(REGEXP_REPLACE(product_name,'\\s+',' ','g')))
+ ), matched AS (
+ SELECT grouped.*,
+ (
+ explicitly_linked OR EXISTS (
+ SELECT 1 FROM sag_subscriptions s
+ WHERE s.customer_id=grouped.hub_customer_id
+ AND s.status <> 'cancelled'
+ AND (
+ LOWER(TRIM(COALESCE(s.product_name,'')))=LOWER(TRIM(grouped.product_name))
+ OR ABS(COALESCE(s.price,0)-COALESCE(grouped.max_amount,0)) <= 0.01
+ )
+ )
+ ) AS has_hub_subscription
+ FROM grouped
+ )
+ SELECT *,
+ CASE
+ WHEN invoiced_months >= 10 THEN 'monthly'
+ WHEN invoiced_months >= 4 AND
+ (last_invoice_date-first_invoice_date)/GREATEST(invoiced_months-1,1) BETWEEN 60 AND 120
+ THEN 'quarterly'
+ WHEN invoice_count >= 2 AND last_invoice_date-first_invoice_date >= 300 THEN 'yearly'
+ ELSE 'irregular'
+ END AS suggested_frequency,
+ (
+ NOT has_hub_subscription AND invoice_count >= 1 AND invoiced_months >= 1
+ AND max_amount > 0 AND LOWER(product_name) NOT LIKE '%%gebyr%%'
+ ) AS subscription_candidate
+ FROM matched
+ {candidate_filter}
+ ORDER BY subscription_candidate DESC, invoiced_months DESC, customer_name, product_name
+ """,
+ (session_id, company_name, company_name),
+ ) or []
+ return {"months": 13, "groups": rows, "count": len(rows)}
+
+
+@router.get("/sessions/{session_id}/items")
+async def list_items(
+ session_id: int,
+ q: Optional[str] = None,
+ company_name: Optional[str] = None,
+ entity_type: Optional[str] = None,
+ source_system: Optional[str] = None,
+ match_status: Optional[str] = None,
+ approval_status: Optional[str] = None,
+ hub_status: Optional[str] = None,
+ lock_status: Optional[str] = None,
+ only_deviations: bool = False,
+ page: int = Query(1, ge=1),
+ page_size: int = Query(50, ge=1, le=200),
+ sort: str = "invoice_date",
+ direction: str = "desc",
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ allowed_sort = {
+ "invoice_date": "invoice_date", "customer": "customer_name", "product": "product_name",
+ "amount": "amount", "status": "match_status", "created": "created_at",
+ }
+ clauses = ["session_id = %s"]
+ params: list[Any] = [session_id]
+ filters = {
+ "entity_type": entity_type, "source_system": source_system, "match_status": match_status,
+ "approval_status": approval_status, "hub_status": hub_status, "lock_status": lock_status,
+ }
+ for column, value in filters.items():
+ if value:
+ clauses.append(f"{column} = %s")
+ params.append(value)
+ if only_deviations:
+ clauses.append("(match_status IN ('no_match','conflict','manual_review','source_changed') OR hub_status='not_created')")
+ if q:
+ clauses.append(
+ "(customer_name ILIKE %s OR customer_no ILIKE %s OR product_name ILIKE %s "
+ "OR product_code ILIKE %s OR invoice_no ILIKE %s OR source_record_id ILIKE %s)"
+ )
+ term = f"%{q.strip()}%"
+ params.extend([term] * 6)
+ if company_name:
+ clauses.append("LOWER(customer_name) = LOWER(%s)")
+ params.append(company_name.strip())
+ clauses.append(EXCLUDED_INVOICE_LINE_SQL)
+ clauses.append(subscription_like_item_sql())
+ where = " AND ".join(clauses)
+ total = execute_query_single(f"SELECT COUNT(*) AS count FROM migration_center_session_items WHERE {where}", tuple(params))
+ order = allowed_sort.get(sort, "invoice_date")
+ direction_sql = "ASC" if direction.lower() == "asc" else "DESC"
+ rows = execute_query(
+ f"""
+ SELECT * FROM migration_center_session_items
+ WHERE {where}
+ ORDER BY {order} {direction_sql} NULLS LAST, id DESC
+ LIMIT %s OFFSET %s
+ """,
+ tuple(params + [page_size, (page - 1) * page_size]),
+ ) or []
+ return {"items": rows, "total": int((total or {}).get("count", 0)), "page": page, "page_size": page_size}
+
+
+@router.get("/items/{item_id}")
+async def get_item(
+ item_id: int,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ return _load_item(item_id)
+
+
+@router.get("/items/{item_id}/context")
+async def get_item_context(
+ item_id: int,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ item = _load_item(item_id)
+ params: list[Any] = [item["session_id"]]
+ relations = []
+ if item.get("hub_customer_id"):
+ relations.append("related.hub_customer_id=%s")
+ params.append(item["hub_customer_id"])
+ if item.get("customer_no"):
+ relations.append("(NULLIF(related.customer_no,'') IS NOT NULL AND related.customer_no=%s)")
+ params.append(item["customer_no"])
+ if item.get("source_customer_id"):
+ relations.append(
+ "(related.source_system=%s AND related.source_customer_id=%s)"
+ )
+ params.extend([item["source_system"], item["source_customer_id"]])
+ relation_sql = " OR ".join(relations) or "related.id=%s"
+ if not relations:
+ params.append(item_id)
+ related = execute_query(
+ f"""
+ SELECT related.*
+ FROM migration_center_session_items related
+ WHERE related.session_id=%s AND ({relation_sql})
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql('related')}
+ ORDER BY
+ CASE related.source_system
+ WHEN 'economic' THEN 1 WHEN 'vtiger' THEN 2 WHEN 'simply' THEN 3 ELSE 4
+ END,
+ related.invoice_date DESC NULLS LAST, related.period_from DESC NULLS LAST, related.id DESC
+ LIMIT 500
+ """,
+ tuple(params),
+ ) or []
+ by_source = {"economic": [], "vtiger": [], "simply": []}
+ for row in related:
+ source = row.get("source_system")
+ if source in by_source:
+ by_source[source].append(row)
+
+ hub_customer = None
+ hub_cases = []
+ hub_subscriptions = []
+ if item.get("hub_customer_id"):
+ hub_customer = execute_query_single(
+ """
+ SELECT id, name, cvr_number, email, phone, economic_customer_number, vtiger_id
+ FROM customers WHERE id=%s
+ """,
+ (item["hub_customer_id"],),
+ )
+ hub_cases = execute_query(
+ """
+ SELECT id, titel, status, template_key, created_at
+ FROM sag_sager WHERE customer_id=%s
+ ORDER BY created_at DESC LIMIT 100
+ """,
+ (item["hub_customer_id"],),
+ ) or []
+ hub_subscriptions = execute_query(
+ """
+ SELECT s.id, s.subscription_number, s.sag_id, s.product_name, s.price,
+ s.billing_interval, s.start_date, s.end_date, s.status,
+ COALESCE(
+ JSONB_AGG(
+ JSONB_BUILD_OBJECT(
+ 'id', line.id, 'description', line.description, 'quantity', line.quantity,
+ 'unit_price', line.unit_price, 'line_total', line.line_total
+ ) ORDER BY line.line_no
+ ) FILTER (WHERE line.id IS NOT NULL),
+ '[]'::jsonb
+ ) AS lines
+ FROM sag_subscriptions s
+ LEFT JOIN sag_subscription_items line ON line.subscription_id=s.id
+ WHERE s.customer_id=%s
+ GROUP BY s.id
+ ORDER BY s.updated_at DESC LIMIT 100
+ """,
+ (item["hub_customer_id"],),
+ ) or []
+ return {
+ "item": item,
+ "sources": by_source,
+ "hub": {
+ "customer": hub_customer,
+ "cases": hub_cases,
+ "subscriptions": hub_subscriptions,
+ },
+ "counts": {
+ "economic": len(by_source["economic"]),
+ "vtiger": len(by_source["vtiger"]),
+ "simply": len(by_source["simply"]),
+ "hub_subscriptions": len(hub_subscriptions),
+ },
+ }
+
+
+@router.get("/sessions/{session_id}/queue/next")
+async def next_queue_item(
+ session_id: int,
+ after_id: Optional[int] = None,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ params: list[Any] = [session_id]
+ after = ""
+ if after_id:
+ after = " AND id > %s"
+ params.append(after_id)
+ row = execute_query_single(
+ f"""
+ SELECT * FROM migration_center_session_items
+ WHERE session_id=%s AND approval_status='pending'
+ AND lock_status IN ('unlocked','lock_failed')
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql()} {after}
+ ORDER BY CASE match_status WHEN 'conflict' THEN 0 WHEN 'source_changed' THEN 1
+ WHEN 'manual_review' THEN 2 ELSE 3 END, id
+ LIMIT 1
+ """,
+ tuple(params),
+ )
+ return {"item": row}
+
+
+@router.get("/sessions/{session_id}/customers/{customer_id}")
+async def customer_overview(
+ session_id: int,
+ customer_id: int,
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ customer = execute_query_single(
+ """
+ SELECT id, name, cvr_number, email, economic_customer_number
+ FROM customers WHERE id=%s AND deleted_at IS NULL
+ """,
+ (customer_id,),
+ )
+ if not customer:
+ raise HTTPException(status_code=404, detail="Kunden blev ikke fundet")
+ items = execute_query(
+ f"""
+ SELECT * FROM migration_center_session_items
+ WHERE session_id=%s AND hub_customer_id=%s
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql()}
+ ORDER BY entity_type, invoice_date DESC NULLS LAST, id
+ """,
+ (session_id, customer_id),
+ ) or []
+ subscriptions = execute_query(
+ """
+ SELECT id, subscription_number, product_name, price, billing_interval, status, start_date, end_date
+ FROM sag_subscriptions WHERE customer_id=%s ORDER BY updated_at DESC
+ """,
+ (customer_id,),
+ ) or []
+ open_conflicts = [
+ row["id"] for row in items if row["match_status"] in {"conflict", "source_changed"}
+ ]
+ untreated = [
+ row["id"] for row in items if row["approval_status"] not in {"verified", "ignored"}
+ ]
+ return {
+ "customer": customer, "items": items, "hub_subscriptions": subscriptions,
+ "open_conflicts": open_conflicts, "untreated": untreated,
+ "can_lock_all": bool(items) and not open_conflicts and not untreated,
+ }
+
+
+@router.post("/items/{item_id}/rematch")
+async def rematch(
+ item_id: int, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.import")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ result = match_item(item_id)
+ audit(
+ request=request, current_user=current_user, action="rematched", entity_type=item["entity_type"],
+ entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
+ old_value=item, new_value=result, source_hash_value=item["source_hash"],
+ )
+ return result
+
+
+@router.post("/sessions/{session_id}/rematch")
+async def rematch_session(
+ session_id: int, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.import")),
+):
+ ensure_writable(session_id)
+ rows = execute_query(
+ """
+ SELECT id FROM migration_center_session_items
+ WHERE session_id=%s AND lock_status IN ('unlocked','lock_failed')
+ ORDER BY id
+ """,
+ (session_id,),
+ ) or []
+ counts = {"processed": 0, "customers_mapped": 0, "matches_found": 0}
+ for row in rows:
+ result = match_item(int(row["id"]))
+ counts["processed"] += 1
+ if result.get("hub_customer_id"):
+ counts["customers_mapped"] += 1
+ if result.get("match_status") == "match_found":
+ counts["matches_found"] += 1
+ audit(
+ request=request, current_user=current_user, action="session_rematched",
+ entity_type="session", entity_id=session_id, session_id=session_id, new_value=counts,
+ )
+ return counts
+
+
+@router.post("/items/{item_id}/customers")
+async def create_customer(
+ item_id: int, payload: CustomerCreate, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.create")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ duplicate = execute_query_single(
+ """
+ SELECT id, name FROM customers
+ WHERE deleted_at IS NULL AND (
+ (%s IS NOT NULL AND NULLIF(TRIM(cvr_number),'') = NULLIF(TRIM(%s),''))
+ OR LOWER(name) = LOWER(%s)
+ ) LIMIT 1
+ """,
+ (payload.cvr_number, payload.cvr_number, payload.name),
+ )
+ if duplicate:
+ raise HTTPException(status_code=409, detail={"message": "Mulig dublet fundet", "customer": duplicate})
+ email_domain = payload.email.split("@", 1)[1].lower() if payload.email and "@" in payload.email else None
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ cursor.execute(
+ """
+ INSERT INTO customers (name, cvr_number, email, email_domain, country, is_active, economic_customer_number)
+ VALUES (%s,%s,%s,%s,'DK',TRUE,%s) RETURNING id, name, cvr_number, email
+ """,
+ (payload.name, payload.cvr_number, payload.email, email_domain, payload.customer_no),
+ )
+ customer = dict(cursor.fetchone())
+ cursor.execute(
+ "UPDATE migration_center_session_items SET hub_customer_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
+ (customer["id"], item_id),
+ )
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ release_db_connection(conn)
+ audit(
+ request=request, current_user=current_user, action="customer_created", entity_type="customer",
+ entity_id=customer["id"], session_id=item["session_id"], item_id=item_id, new_value=customer,
+ source_hash_value=item["source_hash"],
+ )
+ return customer
+
+
+@router.post("/items/{item_id}/customer-link")
+async def link_customer(
+ item_id: int, payload: LinkPayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ customer = execute_query_single("SELECT id, name FROM customers WHERE id=%s AND deleted_at IS NULL", (payload.hub_id,))
+ if not customer:
+ raise HTTPException(status_code=404, detail="Hub-kunden blev ikke fundet")
+ execute_query(
+ """
+ UPDATE migration_center_session_items SET hub_customer_id=%s,
+ hub_sag_id=NULL, hub_record_id=NULL, hub_status='ready_for_creation',
+ updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (payload.hub_id, item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="customer_linked", entity_type=item["entity_type"],
+ entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
+ old_value={"hub_customer_id": item.get("hub_customer_id")}, new_value={"hub_customer_id": payload.hub_id},
+ source_hash_value=item["source_hash"],
+ )
+ return {"success": True, "customer": customer}
+
+
+@router.post("/items/{item_id}/cases")
+async def create_case(
+ item_id: int, payload: CaseCreate, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.create")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ if not item.get("hub_customer_id"):
+ raise HTTPException(status_code=409, detail="Kunden skal linkes eller oprettes først")
+ row = execute_query_single(
+ """
+ INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, created_by_user_id)
+ VALUES (%s,%s,'subscription','åben',%s,%s) RETURNING id, titel, customer_id
+ """,
+ (payload.title, payload.description, item["hub_customer_id"], user_id(current_user)),
+ )
+ execute_query(
+ "UPDATE migration_center_session_items SET hub_sag_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
+ (row["id"], item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="case_created", entity_type="case",
+ entity_id=row["id"], session_id=item["session_id"], item_id=item_id, new_value=row,
+ source_hash_value=item["source_hash"],
+ )
+ return row
+
+
+@router.post("/items/{item_id}/case-link")
+async def link_case(
+ item_id: int, payload: LinkPayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ case = execute_query_single(
+ "SELECT id, titel, customer_id FROM sag_sager WHERE id=%s AND customer_id=%s",
+ (payload.hub_id, item.get("hub_customer_id")),
+ )
+ if not case:
+ raise HTTPException(status_code=404, detail="Sagen findes ikke på den valgte kunde")
+ execute_query(
+ "UPDATE migration_center_session_items SET hub_sag_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
+ (payload.hub_id, item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="case_linked", entity_type=item["entity_type"],
+ entity_id=item["source_record_id"], session_id=item["session_id"], item_id=item_id,
+ new_value={"hub_sag_id": payload.hub_id}, source_hash_value=item["source_hash"],
+ )
+ return {"success": True, "case": case}
+
+
+@router.get("/items/{item_id}/preflight")
+async def preflight(
+ item_id: int,
+ current_user: dict = Depends(require_permission("migration_center.create")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ blockers = []
+ if not item.get("hub_customer_id"):
+ blockers.append("Kunden er ikke linket")
+ if not item.get("hub_sag_id"):
+ blockers.append("Sagen er ikke linket")
+ if item.get("hub_record_id") or item["hub_status"] in {"created_in_hub", "linked_to_existing"}:
+ blockers.append("Posten er allerede oprettet eller linket")
+ duplicate = execute_query_single(
+ "SELECT id FROM sag_subscriptions WHERE migration_source_item_id=%s", (item_id,)
+ )
+ if duplicate:
+ blockers.append(f"Kildeposten er allerede oprettet som abonnement {duplicate['id']}")
+ if item.get("hub_sag_id"):
+ case = execute_query_single(
+ "SELECT id FROM sag_sager WHERE id=%s AND customer_id=%s",
+ (item["hub_sag_id"], item.get("hub_customer_id")),
+ )
+ if not case:
+ blockers.append("Den valgte sag tilhører ikke kunden")
+ preview = {
+ "customer": item.get("customer_name"), "customer_no": item.get("customer_no"),
+ "product": item.get("product_name"), "product_code": item.get("product_code"),
+ "amount": item.get("amount"), "quantity": item.get("quantity"),
+ "frequency": _hub_interval(item.get("billing_frequency")),
+ "start_date": item.get("period_from") or item.get("invoice_date") or date.today(),
+ "end_date": item.get("period_to"), "source": item.get("source_system"),
+ "source_record_id": item.get("source_record_id"),
+ "hub_customer_id": item.get("hub_customer_id"), "hub_sag_id": item.get("hub_sag_id"),
+ }
+ return {
+ "ready": not blockers, "blockers": blockers, "preview": preview,
+ "token": preflight_token(item, current_user) if not blockers else None,
+ }
+
+
+@router.post("/items/{item_id}/create-in-hub")
+async def create_in_hub(
+ item_id: int, payload: CreatePayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.create")),
+):
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ item = _load_item(item_id, for_update=True, cursor=cursor)
+ _ensure_item_mutable(item)
+ verify_preflight(payload.preflight_token, item, current_user)
+ if not item.get("hub_customer_id") or not item.get("hub_sag_id"):
+ raise HTTPException(status_code=409, detail="Kunde og sag skal være valgt")
+ if item.get("hub_record_id") or item["hub_status"] in {"created_in_hub", "linked_to_existing"}:
+ return {"success": True, "idempotent": True, "hub_record_id": item.get("hub_record_id")}
+ cursor.execute(
+ "SELECT id FROM sag_subscriptions WHERE migration_source_item_id=%s", (item_id,)
+ )
+ duplicate = cursor.fetchone()
+ if duplicate:
+ cursor.execute(
+ """
+ UPDATE migration_center_session_items SET hub_record_id=%s, hub_status='created_in_hub',
+ updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (duplicate["id"], item_id),
+ )
+ conn.commit()
+ return {"success": True, "idempotent": True, "hub_record_id": duplicate["id"]}
+ start_date = item.get("period_from") or item.get("invoice_date") or date.today()
+ interval = _hub_interval(item.get("billing_frequency"))
+ amount = Decimal(str(item.get("amount") or 0))
+ quantity = Decimal(str(item.get("quantity") or 1))
+ unit_price = amount / quantity if quantity else amount
+ cursor.execute(
+ """
+ INSERT INTO sag_subscriptions
+ (sag_id, customer_id, product_name, billing_interval, billing_day, price,
+ start_date, end_date, period_start, next_invoice_date, status, notes,
+ created_by_user_id, migration_source_item_id)
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'draft',%s,%s,%s)
+ RETURNING id, subscription_number
+ """,
+ (
+ item["hub_sag_id"], item["hub_customer_id"], item["product_name"], interval,
+ min(max(start_date.day, 1), 31), amount, start_date, item.get("period_to"),
+ start_date, start_date, f"Manuelt oprettet via migreringscenter fra {item['source_system']} "
+ f"{item['source_record_id']} (idempotency {payload.idempotency_key})",
+ user_id(current_user), item_id,
+ ),
+ )
+ subscription = dict(cursor.fetchone())
+ cursor.execute(
+ """
+ INSERT INTO sag_subscription_items
+ (subscription_id, line_no, description, quantity, unit_price, line_total, period_from, period_to)
+ VALUES (%s,1,%s,%s,%s,%s,%s,%s)
+ """,
+ (
+ subscription["id"], item["product_name"], quantity or Decimal("1"),
+ unit_price, amount, item.get("period_from"), item.get("period_to"),
+ ),
+ )
+ cursor.execute(
+ """
+ UPDATE migration_center_session_items
+ SET hub_record_id=%s, hub_status='created_in_hub', approval_status='approved',
+ creation_idempotency_key=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (subscription["id"], payload.idempotency_key, item_id),
+ )
+ conn.commit()
+ except HTTPException:
+ conn.rollback()
+ raise
+ except Exception as exc:
+ conn.rollback()
+ audit(
+ request=request, current_user=current_user, action="create_in_hub",
+ entity_type="session_item", entity_id=item_id, item_id=item_id, success=False,
+ error_message=str(exc),
+ )
+ raise HTTPException(status_code=500, detail=f"Oprettelsen fejlede uden lokal statusændring: {exc}") from exc
+ finally:
+ release_db_connection(conn)
+ audit(
+ request=request, current_user=current_user, action="created_in_hub",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, old_value={"hub_status": item["hub_status"]},
+ new_value={"hub_status": "created_in_hub", "hub_record_id": subscription["id"]},
+ source_hash_value=item["source_hash"],
+ )
+ return {"success": True, "idempotent": False, "hub_record_id": subscription["id"], "subscription": subscription}
+
+
+@router.post("/items/{item_id}/hub-link")
+async def link_hub_record(
+ item_id: int, payload: LinkPayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ subscription = execute_query_single(
+ "SELECT id, subscription_number, customer_id FROM sag_subscriptions WHERE id=%s AND customer_id=%s",
+ (payload.hub_id, item.get("hub_customer_id")),
+ )
+ if not subscription:
+ raise HTTPException(status_code=404, detail="Abonnementet findes ikke på den valgte kunde")
+ execute_query(
+ """
+ UPDATE migration_center_session_items SET hub_record_id=%s, hub_status='linked_to_existing',
+ approval_status='approved', updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (payload.hub_id, item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="linked_to_existing",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, new_value={"hub_record_id": payload.hub_id}, source_hash_value=item["source_hash"],
+ )
+ return {"success": True, "subscription": subscription}
+
+
+@router.post("/items/{item_id}/verify")
+async def verify_item(
+ item_id: int, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ if item["hub_status"] not in {"created_in_hub", "linked_to_existing"}:
+ raise HTTPException(status_code=409, detail="Posten skal oprettes eller linkes før kontrol")
+ execute_query(
+ """
+ UPDATE migration_center_session_items
+ SET approval_status='verified', hub_status='verified', verified_at=CURRENT_TIMESTAMP,
+ verified_by_user_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (user_id(current_user), item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="marked_verified",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, source_hash_value=item["source_hash"],
+ )
+ return {"success": True}
+
+
+@router.post("/items/{item_id}/ignore")
+async def ignore_item(
+ item_id: int, payload: ReasonPayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ execute_query(
+ """
+ UPDATE migration_center_session_items SET approval_status='ignored', ignore_reason=%s,
+ updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (payload.reason, item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="ignored",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, new_value={"reason": payload.reason}, source_hash_value=item["source_hash"],
+ )
+ return {"success": True}
+
+
+@router.put("/items/{item_id}/note")
+async def update_note(
+ item_id: int, payload: NotePayload, request: Request,
+ current_user: dict = Depends(require_permission("migration_center.review")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ execute_query(
+ "UPDATE migration_center_session_items SET manual_note=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s",
+ (payload.note, item_id), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="note_updated",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, old_value={"note": item.get("manual_note")}, new_value={"note": payload.note},
+ source_hash_value=item["source_hash"],
+ )
+ return {"success": True}
+
+
+@router.post("/items/{item_id}/lock")
+async def lock_item(
+ item_id: int, request: Request,
+ current_user: dict = Depends(require_any_permission("migration_center.lock", "migration_center.retry")),
+):
+ item = _load_item(item_id)
+ _ensure_item_mutable(item)
+ if item["approval_status"] not in {"verified", "ignored"}:
+ raise HTTPException(status_code=409, detail="Posten skal være verificeret eller ignoreret før låsning")
+ if item["match_status"] in {"conflict", "source_changed"}:
+ raise HTTPException(status_code=409, detail="Åben konflikt eller kildeændring blokerer låsning")
+ # Economic is read-only and has no external lock target. CRM records require configured custom fields.
+ lock_field = None
+ if item["source_system"] in {"vtiger", "simply"}:
+ lock_field = (
+ getattr(settings, "MIGRATION_CENTER_VTIGER_LOCK_FIELD", "")
+ if item["source_system"] == "vtiger"
+ else getattr(settings, "MIGRATION_CENTER_SIMPLY_LOCK_FIELD", "")
+ )
+ if not lock_field:
+ raise HTTPException(status_code=409, detail="CRM-låsefeltet er ikke konfigureret")
+ # Persist pending before any remote call so a crash cannot look like a completed lock.
+ pending = execute_query_single(
+ """
+ INSERT INTO migration_center_lock_operations
+ (session_item_id, status, external_system, request_payload, requested_by_user_id)
+ VALUES (%s,'pending',%s,%s,%s) RETURNING id
+ """,
+ (
+ item_id, item["source_system"],
+ Json({"source_record_id": item["source_record_id"], "field": lock_field}),
+ user_id(current_user),
+ ),
+ )
+ execute_query(
+ "UPDATE migration_center_session_items SET lock_status='locking_pending', updated_at=CURRENT_TIMESTAMP WHERE id=%s",
+ (item_id,), fetch=False,
+ )
+ if lock_field:
+ try:
+ lock_values = {
+ lock_field: "1",
+ "hub_locked_at": date.today().isoformat(),
+ "hub_transfer_status": "locked",
+ }
+ if item["source_system"] == "vtiger":
+ await get_vtiger_service().update_subscription(item["source_record_id"], lock_values)
+ else:
+ async with SimplyCRMService() as service:
+ await service._ensure_session()
+ async with service.session.post(
+ f"{service.base_url}/webservice.php",
+ data={
+ "operation": "update",
+ "sessionName": service.session_name,
+ "elementType": "SalesOrder",
+ "element": json.dumps({"id": item["source_record_id"], **lock_values}),
+ },
+ ) as response:
+ result = await response.json()
+ if not response.ok or not result.get("success"):
+ raise RuntimeError(str(result.get("error") or f"HTTP {response.status}"))
+ except Exception as exc:
+ execute_query(
+ """
+ UPDATE migration_center_session_items SET lock_status='lock_failed', updated_at=CURRENT_TIMESTAMP
+ WHERE id=%s
+ """,
+ (item_id,), fetch=False,
+ )
+ execute_query(
+ """
+ UPDATE migration_center_lock_operations SET status='failed', error_message=%s,
+ completed_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (str(exc), pending["id"]), fetch=False,
+ )
+ audit(
+ request=request, current_user=current_user, action="lock_failed",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"],
+ session_id=item["session_id"], item_id=item_id, success=False,
+ error_message=str(exc), source_hash_value=item["source_hash"],
+ )
+ raise HTTPException(status_code=502, detail=f"Ekstern låsning fejlede: {exc}") from exc
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ if item.get("hub_record_id"):
+ cursor.execute(
+ """
+ UPDATE sag_subscriptions SET migration_locked=TRUE, migration_locked_at=CURRENT_TIMESTAMP,
+ migration_locked_by_user_id=%s WHERE id=%s
+ """,
+ (user_id(current_user), item["hub_record_id"]),
+ )
+ cursor.execute(
+ """
+ UPDATE migration_center_session_items SET lock_status='locked', locked_at=CURRENT_TIMESTAMP,
+ locked_by_user_id=%s, updated_at=CURRENT_TIMESTAMP WHERE id=%s
+ """,
+ (user_id(current_user), item_id),
+ )
+ cursor.execute(
+ """
+ UPDATE migration_center_lock_operations SET status='succeeded', completed_at=CURRENT_TIMESTAMP
+ WHERE id=%s
+ """,
+ (pending["id"],),
+ )
+ conn.commit()
+ except Exception:
+ conn.rollback()
+ raise
+ finally:
+ release_db_connection(conn)
+ audit(
+ request=request, current_user=current_user, action="locked",
+ entity_type=item["entity_type"], entity_id=item["source_record_id"], session_id=item["session_id"],
+ item_id=item_id, new_value={"lock_status": "locked"}, source_hash_value=item["source_hash"],
+ )
+ return {"success": True, "lock_status": "locked"}
+
+
+@router.post("/sessions/{session_id}/customers/{customer_id}/lock")
+async def lock_customer_items(
+ session_id: int,
+ customer_id: int,
+ request: Request,
+ current_user: dict = Depends(require_any_permission("migration_center.lock", "migration_center.retry")),
+):
+ ensure_writable(session_id)
+ rows = execute_query(
+ f"""
+ SELECT * FROM migration_center_session_items
+ WHERE session_id=%s AND hub_customer_id=%s
+ AND {EXCLUDED_INVOICE_LINE_SQL}
+ AND {subscription_like_item_sql()}
+ ORDER BY id
+ """,
+ (session_id, customer_id),
+ ) or []
+ if not rows:
+ raise HTTPException(status_code=404, detail="Ingen poster blev fundet for kunden")
+ blockers = [
+ row["id"] for row in rows
+ if row["lock_status"] != "locked" and (
+ row["approval_status"] not in {"verified", "ignored"}
+ or row["match_status"] in {"conflict", "source_changed"}
+ )
+ ]
+ if blockers:
+ raise HTTPException(
+ status_code=409,
+ detail={"message": "Alle poster skal være behandlet uden åbne konflikter", "item_ids": blockers},
+ )
+ results = []
+ for row in rows:
+ if row["lock_status"] == "locked":
+ continue
+ try:
+ result = await lock_item(int(row["id"]), request, current_user)
+ results.append({"item_id": row["id"], **result})
+ except HTTPException as exc:
+ audit(
+ request=request, current_user=current_user, action="customer_lock_partial",
+ entity_type="customer", entity_id=customer_id, session_id=session_id,
+ success=False, error_message=str(exc.detail), new_value={"completed": results},
+ )
+ raise
+ audit(
+ request=request, current_user=current_user, action="customer_locked",
+ entity_type="customer", entity_id=customer_id, session_id=session_id,
+ new_value={"items": [row["item_id"] for row in results]},
+ )
+ return {"success": True, "locked": results}
+
+
+@router.get("/sessions/{session_id}/audit")
+async def audit_log(
+ session_id: int, limit: int = Query(200, ge=1, le=1000),
+ current_user: dict = Depends(require_permission("migration_center.view")),
+):
+ return execute_query(
+ """
+ SELECT a.*, COALESCE(u.full_name,u.username) AS performed_by
+ FROM migration_center_audit_log a
+ LEFT JOIN users u ON u.user_id=a.performed_by_user_id
+ WHERE a.session_id=%s ORDER BY a.performed_at DESC LIMIT %s
+ """,
+ (session_id, limit),
+ ) or []
+
+
+@router.get("/sessions/{session_id}/report.csv")
+async def export_report(
+ session_id: int,
+ current_user: dict = Depends(require_permission("migration_center.export")),
+):
+ content = "\ufeff" + report_csv(session_id)
+ return Response(
+ content=content, media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="migration-session-{session_id}.csv"'},
+ )
diff --git a/app/modules/migration_center/backend/service.py b/app/modules/migration_center/backend/service.py
new file mode 100644
index 0000000..b4fff9e
--- /dev/null
+++ b/app/modules/migration_center/backend/service.py
@@ -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()
diff --git a/app/modules/migration_center/frontend/__init__.py b/app/modules/migration_center/frontend/__init__.py
new file mode 100644
index 0000000..2b5b4f0
--- /dev/null
+++ b/app/modules/migration_center/frontend/__init__.py
@@ -0,0 +1 @@
+"""Migration centre frontend."""
diff --git a/app/modules/migration_center/frontend/views.py b/app/modules/migration_center/frontend/views.py
new file mode 100644
index 0000000..6d7937e
--- /dev/null
+++ b/app/modules/migration_center/frontend/views.py
@@ -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},
+ )
diff --git a/app/modules/migration_center/module.json b/app/modules/migration_center/module.json
new file mode 100644
index 0000000..d428796
--- /dev/null
+++ b/app/modules/migration_center/module.json
@@ -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"]
+}
diff --git a/app/modules/migration_center/templates/index.html b/app/modules/migration_center/templates/index.html
new file mode 100644
index 0000000..b972612
--- /dev/null
+++ b/app/modules/migration_center/templates/index.html
@@ -0,0 +1,349 @@
+{% extends "shared/frontend/base.html" %}
+{% block title %}Migreringscenter - BMC Hub{% endblock %}
+{% block content %}
+
+
+
+
+
+
Manuel kontrol og overførsel
+
Migreringscenter
+
Abonnementer fra CRM og fakturalinjer fra Faktura-fejl-finder. Intet oprettes automatisk.
+
+
+
+
Ny session
+
Hent Vtiger
+
Hent Simply
+
Indlæs 13 mdr. fakturaer
+
Match alle igen
+
CSV
+
+
+
+
+
+
+
+
+
Ingen kontrolsession endnu
+
Opret en session for at fastlåse seneste e-conomic-grundlag fra Faktura-fejl-finder.
+
Opret kontrolsession
+
+
+
+
+
+
+
+ Dashboard
+ Arbejdsbakke
+ Abonnementer
+ Fakturaer
+ 13 mdr. historik
+ Mulige abonnementer
+ Afvigelser
+ Låste
+ Log / historik
+
+
+
+
+
+
+
+
+
+
Alle virksomheder
+
Alle kilder Vtiger Simply CRM e-conomic
+
Status Intet match Manuel kontrol Kilde ændret Match fundet
+
Lås Ulåst Låser Låsefejl Låst
+
Filtrér
+
+
+
+
+
+
+ Dato Kunde Kilde Produkt Beløb Match Workflow Hub-ID Handling
+
+
+
+
+
+
+
+
+ Tidspunkt Bruger Handling Type Reference Resultat
+
+
+ Fakturahistorik Grupperet på kunde og vare for de seneste 13 måneder. Forslag opretter aldrig noget automatisk.
Filtrér på virksomhed Alle virksomheder
+ Kunde Vare Fakturaer Måneder Periode Beløb Frekvens Hub-match Handling
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/modules/sag/backend/reminders.py b/app/modules/sag/backend/reminders.py
index e885b87..16add6d 100644
--- a/app/modules/sag/backend/reminders.py
+++ b/app/modules/sag/backend/reminders.py
@@ -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))
diff --git a/app/modules/sag/backend/router.py b/app/modules/sag/backend/router.py
index 9f73295..7834eab 100644
--- a/app/modules/sag/backend/router.py
+++ b/app/modules/sag/backend/router.py
@@ -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,))
@@ -1886,6 +1949,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)
@@ -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"):
diff --git a/app/modules/sag/backend/solutions.py b/app/modules/sag/backend/solutions.py
index 76d752a..f58ce66 100644
--- a/app/modules/sag/backend/solutions.py
+++ b/app/modules/sag/backend/solutions.py
@@ -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:
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index f86b0f3..c672e12 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -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():
diff --git a/app/modules/sag/scripts/sag_module_e2e.py b/app/modules/sag/scripts/sag_module_e2e.py
new file mode 100644
index 0000000..566a296
--- /dev/null
+++ b/app/modules/sag/scripts/sag_module_e2e.py
@@ -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": "
Sikker HTML
"},
+ ))
+ 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())
diff --git a/app/modules/sag/services/relation_service.py b/app/modules/sag/services/relation_service.py
index 2eebc93..6fac859 100644
--- a/app/modules/sag/services/relation_service.py
+++ b/app/modules/sag/services/relation_service.py
@@ -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
diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html
index b2516f0..765681d 100644
--- a/app/modules/sag/templates/create.html
+++ b/app/modules/sag/templates/create.html
@@ -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
};
diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html
index 1df5c0e..4c37066 100644
--- a/app/modules/sag/templates/detail_v3.html
+++ b/app/modules/sag/templates/detail_v3.html
@@ -3859,7 +3859,7 @@
-
{{ case.beskrivelse or '' }}
+
{{ (case.beskrivelse or '')|safe_case_html|safe }}
{% if not case.beskrivelse %}
Ingen opgavebeskrivelse tilføjet endnu.
@@ -4130,7 +4130,7 @@
data-bs-toggle="tooltip"
data-bs-html="true"
data-bs-placement="right"
- title="
Hvad betyder relationstyper? Relateret til : Faglig kobling uden direkte afhængighed.
Afledt af : Denne sag er opstået på baggrund af en anden sag.
Årsag til : Denne sag er årsagen til en anden sag.
Blokkerer : Arbejde i en sag stopper fremdrift i den anden.">
+ title="
Vælg det, der beskriver forholdet bedst Relateret : Samme emne uden afhængighed.
Undersag : En mindre del af denne sag.
Afhænger af : Denne sag kan ikke fortsætte før den anden.
Blokerer : Den anden sag kan ikke fortsætte før denne.
Duplikat : Samme problem er registreret to gange.">
@@ -4179,6 +4179,12 @@
(Årsag til)
{% elif rel_key == 'blokkerer' %}
Blokerer
+ {% elif rel_key == 'undersag' %}
+ Undersag
+ {% elif rel_key == 'afhænger af' %}
+ Afhænger af
+ {% elif rel_key == 'duplikat' %}
+ Duplikat
{% else %}
Koblet til
(Relateret til)
@@ -4187,6 +4193,23 @@
{% endif %}
+
+ {% if node.case.next_todo_title %}
+
+
+ {{ node.case.next_todo_title }}
+
+ {% if node.case.next_todo_due_date %}
+
+ Frist {{ node.case.next_todo_due_date.strftime('%d.%m.%Y') }}
+
+ {% endif %}
+ {% elif not node.is_current %}
+ Ingen åben todo
+ {% else %}
+ –
+ {% endif %}
+
{% if node.relation_id %}
@@ -4221,6 +4244,7 @@
Status
Type
Sammenhæng
+ Næste todo
Handling
@@ -4352,13 +4376,14 @@
- 2. Vælg relationstype
+ 2. Hvordan forholder den valgte sag sig til denne sag?
- Vælg hvordan sagerne er relateret...
- 🔗 Koblet til (Relateret) - Faglig kobling uden direkte afhængighed
- ↪ Kommer fra (Afledt af) - Denne sag er opstået pga. den anden
- ➡ Skaber følge-sag (Årsag til) - Denne sag skaber den anden
- ⛔ Blokerer - Den anden kan ikke videre før denne er løst
+ Vælg forholdet mellem sagerne...
+ 🔗 Kun relateret — ingen afhængighed
+ ↳ Den valgte sag er en undersag til denne
+ ⏳ Denne sag afhænger af den valgte
+ ⛔ Denne sag blokerer den valgte
+ ⧉ Sagerne beskriver det samme problem
@@ -4366,10 +4391,7 @@
Betydning i praksis
-
Koblet til (Relateret) : Faglig sammenhæng, men ingen direkte afhængighed.
-
Kommer fra (Afledt af) : Sagen er opstået pga. en anden sag.
-
Skaber følge-sag (Årsag til) : Sagen skaber behovet for en anden sag.
-
Blokkerer : Bruges når løsning i én sag er nødvendig før den anden kan videre.
+
Teksten i valget beskriver retningen direkte. Du skal altså ikke selv afkode “kilde” og “mål”.
@@ -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 = '
↪ Effekt: Nuværende sag markeres som kommer fra den nye sag.';
+ if (selected === 'undersag') {
+ hint.innerHTML = '
↳ Resultat: Den nye sag bliver en undersag til denne sag.';
return;
}
- if (selected === 'Årsag til') {
- hint.innerHTML = '
➡ Effekt: Nuværende sag markeres som at den skaber den nye følge-sag.';
+ if (selected === 'afhænger af') {
+ hint.innerHTML = '
⏳ Resultat: Denne sag markeres som afhængig af den nye sag.';
return;
}
if (selected === 'Blokkerer') {
- hint.innerHTML = '
⛔ Effekt: Nuværende sag markeres som blokering for den nye sag.';
+ hint.innerHTML = '
⛔ Resultat: Denne sag markeres som blokering for den nye sag.';
+ return;
+ }
+ if (selected === 'duplikat') {
+ hint.innerHTML = '
⧉ Resultat: Den nye sag markeres som et duplikat af denne sag.';
return;
}
- hint.innerHTML = '
🔗 Effekt: Sagerne kobles fagligt uden direkte afhængighed (Koblet til).';
+ hint.innerHTML = '
🔗 Resultat: 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 @@
-
${reminder.title}
-
${reminder.message || '-'}
+
${escapeHtml(reminder.title)}
+
${escapeHtml(reminder.message || '-')}
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}
@@ -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 @@
- Relationstype *
+ Hvordan forholder den nye sag sig til denne sag? *
- Relateret til (Ingen direkte afhængighed)
- Afledt af (Nuværende sag er afledt af den nye)
- Årsag til (Nuværende sag er årsag til den nye)
- Blokkerer (Nuværende sag blokerer den nye)
+ ↳ Den nye sag er en undersag til denne
+ 🔗 Kun relateret — ingen afhængighed
+ ⏳ Denne sag afhænger af den nye
+ ⛔ Denne sag blokerer den nye
+ ⧉ Den nye sag er et duplikat af denne
-
Sådan vælger du korrekt relation
-
Relateret til : Samme emne/område, men ingen direkte afhængighed.
-
Afledt af : Den nye sag opstår fordi den nuværende sag findes.
-
Årsag til : Den nuværende sag opstår fordi den nye sag findes.
-
Blokkerer : Løsning i én sag er nødvendig før den anden kan afsluttes.
+
Vælg ud fra sætningen
+
Hvert valg beskriver den nye sag i forhold til den sag, du står på nu.
Beskrivelse
@@ -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
? '
Arbejdsseddel'
: (isScannerFile
@@ -15051,7 +15156,7 @@
-
+
${displayName}
${badgeHtml}
@@ -15059,10 +15164,10 @@
${size} • ${new Date(f.created_at).toLocaleDateString()}
@@ -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 @@
- ${bodyText ? `
${escapeHtml(bodyText)} ` : (bodyHtml ? bodyHtml : '
Ingen indhold
')}
+ ${bodyText ? `
${escapeHtml(bodyText)} ` : (bodyHtml || '
Ingen indhold
')}
`;
@@ -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 (saveBtn) saveBtn.disabled = false; }
+ } catch {
+ if (typeof showNotification === 'function') showNotification('Kunne ikke kontakte serveren', 'error');
+ 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();
diff --git a/app/services/email_service.py b/app/services/email_service.py
index 58c3e2a..6245a65 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -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,
diff --git a/app/services/reminder_notification_service.py b/app/services/reminder_notification_service.py
index d561831..5cac635 100644
--- a/app/services/reminder_notification_service.py
+++ b/app/services/reminder_notification_service.py
@@ -25,8 +25,38 @@ 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,
@@ -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
)
- result['channels_used'].append('mattermost')
+ if sent:
+ result['channels_used'].append('mattermost')
+ else:
+ result['errors'].append('Mattermost delivery failed')
- elif channel == 'email' and settings.REMINDERS_EMAIL_ENABLED and user_email:
- 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
)
- result['channels_used'].append('email')
+ if sent:
+ result['channels_used'].append('email')
+ else:
+ result['errors'].append(f'Email delivery failed for {user_email}')
elif channel == 'frontend':
# 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
)
- result['channels_used'].append('email')
+ if sent:
+ result['channels_used'].append('email')
+ else:
+ result['errors'].append(f'Email delivery failed for {email_addr}')
+ else:
+ result['errors'].append('Email reminders are disabled')
except Exception as e:
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(
@@ -221,6 +281,11 @@ class ReminderNotificationService:
) -> List[str]:
"""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)
@@ -228,7 +293,7 @@ class ReminderNotificationService:
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")
}
diff --git a/app/settings/backend/router.py b/app/settings/backend/router.py
index a3ab420..ede7935 100644
--- a/app/settings/backend/router.py
+++ b/app/settings/backend/router.py
@@ -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"])
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index bd9b868..d3291af 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -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;
+ }
{% endblock %}
@@ -122,6 +142,9 @@
Mission
+
+ Tests
+
System
@@ -469,7 +492,7 @@
-
+
Notifikation Indstillinger
+
+
+
+
Mattermost
+
Send sagsreminders til en Mattermost incoming webhook.
+
+
Ikke konfigureret
+
+
+
+ Aktivér Mattermost-reminders
+
+
+
+
Incoming webhook URL
+
+
En gemt webhook vises aldrig igen. Lad feltet være tomt for at beholde den nuværende.
+
+
+
Kanal (valgfri)
+
+
Brug kanalens tekniske navn/slug uden mellemrum. Tomt felt bruger webhookens standardkanal.
+
+
+
+
+ Gem Mattermost
+
+
+ Send test
+
+
+
+
@@ -1507,6 +1565,84 @@ async def scan_document(file_path: str):
+
+
+
+
+
+
+
Sag-modul – komplet funktionstest
+ Ikke kørt
+
+
+ Tester sager, undersager, relationer, todos, kommentarer, tags,
+ buzzwords, søgning, ordrelinjer og filer. Testdata ryddes automatisk op.
+
+
+
+ Kør Sag-test
+
+
+
+
+ Kun superadmins kan starte testen. Mail, AnyDesk, printer og eksterne
+ økonomisystemer markeres som SKIP, fordi de kræver eksterne tjenester eller hardware.
+
+
+
+
+
+
+
+
+
Seneste resultat
+
Ingen testrapport endnu.
+
+
+
+
Kør testen for at se resultater.
+
+
+
+
+
Tidligere kørsler
+
+
+
+
+ Tidspunkt
+ Bestået
+ Fejl
+ Skip
+
+
+
+ Ingen historik endnu.
+
+
+
+
+
+
@@ -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 = '
Kør testen for at se resultater.
';
+ 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 => `
+
+
${sagTestEscape(item.status)}
+
${sagTestEscape(item.name)}
+
${Number(item.duration_ms || 0)} ms
+ ${item.detail ? `
${sagTestEscape(item.detail)}
` : ''}
+
+ `).join('') || '
Rapporten indeholder ingen resultater.
';
+}
+
+function renderSagTestHistory(reports) {
+ sagTestReportsCache = Array.isArray(reports) ? reports : [];
+ const body = document.getElementById('sagTestHistory');
+ if (!sagTestReportsCache.length) {
+ body.innerHTML = '
Ingen historik endnu. ';
+ return;
+ }
+ body.innerHTML = sagTestReportsCache.map((report, index) => {
+ const summary = report.summary || {};
+ return `
+
+ ${sagTestEscape(formatDate(report.created_at))}
+ ${Number(summary.PASS || 0)}
+ ${Number(summary.FAIL || 0)}
+ ${Number(summary.SKIP || 0)}
+
+ `;
+ }).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 =
+ `
${sagTestEscape(error.message)}
`;
+ }
+}
+
+async function runSagModuleTest() {
+ const button = document.getElementById('runSagTestBtn');
+ const state = document.getElementById('sagTestState');
+ button.disabled = true;
+ button.innerHTML = '
Kører test…';
+ state.className = 'badge text-bg-primary';
+ state.textContent = 'Kører…';
+ document.getElementById('sagTestResults').innerHTML =
+ '
Tester Sag-modulet og rydder testdata op…
';
+ 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 =
+ `
${sagTestEscape(error.message)}
`;
+ } finally {
+ button.disabled = false;
+ button.innerHTML = '
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);
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 6c93bc7..700e404 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -1014,6 +1014,8 @@
Data migration
+
+
+
@@ -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
+ ? ` •
${buzzwords.map(word => escapeHtml(word)).join(', ')}`
+ : '';
+ return `
+
+
+
#${Number(item.id)} ${escapeHtml(item.titel || 'Uden titel')}
+
+ ${escapeHtml(item.status || '-')}
+ ${item.customer_name ? ` • ${escapeHtml(item.customer_name)}` : ''}
+ ${buzzwordHtml}
+
+
+
+
+ `;
+ }).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) {
Email override
+
+
Mattermost-brugernavn
+
+
Bruges til direkte beskeder som @brugernavn.
+
Gem
@@ -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 {
diff --git a/app/subscriptions/frontend/views.py b/app/subscriptions/frontend/views.py
index a9083d1..259b661 100644
--- a/app/subscriptions/frontend/views.py
+++ b/app/subscriptions/frontend/views.py
@@ -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)
diff --git a/app/utils/safe_html.py b/app/utils/safe_html.py
new file mode 100644
index 0000000..d039111
--- /dev/null
+++ b/app/utils/safe_html.py
@@ -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)
diff --git a/app/vendors/backend/router.py b/app/vendors/backend/router.py
index 2a1972b..e6c8b31 100644
--- a/app/vendors/backend/router.py
+++ b/app/vendors/backend/router.py
@@ -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"""
diff --git a/app/vendors/frontend/vendor_detail.html b/app/vendors/frontend/vendor_detail.html
index 4327c98..f67c328 100644
--- a/app/vendors/frontend/vendor_detail.html
+++ b/app/vendors/frontend/vendor_detail.html
@@ -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) {
${formatCurrency(invoice.total_amount, invoice.currency)}
${statusText}
-
+
@@ -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;
}
diff --git a/main.py b/main.py
index 6957eab..a1cf2d7 100644
--- a/main.py
+++ b/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
diff --git a/migrations/1008_wall_outlet_wan.sql b/migrations/1008_wall_outlet_wan.sql
new file mode 100644
index 0000000..8d81ced
--- /dev/null
+++ b/migrations/1008_wall_outlet_wan.sql
@@ -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;
diff --git a/migrations/1009_internet_invoice_sync_runs.sql b/migrations/1009_internet_invoice_sync_runs.sql
new file mode 100644
index 0000000..d36051a
--- /dev/null
+++ b/migrations/1009_internet_invoice_sync_runs.sql
@@ -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);
diff --git a/migrations/1010_internet_invoice_review_decisions.sql b/migrations/1010_internet_invoice_review_decisions.sql
new file mode 100644
index 0000000..6d5a850
--- /dev/null
+++ b/migrations/1010_internet_invoice_review_decisions.sql
@@ -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);
diff --git a/migrations/1011_mattermost_notification_settings.sql b/migrations/1011_mattermost_notification_settings.sql
new file mode 100644
index 0000000..8fb0f5f
--- /dev/null
+++ b/migrations/1011_mattermost_notification_settings.sql
@@ -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;
diff --git a/migrations/1012_user_mattermost_username.sql b/migrations/1012_user_mattermost_username.sql
new file mode 100644
index 0000000..80e68dc
--- /dev/null
+++ b/migrations/1012_user_mattermost_username.sql
@@ -0,0 +1,2 @@
+ALTER TABLE user_notification_preferences
+ ADD COLUMN IF NOT EXISTS mattermost_username VARCHAR(100);
diff --git a/migrations/1013_sag_permissions.sql b/migrations/1013_sag_permissions.sql
new file mode 100644
index 0000000..1778596
--- /dev/null
+++ b/migrations/1013_sag_permissions.sql
@@ -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;
diff --git a/migrations/228_migration_center.sql b/migrations/228_migration_center.sql
new file mode 100644
index 0000000..7078f98
--- /dev/null
+++ b/migrations/228_migration_center.sql
@@ -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;
diff --git a/migrations/229_migration_center_subscription_visibility_indexes.sql b/migrations/229_migration_center_subscription_visibility_indexes.sql
new file mode 100644
index 0000000..e38efa0
--- /dev/null
+++ b/migrations/229_migration_center_subscription_visibility_indexes.sql
@@ -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';
diff --git a/migrations/230_migration_center_subscription_relevance.sql b/migrations/230_migration_center_subscription_relevance.sql
new file mode 100644
index 0000000..06aa944
--- /dev/null
+++ b/migrations/230_migration_center_subscription_relevance.sql
@@ -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);
diff --git a/tests/test_migration_center.py b/tests/test_migration_center.py
new file mode 100644
index 0000000..2746236
--- /dev/null
+++ b/tests/test_migration_center.py
@@ -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"
diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py
index 1b9c1a7..3e87277 100644
--- a/tests/test_sag_module.py
+++ b/tests/test_sag_module.py
@@ -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
\ No newline at end of file
+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""))
+
+ 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(
+ ''
+ '
Hej verden
'
+ ''
+ '
farligt link '
+ )
+
+ assert result == (
+ '
Hej verden
'
+ '
farligt link '
+ )
+ 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