Compare commits

..

No commits in common. "main" and "v2.6.1" have entirely different histories.
main ... v2.6.1

217 changed files with 1757 additions and 24452 deletions

View File

@ -3,7 +3,6 @@
# =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub
@ -11,12 +10,6 @@ POSTGRES_PASSWORD=bmc_hub
POSTGRES_DB=bmc_hub
POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services
# Public website content administration API
WEBSITE_CONTENT_API_URL=https://isp.bmcnetworks.dk/api/admin-content.php
WEBSITE_CONTENT_API_HOST_HEADER=ct.bmcnetworks.dk
WEBSITE_CONTENT_API_TOKEN=
WEBSITE_CONTENT_API_TIMEOUT_SECONDS=20
# =====================================================
# API CONFIGURATION
# =====================================================

View File

@ -1,19 +0,0 @@
# Release Notes: v2.6.2
**Dato:** 25. august 2026
## Overblik
Version 2.6.2 retter udgående opkald fra kontakt-popupen på en sag.
## Telefoni
- En manglende afsluttende klamme i SMS-eventlisteneren gjorde den tilhørende JavaScript-blok ugyldig.
- Click-to-call-funktionen indlæses nu korrekt igen.
- Ring-knappen bruger fortsat kontaktens mobilnummer som førstevalg og telefonnummeret som fallback.
## Verifikation
- JavaScript-syntakskontrol bestået for den berørte blok.
- Sag-modulets tests: **31 bestået**.
- Diff-kontrol gennemført.

View File

@ -1,25 +0,0 @@
# Release Notes: v2.7.0
**Dato:** 25. august 2026
## Overblik
Version 2.7.0 gør linkede telefonopkald og AnyDesk-sessioner synlige i både sagens tidsregistrering og samlede historik.
## Tidsregistrering
- Ny oversigt over linkede opkald og AnyDesk-sessioner på sagen.
- Aktiviteter viser type, tidspunkt, varighed, kontakt/nummer og medarbejder.
- Aktiviteterne vises som kildedata og opretter ikke automatisk fakturerbar tid.
## Historik
- Linkede indgående og udgående opkald indgår som selvstændige historikhændelser.
- Linkede AnyDesk-sessioner indgår med status, varighed og ansvarlig medarbejder.
- Historik med undersager inkluderer også aktiviteter linket til undersagerne.
## Verifikation
- Sag-modul: **33 bestået**.
- Telefoni: **5 bestået**.
- Python-kompilering og diff-kontrol gennemført.

View File

@ -1,20 +0,0 @@
# Release Notes: v2.7.1
**Dato:** 25. august 2026
## Overblik
Version 2.7.1 retter linkning og varighed for ældre telefonopkald fra Mission Control.
## Telefoni
- Ældre opkald fra `mission_call_state` flyttes til den almindelige telefonilog, når en kontakt eller sag linkes.
- Alle linkede opkald bliver dermed synlige på sagen samt i Tidregistrering og Historik.
- Den stabile eksterne call-id og kildetype sendes med ved linkning.
- Forsinkede afslutningshændelser kan ikke længere skabe varigheder på flere måneder.
- Eksisterende urimelige varigheder over 12 timer skjules som ukendt varighed.
## Verifikation
- Telefoni- og sag-tests: **41 bestået**.
- Python-kompilering og diff-kontrol gennemført.

View File

@ -1,30 +0,0 @@
# BMC Hub v2.7.2
## CRM
- Kunde- og kontaktkort har nye faner til direkte relaterede sager og e-mails.
- Kontaktsøgning understøtter navn i flere rækkefølger, firma, titel, afdeling, e-mail og normaliserede telefonnumre.
- Kontakter kan oprettes eller opdateres fra Outlook `.msg`/`.eml` med godkendelse før lagring.
- CVR fra mails valideres gennem den eksisterende FirmaAPI-integration, og eksisterende virksomheder genbruges.
- Leverandørservice kan administreres direkte fra kundens økonomiske oplysninger.
## Sager og telefoni
- Nye sager forvælger den aktuelle bruger som ansvarlig.
- Kontaktkort på sager har direkte handlinger til opkald og SMS.
- Opkald startet fra en sag knyttes til sagen og vises i opkaldshistorikken.
- Firmaet i sagens topområde linker direkte til kundekortet.
## Abonnementer
- Udvidet aftale-, faktureringskalender- og ændringsflow.
- Nye migreringer til første fakturalinjer, periodisering og integritetskontrol.
## Websiteindhold
- Nyt administrationsmodul til kundereferencer og driftsinformation.
- Rettighedsstyret visning og redigering samt website-endpoints til indhold og logoer.
## Verifikation
- Målrettede CRM-, sag-, telefoni-, abonnements- og website-tests er kørt før release.

View File

@ -1,18 +0,0 @@
# BMC Hub v2.8.0
## Ny sag
- Redesignet, mere fokuseret oprettelsesflow med kompakte typevalg og progressive paneler.
- Browser-kladdesystem, hurtigskabeloner, tagvælger og tastaturgenvej til oprettelse.
- Duplikatindsigt for kunde og valgt kontakt samt arbejdsbelastning for ansvarlig medarbejder.
- Brand- og type-tagforslag ud fra sagens titel og beskrivelse.
## Interne beskeder
- Nyt, mere overskueligt beskedflow mellem medarbejdere.
- Understøttelse af korte telefonbeskeder med valgfri kontaktperson og tilbageringningsnummer.
## Database
- Kør `migrations/238_case_create_templates.sql`.
- Kør `migrations/239_internal_phone_messages.sql`.

View File

@ -1,39 +0,0 @@
# BMC Hub v2.8.1
## Planner
- Nyt Planner-modul til fordeling af sager og overblik over teamets kapacitet.
- Dag-, uge- og månedsvisning med planlagte blokke, registreret tid og ekstern travl tid.
- Drag-and-drop af sager med kompakt forhåndsvisning, 15-minutters placering og håndterbare standardblokke.
- Bredere sagsoversigt og forbedret pladsudnyttelse i kalenderen.
- Kalender og Planlægger er samlet i en Kalender-undermenu.
## Systemlog og drift
- Ny administrativ systemlog til telefoni, e-mail og øvrige integrationshændelser.
- Udvidet hændelseslogning og adgangskontrol i indstillingerne.
## AI og fakturaskabeloner
- Rettet AI Auto-generer i Template Builder.
- Tilføjet manglende `OWN_CVR`-konfiguration og validering af Ollama-fejl.
- Ollama fakturaudtræk tvinges til struktureret JSON.
- GUI'en viser nu den konkrete AI-fejl i stedet for en generisk fejlbesked.
## Integrationer og brugerflade
- Forbedringer til telefoni, e-mail og ALSO Cloud-integrationerne.
- Mindre rettelser til login, indstillinger og den fælles navigation.
- Fremtidige udviklingsplaner har fået et fast dokumentationsområde.
## Database
- Kør `migrations/240_planner.sql`.
- Kør `migrations/241_system_event_log.sql`.
## Verifikation
- Planner: 8 tests bestået.
- Samlet testsuite: 271 bestået, 1 sprunget over.
- Fire eksisterende tests kræver databaseinitialisering eller har forældede invoice-history mocks.
- Python- og JavaScript-syntakskontrol bestået.

View File

@ -1,11 +0,0 @@
# BMC Hub v2.8.10
## Gem e-conomic-ordrenummer
- Gemmer `economic_draft_id` fra det eksisterende eksportflow som kladdens e-conomic-ordrenummer.
- Audit-eventet `export_success` indeholder dermed det faktiske nummer i stedet for `null`.
- Bevarer understøttelse af de øvrige eksisterende feltnavne i eksportsvaret.
## Verifikation
- Regressionstest dækker mapping fra `economic_draft_id` til e-conomic-ordrenummer.

View File

@ -1,6 +0,0 @@
# BMC Hub v2.8.11
## Ordreoverskrift
- Ordrekladder oprettet fra ALSO-abonnementer får overskriften `Abonnementer`.
- Eksisterende åbne abonnementskladder får samme overskrift, når der tilføjes flere godkendte linjer.

View File

@ -1,21 +0,0 @@
# BMC Hub v2.8.12
## Eksportkvittering og afstemning
- Gemmer e-conomic-kunde, valuta, layout, nettobeløb, request, response, ekstern reference, oprettede varer og brugeren, der eksporterede.
- Sender den interne idempotency-reference med ordrekladden til e-conomic.
- En ordre kan ikke længere markeres som eksporteret, bogført eller betalt uden et e-conomic-ordrenummer.
- Eksisterende poster i den ugyldige kombination flyttes til `uncertain`, så de skal afstemmes før en eventuel ny eksport.
- Gemmer og viser den seneste eksportfejl.
## Ordrevisning
- Viser kundenavn sammen med Hub-ID og e-conomic-kundenummer.
- Viser en kompakt eksportkvittering med ordrenummer, periode, valuta, layout, nettobeløb, bruger og reference.
- Viser eksporthistorik som læsbare beskrivelser i stedet for rå JSON.
- Skjuler fakturanummer indtil bogføring og skjuler tvungen geneksport efter gennemført eksport.
- Retter eksisterende ALSO-abonnementskladder til titlen `Abonnementer`.
## Installation
- Kør migration `245_ordre_export_receipt.sql` før applikationen opdateres.

View File

@ -1,13 +0,0 @@
# BMC Hub v2.8.14
## ALSO ordrekladder og e-conomic-reference
- Bruger Hub-ordre-ID som `Eksternt ID` ved eksport til e-conomic.
- Efterlader `Tekst 1` tom for nye ALSO-genererede ordrekladder.
- Tilføjer perioden fra CSV-feltet `Actual Charge Interval` til varelinjens beskrivelse.
- Migration 247 rydder den tidligere systemtekst og opdaterer eksisterende, ikke-eksporterede ALSO-ordrekladder med perioden.
- Bevarer den tidligere eksportreference ved afstemning af allerede påbegyndte eksportforsøg.
## Opgradering
Kør `migrations/247_also_order_external_id.sql` efter deployment. Migrationen ændrer kun ALSO-ordrekladder med status `pending`; eksporterede ordre ændres ikke.

View File

@ -1,11 +0,0 @@
# BMC Hub v2.8.15
## ALSO-perioder på ordrelinjer
- Viser perioden fra `Actual Charge Interval` på en ny linje under produktnavnet.
- Migration 248 retter eksisterende, ikke-eksporterede ALSO-ordrekladder.
- Allerede eksporterede ordre ændres ikke.
## Opgradering
Kør `migrations/248_also_order_period_line_break.sql` efter deployment.

View File

@ -1,9 +0,0 @@
# BMC Hub v2.8.16
## Flerlinjede ordrebeskrivelser
- Viser ordrelinjens produkt og ALSO-periode på separate linjer i både opret- og detaljevisningen.
- Bruger et kompakt tekstområde ved redigering, så linjeskiftet bevares ved gem og eksport til e-conomic.
- Viser linjeskift i forhåndsvisningen af en e-conomic-opdatering.
Der følger ingen ny databasemigration med denne release.

View File

@ -1,13 +0,0 @@
# BMC Hub v2.8.2
## AI Template Builder
- Rettet fejl når Ollama returnerer fakturabeløb som tekst i stedet for numerisk JSON.
- Understøtter nu både danske beløbsformater som `1.471,20 DKK` og engelske formater som `1471.20`.
- Ugyldige eller tomme beløb håndteres uden at afbryde AI-analysen.
- Tilføjet regressionstest for beløbsnormalisering.
## Verifikation
- Regressionstest: 2 bestået.
- Python-syntaks og diff-kontrol bestået.

View File

@ -1,38 +0,0 @@
# BMC Hub v2.8.3
## Internetforbindelser og abonnementer
- Ny hurtig oprettelse med særskilte flows for fysisk forbindelse, delt hovedfiber og BMCnet.
- Delt hovedfiber oprettes med BMC Networks som ejer og uden produkt, abonnement eller abonnementssag.
- BMCnet opretter og forbinder kunde, internetprodukt, abonnement og delt hovedforbindelse i ét flow.
- Kundens adresse bruges til tolerant forslag af delt hovedforbindelse, inklusive mindre staveforskelle.
- Forenklet BMCnet-formular, hvor tekniske værdier arves og kun et valgfrit notefelt vises.
- Forbedret overblik over delte hovedforbindelser, BMCnet-kunder, kapacitet og økonomi.
## Leverandørfakturaer
- Hybrid fakturaudtræk kombinerer tekst, layout og AI med kontrolleret fallback.
- Forbedret udtræk af danske beløb, fakturafelter og varelinjer fra forskellige leverandørformater.
- Template Builder viser fundne data tydeligere og håndterer AI-fejl mere robust.
- Internetfakturaer kan behandles og følges i internetforbindelsesmodulet.
## E-mail og arbejdsgange
- Forenklet valg af mailtype og arbejdsflow med tydeligere handlinger.
- Leverandørfaktura-flow kan registrere internetfakturaer til efterfølgende behandling.
- Forbedret leverandør- og kundematch samt mere kompakt visning af avancerede oplysninger.
## Sager, kontakter og brugerflade
- Kontakter fra andre virksomheder markeres tydeligt på en sag.
- Kontakter kan oprettes direkte fra kontaktsøgningen, også når søgningen ikke giver resultat.
- Bugrapporter bruger en hurtigere og mere stabil lokal indsamling af relevant sideinformation.
- Rettelser til ALSO Cloud-søgning, fælles navigation og øvrige betjeningsdetaljer.
- Plan for AI-baseret kundestemning og sikker fakturabehandling er gemt under `fremtidige planer`.
## Verifikation
- 279 tests bestået og 1 sprunget over.
- Fire kendte, ikke-release-relaterede tests fejler fortsat: tre forældede invoice-error mocks og én databaseafhængig deaktiveringstest.
- Nye regressionstests for hybrid fakturaudtræk og hurtig oprettelse af internetforbindelser består.
- Python- og JavaScript-syntaks samt diff-kontrol bestået.

View File

@ -1,13 +0,0 @@
# BMC Hub v2.8.4
## e-conomic ordreeksport
- Ordremodulet arver nu de globale `ECONOMIC_READ_ONLY` og `ECONOMIC_DRY_RUN`, når de valgfrie `ORDRE_ECONOMIC_*`-indstillinger ikke er defineret.
- Eksplicitte ordreindstillinger har fortsat førsteprioritet.
- Sikker standard bevares: uden eksplicit konfiguration er eksport stadig read-only/dry-run.
## Verifikation
- Regressionstest for både global fallback og eksplicit ordreoverride består.
- Relevante faktura- og internettests består.
- Python-syntaks og diff-kontrol består.

View File

@ -1,13 +0,0 @@
# BMC Hub v2.8.5
## e-conomic ordreeksport
- Henter kundens standardopsætning fra e-conomic før oprettelse af en ordrekladde.
- Medsender obligatoriske betalingsbetingelser, modtageroplysninger og momszone.
- Bruger kundens valuta og adresseoplysninger fra e-conomic.
- Viser e-conomics konkrete valideringsfejl i stedet for kun HTTP-status 400.
## Verifikation
- 9 relevante regressionstests består.
- Python-syntaks og diff-kontrol består.

View File

@ -1,13 +0,0 @@
# BMC Hub v2.8.6
## e-conomic ordreeksport
- Bruger kundens gyldige standardlayout fra e-conomic i stedet for et ugyldigt lokalt standardnummer.
- Kontrollerer produktnumre mod e-conomic inden eksport.
- Ukendte lokale produktnumre eksporteres som fritekstlinjer uden ugyldig produktreference.
- Bevarer produktreferencen, når produktet findes i e-conomic.
## Verifikation
- Fokuserede regressionstests består.
- Python-syntaks og diff-kontrol består.

View File

@ -1,21 +0,0 @@
# BMC Hub v2.8.7
## Varer, priser og e-conomic
- Nyt varekatalog med importkontrol, låste e-conomic-koblinger og synkroniseringsstatus.
- Lokale prisregler, særskilte prisrettigheder og bevarelse af priser på gemte kladder.
- Kontrol af varer og priser før ordreeksport samt afstemning efter usikre eksportforsøg.
- Ændring af eksisterende kunder og varer i e-conomic kræver en begrundet anmodning og godkendelse fra en anden bruger med særskilt rettighed.
- Direkte ekstern skrivning er begrænset til oprettelse af kunder, varer og ordrekladder; godkendte kunde- og vareændringer har en særskilt adgangskontrol.
## Installation
- Kør migration `242_economic_product_integration.sql` og derefter `243_economic_four_eyes_changes.sql` før aktivering af den nye version.
- Migrationerne tilføjer tabeller, felter, indeks, constraints og rettigheder. Eksisterende varenumre kobles ikke automatisk.
- Tilknyt og kontrollér aftalen via **Indstillinger → Varer, priser og synkronisering**, før forbindelsen aktiveres. Se `docs/ECONOMIC_PRODUCT_CATALOG.md`.
## Verifikation
- 69 fokuserede tests består, inklusive seks lokale databasetests med rollback og uden eksterne e-conomic-skrivninger.
- Isoleret browsertest består for varekobling, decimalpriser, livscyklus, prisregler og eksportfanen uden JavaScript-fejl.
- Python- og JavaScript-syntakskontrol samt diff-kontrol består.

View File

@ -1,17 +0,0 @@
# BMC Hub v2.8.8
## Rettelse af adgang til e-conomic-varekataloget
- Tildeler de nye e-conomic-rettigheder til den eksisterende `Administrators`-gruppe.
- Retter fejlen `Missing required permission: economic.catalog.view` for almindelige administratorer.
- Andre grupper og brugere får ikke nye rettigheder automatisk.
## Ordreeksport
- Stopper eksporten før afsendelse, hvis et varenummer ikke findes i e-conomic.
- Viser de manglende varenumre og sender ikke længere prislinjer uden den produktreference, som e-conomic kræver.
## Installation
- Kør migration `244_economic_permissions_backfill.sql` efter migration 242 og 243.
- Migrationen er idempotent og ændrer kun gruppens koblinger til eksisterende rettigheder.

View File

@ -1,14 +0,0 @@
# BMC Hub v2.8.9
## Produktoprettelse ved ordreeksport
- Hvis en ordrelinjes vare ikke findes i e-conomic, åbnes et forhåndsvindue før eksport.
- Vinduet viser varenummer, navn, salgspris, EAN/stregkode, beskrivelse og varegruppe for hver vare, der skal oprettes.
- Brugeren vælger eller kontrollerer varegruppen og godkender samlet med **Opret varer og eksportér**.
- Før godkendelsen udføres kun opslag. Efter godkendelsen oprettes de viste varer og derefter ordrekladden.
- Vareoprettelse kræver fortsat rettigheden `economic.catalog.manage`; ordreeksport kræver `economic.documents.export`.
## Verifikation
- Regressionstest dækker den præcise e-conomic-payload med pris, EAN, beskrivelse og varegruppe.
- Python- og JavaScript-syntakskontrol samt diff-kontrol består.

View File

@ -1 +1 @@
2.8.16
2.6.1

View File

@ -1 +0,0 @@
"""Restricted administrative reporting modules."""

View File

@ -1,254 +0,0 @@
"""Portable, checksummed Project CT archive bundles. Never contacts vTiger."""
from __future__ import annotations
import hashlib
import json
import os
import socket
import tempfile
import zipfile
from datetime import datetime, timezone
from typing import Any, Iterable
from psycopg2.extras import RealDictCursor
from app.core.database import get_db_connection, release_db_connection
BUNDLE_SCHEMA_VERSION = 1
ENTRY_NAMES = ("versions.jsonl", "records.jsonl", "relations.jsonl", "checkpoints.jsonl", "files.jsonl")
def file_sha256(path: str) -> str:
digest = hashlib.sha256()
with open(path, "rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _json_line(value: Any) -> bytes:
return (json.dumps(value, ensure_ascii=False, sort_keys=True, default=str) + "\n").encode("utf-8")
def _write_cursor_entry(archive: zipfile.ZipFile, name: str, cursor, batch_size: int = 500) -> tuple[int, str]:
digest = hashlib.sha256()
count = 0
with archive.open(name, "w") as target:
while True:
rows = cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
raw = _json_line(dict(row))
target.write(raw)
digest.update(raw)
count += 1
return count, digest.hexdigest()
def create_archive_bundle(through_version_id: int, initiated_by: int | None = None) -> tuple[str, dict]:
fd, path = tempfile.mkstemp(prefix="project-ct-", suffix=".zip")
os.close(fd)
conn = get_db_connection()
manifest = {
"schema_version": BUNDLE_SCHEMA_VERSION,
"created_at": datetime.now(timezone.utc).isoformat(),
"source_instance": socket.gethostname(),
"through_version_id": through_version_id,
"entries": {},
"files": {},
"contains_files": True,
"document_policy": "metadata_and_original_bytes",
}
try:
with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive:
queries = {
"versions.jsonl": ("SELECT * FROM vtiger_archive_versions WHERE id<=%s ORDER BY id", (through_version_id,)),
"records.jsonl": ("SELECT * FROM vtiger_archive_records WHERE version_id<=%s ORDER BY module,vtiger_id,revision_no", (through_version_id,)),
"relations.jsonl": ("SELECT * FROM vtiger_archive_relations WHERE version_id<=%s ORDER BY id", (through_version_id,)),
"checkpoints.jsonl": (
"""SELECT module,MAX(source_modified_at) AS last_modified_at,MAX(vtiger_id) AS last_vtiger_id,
%s::bigint AS last_successful_version_id,NOW() AS updated_at
FROM vtiger_archive_records WHERE version_id<=%s GROUP BY module ORDER BY module""",
(through_version_id, through_version_id),
),
"files.jsonl": (
"""SELECT id,version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,
size_bytes,content_sha256,archived_at
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY id""",
(through_version_id,),
),
}
for index, (name, (sql, params)) in enumerate(queries.items()):
cursor = conn.cursor(name=f"project_ct_export_{index}", cursor_factory=RealDictCursor)
cursor.itersize = 500
cursor.execute(sql, params)
count, digest = _write_cursor_entry(archive, name, cursor)
cursor.close()
manifest["entries"][name] = {"count": count, "sha256": digest}
file_rows = conn.cursor(cursor_factory=RealDictCursor)
file_rows.execute(
"""SELECT DISTINCT ON (content_sha256) content_sha256,content,size_bytes
FROM vtiger_archive_files WHERE version_id<=%s ORDER BY content_sha256,id""",
(through_version_id,),
)
for row in file_rows:
content = bytes(row["content"])
digest = hashlib.sha256(content).hexdigest()
if digest != row["content_sha256"]:
raise ValueError(f"Lokal filchecksum er ugyldig: {row['content_sha256']}")
entry_name = f"files/{digest}"
archive.writestr(entry_name, content)
manifest["files"][digest] = {"entry": entry_name, "size": len(content), "sha256": digest}
file_rows.close()
archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2, default=str))
manifest["bundle_sha256"] = file_sha256(path)
return path, manifest
except Exception:
if os.path.exists(path):
os.unlink(path)
raise
finally:
release_db_connection(conn)
def _verify_entry(archive: zipfile.ZipFile, manifest: dict, name: str) -> None:
expected = ((manifest.get("entries") or {}).get(name) or {}).get("sha256")
if not expected:
raise ValueError(f"Manifest mangler checksum for {name}")
digest = hashlib.sha256()
with archive.open(name) as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
if digest.hexdigest() != expected:
raise ValueError(f"Checksumfejl i {name}")
def _rows(archive: zipfile.ZipFile, name: str) -> Iterable[dict]:
with archive.open(name) as source:
for line_number, raw in enumerate(source, 1):
if not raw.strip():
continue
try:
yield json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Ugyldig JSON i {name}, linje {line_number}") from exc
def import_archive_bundle(path: str, initiated_by: int | None = None) -> dict:
bundle_digest = file_sha256(path)
conn = get_db_connection()
counts = {"versions": 0, "records": 0, "records_skipped": 0, "relations": 0, "checkpoints": 0}
try:
with zipfile.ZipFile(path) as archive:
try:
manifest = json.loads(archive.read("manifest.json"))
except (KeyError, json.JSONDecodeError) as exc:
raise ValueError("Filen er ikke en gyldig Project CT-arkivpakke") from exc
if manifest.get("schema_version") != BUNDLE_SCHEMA_VERSION:
raise ValueError(f"Ikke-understøttet arkivformat: {manifest.get('schema_version')}")
if manifest.get("contains_files") is not True:
raise ValueError("Pakken mangler originale vTiger-dokumentfiler")
for name in ENTRY_NAMES:
_verify_entry(archive, manifest, name)
for digest, info in (manifest.get("files") or {}).items():
entry_name = info.get("entry")
if not entry_name or entry_name not in archive.namelist():
raise ValueError(f"Pakken mangler dokumentfil {digest}")
actual = hashlib.sha256(archive.read(entry_name)).hexdigest()
if actual != digest or actual != info.get("sha256"):
raise ValueError(f"Checksumfejl i dokumentfil {digest}")
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
version_map: dict[int, int] = {}
for row in _rows(archive, "versions.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_versions
(sync_kind,status,started_at,completed_at,source_cutoff,module_counts,warnings,
critical_errors,control_report,control_approved_at,raw_export_sha256)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s) RETURNING id""",
(row["sync_kind"], row["status"], row["started_at"], row.get("completed_at"), row["source_cutoff"],
json.dumps(row.get("module_counts") or {}), json.dumps(row.get("warnings") or []),
json.dumps(row.get("critical_errors") or []), json.dumps(row.get("control_report") or {}),
row.get("control_approved_at"), row.get("raw_export_sha256")),
)
version_map[int(row["id"])] = int(cursor.fetchone()["id"])
counts["versions"] += 1
for row in _rows(archive, "records.jsonl"):
cursor.execute(
"SELECT id FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s AND payload_sha256=%s",
(row["module"], row["vtiger_id"], row["payload_sha256"]),
)
if cursor.fetchone():
counts["records_skipped"] += 1
continue
cursor.execute(
"SELECT COALESCE(MAX(revision_no),0)+1 AS revision FROM vtiger_archive_records WHERE module=%s AND vtiger_id=%s",
(row["module"], row["vtiger_id"]),
)
revision = int(cursor.fetchone()["revision"])
cursor.execute(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s)""",
(version_map[int(row["version_id"])], row["module"], row["vtiger_id"], revision,
row.get("source_created_at"), row.get("source_modified_at"), row.get("is_deleted", False),
json.dumps(row["payload"], ensure_ascii=False), row["payload_sha256"], row.get("archived_at")),
)
counts["records"] += 1
counts["files"] = 0
counts["files_skipped"] = 0
for row in _rows(archive, "files.jsonl"):
cursor.execute(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s AND content_sha256=%s""",
(row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"], row["content_sha256"]),
)
if cursor.fetchone():
counts["files_skipped"] += 1
continue
info = manifest["files"][row["content_sha256"]]
content = archive.read(info["entry"])
cursor.execute(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,
content_sha256,content,archived_at)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(version_map[int(row["version_id"])], row.get("source_module") or "Documents", row["document_vtiger_id"], row["resource_vtiger_id"],
row["filename"], row.get("content_type"), row["size_bytes"], row["content_sha256"], content,
row.get("archived_at")),
)
counts["files"] += 1
for row in _rows(archive, "relations.jsonl"):
cursor.execute(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_map[int(row["version_id"])], row["source_module"], row["source_vtiger_id"],
row["field_name"], row["target_vtiger_id"], row.get("target_module")),
)
counts["relations"] += cursor.rowcount
for row in _rows(archive, "checkpoints.jsonl"):
mapped_version = version_map.get(int(row["last_successful_version_id"])) if row.get("last_successful_version_id") else None
cursor.execute(
"""INSERT INTO vtiger_archive_checkpoints
(module,last_modified_at,last_vtiger_id,last_successful_version_id,updated_at)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=GREATEST(vtiger_archive_checkpoints.last_modified_at,EXCLUDED.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(row["module"], row.get("last_modified_at"), row.get("last_vtiger_id"), mapped_version, row.get("updated_at")),
)
counts["checkpoints"] += 1
conn.commit()
return {"bundle_sha256": bundle_digest, "manifest": manifest, "counts": counts, "contacted_vtiger": False}
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)

View File

@ -1,60 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Project CT · Hub Impact{% endblock %}
{% block extra_css %}
<style>
.ct-shell{max-width:1500px;margin:auto}.ct-hero{border-radius:24px;padding:2rem;background:linear-gradient(120deg,#082f49,#0f4c75 55%,#2563eb);color:#fff;box-shadow:0 22px 55px rgba(8,47,73,.22)}
.ct-panel{background:var(--bs-body-bg);border:1px solid rgba(15,76,117,.14);border-radius:18px;box-shadow:0 10px 28px rgba(15,76,117,.06)}
.ct-kpi{border-radius:16px;padding:1.1rem;background:linear-gradient(145deg,rgba(15,76,117,.08),rgba(37,99,235,.03));height:100%}.ct-kpi strong{font-size:1.7rem}.ct-delta.positive{color:#16803c}.ct-delta.negative{color:#dc3545}
.ct-status{display:inline-flex;align-items:center;gap:.45rem;padding:.38rem .75rem;border-radius:999px;font-weight:700}.ct-status.ready{background:#dcfce7;color:#166534}.ct-status.blocked{background:#fee2e2;color:#991b1b}
.ct-table th{font-size:.75rem;text-transform:uppercase;letter-spacing:.04em;color:#64748b}.ct-empty{padding:3rem;text-align:center;color:#64748b}
</style>
{% endblock %}
{% block content %}
<main class="container-fluid px-4 py-4 ct-shell">
<section class="ct-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="text-uppercase small fw-bold opacity-75 mb-2">Fortrolig · kun superadmin</div><h1 class="h2 mb-2">Project CT · Hub Impact</h1><p class="mb-0 opacity-75">Dokumentér effekten af Hub mod et permanent vTiger-arkiv.</p></div>
<div class="text-end"><div id="readinessBadge" class="ct-status blocked">Indlæser arkivstatus…</div><div class="small opacity-75 mt-2" id="readinessReasons"></div></div>
</section>
<section class="ct-panel p-3 p-lg-4 mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"><div><h2 class="h5 mb-1">Ny reproducerbar sammenligning</h2><div class="small text-muted">Perioderne skal have præcis samme antal kalenderdage.</div></div><div class="d-flex flex-wrap gap-2"><button class="btn btn-outline-secondary" id="syncFull">Kør fuld sync</button><button class="btn btn-outline-primary" id="syncIncremental">Kør inkrementel sync</button><button class="btn btn-outline-danger" id="syncFinal">Kør final sync</button><button class="btn btn-outline-success" id="approveFinal">Godkend final kontrol</button><a class="btn btn-dark" id="bundleExport">Download pakke til prod</a><button class="btn btn-outline-dark" id="bundleImportBtn">Importér arkivpakke</button><input type="file" id="bundleImportFile" accept=".zip" hidden></div></div>
<div class="row g-3 align-items-end">
<div class="col-lg-3"><label class="form-label fw-semibold">Arkivversion</label><select id="archiveVersion" class="form-select"></select></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger fra</label><input id="vtFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">vTiger til</label><input id="vtTo" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub fra</label><input id="hubFrom" type="date" class="form-control"></div>
<div class="col-sm-6 col-lg-2"><label class="form-label fw-semibold">Hub til</label><input id="hubTo" type="date" class="form-control"></div>
<div class="col-lg-1 d-grid gap-2"><button id="runReport" class="btn btn-primary">Kør</button><a id="rawExport" class="btn btn-sm btn-outline-secondary">Rådata</a></div>
</div><div id="feedback" class="small mt-3 text-muted"></div>
</section>
<section id="resultArea" class="d-none">
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h4 mb-0">Resultat</h2><a id="excelExport" class="btn btn-success">Eksportér Excel</a></div>
<div class="row g-3 mb-4" id="kpis"></div>
<div class="ct-panel p-4 mb-4"><div class="d-flex align-items-center gap-2 mb-3"><i class="bi bi-speedometer2 text-primary fs-4"></i><h3 class="h5 mb-0">Hvad Hub effektiviserer og optimerer</h3></div><div id="impactEvidence" class="row g-3"></div><div class="small text-muted mt-3">Positive og negative ændringer vises ensartet. Tallene sammenligner lige lange perioder og er dokumentation—ikke en skjult vægtet score.</div></div>
<div class="ct-panel p-3 p-lg-4 mb-4"><h3 class="h5 mb-3">Pr. medarbejder</h3><div class="table-responsive"><table class="table align-middle ct-table"><thead><tr><th>Kilde</th><th>Medarbejder</th><th>E-mail</th><th>Timer</th><th>Registreringer</th><th>Sager</th><th>Ordrer</th><th>Timer/dag</th></tr></thead><tbody id="employeeRows"></tbody></table></div></div>
<div class="row g-4"><div class="col-lg-7"><div class="ct-panel p-4 h-100"><h3 class="h5">Afvigelser udeladt fra KPI</h3><div id="anomalies"></div></div></div><div class="col-lg-5"><div class="ct-panel p-4 h-100"><h3 class="h5">Datakvalitet</h3><div id="quality"></div></div></div></div>
</section>
<section class="ct-panel p-4 mt-4"><h2 class="h5 mb-3">Gemte rapporter</h2><div id="savedReports" class="list-group list-group-flush"></div></section>
<section class="ct-panel p-4 mt-4"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2 class="h5 mb-1">Flytning mellem test og produktion</h2><div class="small text-muted">Checksummede arkivpakker. Import læser kun ZIP-filen og kontakter aldrig vTiger.</div><div class="small fw-semibold mt-1" id="fileArchiveStatus"></div></div><button class="btn btn-sm btn-outline-secondary" id="refreshTransfers">Opdatér</button></div><div id="transferRows" class="table-responsive ct-empty">Ingen overførsler endnu.</div></section>
</main>
{% endblock %}
{% block extra_js %}
<script>
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const iso=d=>d.toISOString().slice(0,10); let optionsData={};
function defaults(){const now=new Date(),hTo=new Date(now),hFrom=new Date(now),vTo=new Date(now),vFrom=new Date(now);hFrom.setDate(hFrom.getDate()-29);vTo.setDate(hFrom.getDate()-1);vFrom.setDate(vTo.getDate()-29);hubTo.value=iso(hTo);hubFrom.value=iso(hFrom);vtTo.value=iso(vTo);vtFrom.value=iso(vFrom)}
async function api(url,opt={}){const r=await fetch(url,{credentials:'include',...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});if(!r.ok){let e={};try{e=await r.json()}catch{}throw new Error(e.detail||`HTTP ${r.status}`)}return r.json()}
async function loadOptions(){optionsData=await api('/api/v1/admin/hub-impact/options');archiveVersion.innerHTML=optionsData.versions.map(v=>`<option value="${v.id}">Version ${v.id} · ${esc(v.sync_kind)} · ${new Date(v.completed_at).toLocaleString('da-DK')}${v.control_approved_at?' · godkendt':''}</option>`).join('');setRawLink();const ready=optionsData.readiness.ready;readinessBadge.className=`ct-status ${ready?'ready':'blocked'}`;readinessBadge.textContent=ready?'Klar til opsigelse':'Ikke klar til opsigelse';readinessReasons.textContent=(optionsData.readiness.reasons||[]).join(' · ');savedReports.innerHTML=optionsData.reports.length?optionsData.reports.map(r=>`<button class="list-group-item list-group-item-action d-flex justify-content-between" onclick="openReport(${r.id})"><span>Rapport #${r.id} · arkiv v${r.archive_version_id}</span><small>${esc(r.vtiger_from)}${esc(r.vtiger_to)} / ${esc(r.hub_from)}${esc(r.hub_to)}</small></button>`).join(''):'<div class="text-muted">Ingen gemte rapporter endnu.</div>'}
async function loadTransfers(){const data=await api('/api/v1/admin/vtiger-archive/status');const files=data.file_archive||[];fileArchiveStatus.textContent=files.length?`Originale filer i arkivet: ${files.map(x=>`${x.source_module} ${x.files} (${(Number(x.size_bytes)/1048576).toFixed(1)} MB)`).join(' · ')}`:'Ingen originale filer arkiveret endnu.';const rows=data.transfers||[];transferRows.className='table-responsive';transferRows.innerHTML=rows.length?`<table class="table align-middle ct-table mb-0"><thead><tr><th>Tid</th><th>Retning</th><th>Status</th><th>Til version</th><th>Checksum</th><th>Indhold</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${new Date(x.completed_at||x.started_at).toLocaleString('da-DK')}</td><td>${x.direction==='export'?'Test → prod':'Pakke → denne server'}</td><td><span class="badge ${x.status==='completed'?'bg-success':x.status==='failed'?'bg-danger':'bg-warning text-dark'}">${esc(x.status)}</span></td><td>${x.through_version_id||'—'}</td><td><code>${esc((x.bundle_sha256||'').slice(0,12))}${x.bundle_sha256?'…':'—'}</code></td><td>${Object.entries(x.counts||{}).map(([k,v])=>`${esc(k)}: ${v}`).join(' · ')||esc(x.error_message||'—')}</td></tr>`).join('')}</tbody></table>`:'<div class="ct-empty">Ingen overførsler endnu.</div>'}
function setRawLink(){rawExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/versions/${archiveVersion.value}/raw-export`:'#';bundleExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/bundle.zip?through_version_id=${archiveVersion.value}`:'#'}archiveVersion.onchange=setRawLink;
function render(id,r){resultArea.classList.remove('d-none');excelExport.href=`/api/v1/admin/hub-impact/reports/${id}/export.xlsx`;const labels={hours:'Timer',time_entries:'Tidsregistreringer',cases:'Sager',orders:'Ordrer'};kpis.innerHTML=Object.entries(labels).map(([k,l])=>{const d=r.comparison[k],pct=r.improvement?.percent?.[k],cls=d>0?'positive':d<0?'negative':'';return `<div class="col-sm-6 col-xl-3"><div class="ct-kpi"><div class="text-muted small fw-bold">${l}</div><div class="d-flex justify-content-between mt-2"><span>vTiger <strong>${r.vtiger.totals[k]}</strong></span><span>Hub <strong>${r.hub.totals[k]}</strong></span></div><div class="ct-delta ${cls} mt-2 fw-bold">Forskel ${d>0?'+':''}${d}${pct==null?'':` · ${pct>0?'+':''}${pct}%`}</div></div></div>`}).join('');impactEvidence.innerHTML=(r.improvement?.evidence||[]).map((text,i)=>`<div class="col-md-6"><div class="ct-kpi d-flex gap-3 align-items-center"><span class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style="width:34px;height:34px;flex:0 0 34px">${i+1}</span><strong class="fs-6">${esc(text)}</strong></div></div>`).join('')||'<div class="text-muted">Ikke nok vTiger-baseline til at beregne procentvis effekt.</div>';employeeRows.innerHTML=['vtiger','hub'].flatMap(src=>r[src].employees.map(x=>`<tr><td><span class="badge ${src==='hub'?'bg-primary':'bg-secondary'}">${src}</span></td><td>${esc(x.name)}</td><td>${esc(x.email||'—')}</td><td>${x.hours}</td><td>${x.time_entries}</td><td>${x.cases}</td><td>${x.orders}</td><td>${x.productivity.hours_per_workday}</td></tr>`)).join('');const aa=[...r.vtiger.anomalies,...r.hub.anomalies];anomalies.innerHTML=aa.length?aa.map(a=>`<div class="border-bottom py-2"><strong>${esc(a.source)}</strong> · ${esc(a.type)} · #${esc(a.id)} · ${a.hours} timer</div>`).join(''):'<div class="text-success">Ingen afvigelser i perioderne.</div>';quality.innerHTML=['vtiger','hub'].map(src=>`<div class="mb-3"><strong>${src}</strong><div>Manglende datoer: ${r[src].data_quality.missing_dates}</div><div>Ikke matchede medarbejdere: ${r[src].data_quality.unmatched_employees.length}</div></div>`).join('')}
async function openReport(id){feedback.textContent='Indlæser rapport…';try{const row=await api(`/api/v1/admin/hub-impact/reports/${id}`);render(id,row.result);feedback.textContent=`Rapport #${id} indlæst.`}catch(e){feedback.textContent=e.message}}
runReport.onclick=async()=>{runReport.disabled=true;feedback.textContent='Beregner og gemmer rapport…';try{const x=await api('/api/v1/admin/hub-impact/reports',{method:'POST',body:JSON.stringify({archive_version_id:Number(archiveVersion.value),vtiger_from:vtFrom.value,vtiger_to:vtTo.value,hub_from:hubFrom.value,hub_to:hubTo.value})});render(x.id,x.result);feedback.textContent=`Rapport #${x.id} er gemt reproducerbart.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{runReport.disabled=false}};
async function sync(kind,button){if(kind==='final'&&!confirm('Final sync bruges som grundlag for opsigelsesgodkendelsen. Fortsæt?'))return;button.disabled=true;feedback.textContent=`Kører ${kind} sync…`;try{const r=await api(`/api/v1/admin/vtiger-archive/sync/${kind}`,{method:'POST'});feedback.textContent=`Arkivversion ${r.version_id}: ${r.status}`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{button.disabled=false}}
approveFinal.onclick=async()=>{const final=optionsData.versions.find(v=>v.sync_kind==='final');if(!final){feedback.textContent='Der findes endnu ingen fuldført final sync.';return}approveFinal.disabled=true;try{await api(`/api/v1/admin/vtiger-archive/versions/${final.id}/approve-control`,{method:'POST'});feedback.textContent=`Kontrolrapport for final version ${final.id} er godkendt.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{approveFinal.disabled=false}};
bundleImportBtn.onclick=()=>bundleImportFile.click();bundleImportFile.onchange=async()=>{const file=bundleImportFile.files[0];if(!file)return;if(!confirm(`Importér ${file.name}? Importen bruger kun filen og kontakter aldrig vTiger.`))return;bundleImportBtn.disabled=true;feedback.textContent='Validerer checksums og importerer hele arkivpakken…';try{const form=new FormData();form.append('file',file);const response=await fetch('/api/v1/admin/vtiger-archive/import-bundle',{method:'POST',body:form,credentials:'include'});const data=await response.json();if(!response.ok)throw new Error(data.detail||`HTTP ${response.status}`);feedback.textContent=data.already_imported?'Denne præcise pakke var allerede importeret; intet blev duplikeret.':`Import færdig: ${data.counts.records} revisioner, ${data.counts.relations} relationer. vTiger kontaktet: nej.`;await Promise.all([loadOptions(),loadTransfers()])}catch(e){feedback.textContent=e.message}finally{bundleImportBtn.disabled=false;bundleImportFile.value=''}};
refreshTransfers.onclick=()=>loadTransfers().catch(e=>feedback.textContent=e.message);syncFull.onclick=()=>sync('full',syncFull);syncIncremental.onclick=()=>sync('incremental',syncIncremental);syncFinal.onclick=()=>sync('final',syncFinal);defaults();Promise.all([loadOptions(),loadTransfers()]).catch(e=>feedback.textContent=e.message);
</script>
{% endblock %}

View File

@ -1,235 +0,0 @@
"""Reproducible vTiger-versus-Hub impact metrics for Project CT."""
from __future__ import annotations
import hashlib
import json
from collections import defaultdict
from datetime import date, datetime, timedelta
from decimal import Decimal, InvalidOperation
from typing import Any
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService
DATE_FIELDS = ("worked_date", "date", "createdtime", "created_at", "modifiedtime")
EMAIL_FIELDS = ("email1", "email", "email_address", "user_email")
USER_REF_FIELDS = ("assigned_user_id", "creator", "created_by", "userid", "user_id")
def _date(value: Any) -> date | None:
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
try:
return datetime.fromisoformat(str(value or "").strip().replace("Z", "+00:00")).date()
except (ValueError, TypeError):
return None
def _first(payload: dict, fields: tuple[str, ...]):
return next((payload.get(field) for field in fields if payload.get(field) not in (None, "")), None)
def _hours(payload: dict) -> Decimal:
return VTigerService._extract_timelog_hours(payload)
def _archive_snapshot(version_id: int) -> list[dict[str, Any]]:
return execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
def _hub_users() -> tuple[dict[int, dict], dict[str, dict]]:
rows = execute_query("SELECT user_id,email,full_name,username FROM users") or []
by_id = {int(row["user_id"]): dict(row) for row in rows}
by_email = {str(row.get("email") or "").strip().lower(): dict(row) for row in rows if row.get("email")}
return by_id, by_email
def _employee_bucket(store: dict, key: str, name: str, email: str | None):
if key not in store:
store[key] = {"key": key, "name": name, "email": email, "hours": Decimal("0"),
"time_entries": 0, "cases": 0, "orders": 0}
return store[key]
def _finalize(metrics: dict[str, Any], start: date, end: date) -> dict[str, Any]:
days = (end - start).days + 1
workdays = max(sum(1 for offset in range(days) if (start + timedelta(days=offset)).weekday() < 5), 1)
employees = []
for item in metrics["employees"].values():
normalized = dict(item)
normalized["hours"] = round(float(item["hours"]), 2)
normalized["productivity"] = {
"hours_per_workday": round(normalized["hours"] / workdays, 2),
"registrations_per_workday": round(normalized["time_entries"] / workdays, 2),
"cases_per_workday": round(normalized["cases"] / workdays, 2),
"orders_per_workday": round(normalized["orders"] / workdays, 2),
"deliveries_per_workday": round((normalized["time_entries"] + normalized["cases"] + normalized["orders"]) / workdays, 2),
}
employees.append(normalized)
employees.sort(key=lambda row: (row.get("name") or "").lower())
totals = {key: sum(float(row[key]) for row in employees) for key in ("hours", "time_entries", "cases", "orders")}
totals["hours"] = round(totals["hours"], 2)
totals["time_entries"] = int(totals["time_entries"])
totals["cases"] = int(totals["cases"])
totals["orders"] = int(totals["orders"])
totals["productivity"] = {
"hours_per_workday": round(totals["hours"] / workdays, 2),
"registrations_per_workday": round(totals["time_entries"] / workdays, 2),
"cases_per_workday": round(totals["cases"] / workdays, 2),
"orders_per_workday": round(totals["orders"] / workdays, 2),
"deliveries_per_workday": round((totals["time_entries"] + totals["cases"] + totals["orders"]) / workdays, 2),
"hours_per_case_or_order": round(totals["hours"] / max(totals["cases"] + totals["orders"], 1), 2),
}
return {"totals": totals, "employees": employees, "anomalies": metrics["anomalies"],
"data_quality": metrics["data_quality"], "workdays": workdays}
def _vtiger_metrics(records: list[dict], start: date, end: date, hub_email_users: dict[str, dict]) -> dict:
active = [row for row in records if not row.get("is_deleted")]
vt_users = {}
for row in active:
if row["module"] != "Users":
continue
payload = row["payload"] or {}
email = str(_first(payload, EMAIL_FIELDS) or "").strip().lower()
vt_users[row["vtiger_id"]] = {
"email": email or None,
"name": str(payload.get("first_name") or "") + " " + str(payload.get("last_name") or payload.get("user_name") or row["vtiger_id"]),
}
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
module_metric = {"Timelog": "time_entries", "Cases": "cases", "SalesOrder": "orders"}
for row in active:
metric = module_metric.get(row["module"])
if not metric:
continue
payload = row["payload"] or {}
occurred = _date(_first(payload, DATE_FIELDS))
if not occurred:
result["data_quality"]["missing_dates"] += 1
continue
if occurred < start or occurred > end:
continue
user_ref = str(_first(payload, USER_REF_FIELDS) or "")
vt_user = vt_users.get(user_ref, {"email": None, "name": user_ref or "Ukendt vTiger-bruger"})
email = vt_user.get("email")
matched = hub_email_users.get(email) if email else None
key = f"hub:{matched['user_id']}" if matched else f"vtiger:{user_ref or 'unknown'}"
name = str(matched.get("full_name") or matched.get("username")) if matched else str(vt_user.get("name") or key).strip()
bucket = _employee_bucket(result["employees"], key, name, email)
if not matched and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
if metric == "time_entries":
hours = _hours(payload)
if hours > 16:
result["anomalies"].append({"source": "vtiger", "type": "over_16_hours", "id": row["vtiger_id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket[metric] += 1
return result
def _hub_metrics(start: date, end: date, hub_users: dict[int, dict]) -> dict:
result = {"employees": {}, "anomalies": [], "data_quality": {"unmatched_employees": [], "missing_dates": 0}}
times = execute_query(
"""SELECT id,medarbejder_id,worked_date,start_tid,created_at,aktiv_timer,
COALESCE(faktisk_tid_min::numeric/60,approved_hours,original_hours,0) AS hours
FROM tmodule_times WHERE vtiger_id IS NULL
AND COALESCE(worked_date,start_tid::date,created_at::date) BETWEEN %s AND %s""", (start, end),
) or []
for row in times:
user = hub_users.get(int(row["medarbejder_id"])) if row.get("medarbejder_id") else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
hours = Decimal(str(row.get("hours") or 0))
if row.get("aktiv_timer") or hours > 16:
result["anomalies"].append({"source": "hub", "type": "active_timer" if row.get("aktiv_timer") else "over_16_hours",
"id": row["id"], "hours": float(hours)})
continue
bucket["hours"] += hours
bucket["time_entries"] += 1
cases = execute_query(
"""SELECT id,created_by_user_id,created_at FROM sag_sager
WHERE deleted_at IS NULL AND created_at::date BETWEEN %s AND %s""", (start, end),
) or []
orders = execute_query(
"SELECT id,created_by,order_date FROM tmodule_orders WHERE order_date BETWEEN %s AND %s", (start, end),
) or []
for rows, metric, user_field in ((cases, "cases", "created_by_user_id"), (orders, "orders", "created_by")):
for row in rows:
user = hub_users.get(int(row[user_field])) if row.get(user_field) else None
key = f"hub:{user['user_id']}" if user else "hub:unknown"
bucket = _employee_bucket(result["employees"], key,
str((user or {}).get("full_name") or (user or {}).get("username") or "Ukendt Hub-bruger"),
(user or {}).get("email"))
if not user and key not in result["data_quality"]["unmatched_employees"]:
result["data_quality"]["unmatched_employees"].append(key)
bucket[metric] += 1
return result
def build_impact_report(version_id: int, vtiger_from: date, vtiger_to: date, hub_from: date, hub_to: date) -> dict:
vt_days = (vtiger_to - vtiger_from).days + 1
hub_days = (hub_to - hub_from).days + 1
if vt_days <= 0 or hub_days <= 0 or vt_days != hub_days:
raise ValueError("vTiger- og Hub-perioderne skal være gyldige og lige lange")
version = execute_query_single(
"SELECT id,status,source_cutoff FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version or version.get("status") != "completed":
raise ValueError("Vælg en fuldført arkivversion")
by_id, by_email = _hub_users()
vtiger = _finalize(_vtiger_metrics(_archive_snapshot(version_id), vtiger_from, vtiger_to, by_email), vtiger_from, vtiger_to)
hub = _finalize(_hub_metrics(hub_from, hub_to, by_id), hub_from, hub_to)
keys = ("hours", "time_entries", "cases", "orders")
delta = {key: round(float(hub["totals"][key]) - float(vtiger["totals"][key]), 2) for key in keys}
percent = {
key: (round(delta[key] / float(vtiger["totals"][key]) * 100, 1) if float(vtiger["totals"][key]) else None)
for key in keys
}
productivity_keys = ("hours_per_workday", "registrations_per_workday", "cases_per_workday",
"orders_per_workday", "deliveries_per_workday", "hours_per_case_or_order")
productivity_delta = {}
for key in productivity_keys:
old, new = float(vtiger["totals"]["productivity"][key]), float(hub["totals"]["productivity"][key])
productivity_delta[key] = {"delta": round(new - old, 2), "percent": round((new - old) / old * 100, 1) if old else None}
evidence = []
for key, label in (("deliveries_per_workday", "samlet registreret output pr. arbejdsdag"),
("cases_per_workday", "sager pr. arbejdsdag"),
("orders_per_workday", "ordrer pr. arbejdsdag"),
("registrations_per_workday", "tidsregistreringer pr. arbejdsdag")):
change = productivity_delta[key]
if change["percent"] is not None:
direction = "mere" if change["percent"] >= 0 else "mindre"
evidence.append(f"Hub registrerer {abs(change['percent']):.1f}% {direction} {label}.")
return {
"archive_version": {"id": version_id, "source_cutoff": version.get("source_cutoff")},
"periods": {"vtiger": {"from": vtiger_from, "to": vtiger_to, "days": vt_days},
"hub": {"from": hub_from, "to": hub_to, "days": hub_days}},
"vtiger": vtiger, "hub": hub,
"comparison": delta,
"improvement": {"percent": percent, "productivity": productivity_delta, "evidence": evidence},
}
def save_impact_report(result: dict, generated_by: int) -> int:
canonical = json.dumps(result, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
periods = result["periods"]
return int(execute_insert(
"""INSERT INTO hub_impact_reports
(archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result,result_sha256,generated_by)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s,%s) RETURNING id""",
(result["archive_version"]["id"], periods["vtiger"]["from"], periods["vtiger"]["to"],
periods["hub"]["from"], periods["hub"]["to"], canonical,
hashlib.sha256(canonical.encode()).hexdigest(), generated_by),
))

View File

@ -1,344 +0,0 @@
"""Superadmin-only Project CT archive endpoints."""
from __future__ import annotations
import hashlib
import io
import json
import os
import tempfile
from datetime import date
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status
from fastapi.responses import FileResponse, Response
from starlette.background import BackgroundTask
from pydantic import BaseModel
from app.admin.vtiger_archive import ARCHIVE_MODULES, run_archive_sync, termination_readiness
from app.admin.hub_impact import build_impact_report, save_impact_report
from app.admin.archive_bundle import create_archive_bundle, file_sha256, import_archive_bundle
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single
router = APIRouter()
class ImpactReportRequest(BaseModel):
archive_version_id: int
vtiger_from: date
vtiger_to: date
hub_from: date
hub_to: date
def require_hidden_superadmin(current_user: dict = Depends(get_current_user)) -> dict:
# This feature must not disclose its existence to ordinary users.
if not current_user.get("is_superadmin"):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found")
return current_user
@router.get("/admin/vtiger-archive/status")
async def archive_status(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
critical_errors,control_report,control_approved_at,raw_export_sha256
FROM vtiger_archive_versions ORDER BY id DESC LIMIT 25"""
) or []
checkpoints = execute_query(
"SELECT * FROM vtiger_archive_checkpoints ORDER BY module"
) or []
transfers = execute_query(
"""SELECT id,direction,bundle_sha256,through_version_id,source_instance,status,counts,
error_message,started_at,completed_at
FROM vtiger_archive_transfers ORDER BY id DESC LIMIT 25"""
) or []
file_archive = execute_query(
"""SELECT source_module,COUNT(*)::integer AS files,SUM(size_bytes)::bigint AS size_bytes
FROM vtiger_archive_files GROUP BY source_module ORDER BY source_module"""
) or []
return {"versions": versions, "checkpoints": checkpoints, "transfers": transfers,
"file_archive": file_archive, "readiness": termination_readiness()}
@router.post("/admin/vtiger-archive/sync/{sync_kind}")
async def archive_sync(sync_kind: str, current_user: dict = Depends(require_hidden_superadmin)):
return await run_archive_sync(sync_kind, int(current_user["id"]))
@router.post("/admin/vtiger-archive/versions/{version_id}/approve-control")
async def approve_control(version_id: int, current_user: dict = Depends(require_hidden_superadmin)):
version = execute_query_single(
"SELECT * FROM vtiger_archive_versions WHERE id=%s", (version_id,),
)
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if version.get("status") != "completed" or version.get("critical_errors"):
raise HTTPException(status_code=409, detail="En fejlet eller ukomplet kontrolrapport kan ikke godkendes")
counts = version.get("module_counts") or {}
missing = [module for module in ARCHIVE_MODULES if module not in counts]
if missing:
raise HTTPException(status_code=409, detail=f"Kontrolrapport mangler moduler: {', '.join(missing)}")
execute_query(
"""UPDATE vtiger_archive_versions SET control_approved_at=NOW(),control_approved_by=%s
WHERE id=%s""", (current_user["id"], version_id), fetch=False,
)
return {"approved": True, "version_id": version_id, "readiness": termination_readiness()}
@router.get("/admin/vtiger-archive/versions/{version_id}/raw-export")
async def raw_export(
version_id: int,
module: str | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
version = execute_query_single("SELECT id FROM vtiger_archive_versions WHERE id=%s", (version_id,))
if not version:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
params: list[object] = [version_id]
module_filter = ""
if module:
module_filter = "AND module=%s"
params.append(module)
rows = execute_query(
f"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,revision_no,
source_created_at,source_modified_at,is_deleted,payload,payload_sha256
FROM vtiger_archive_records WHERE version_id <= %s {module_filter}
ORDER BY module,vtiger_id,revision_no DESC""", tuple(params),
) or []
relations = execute_query(
"""SELECT source_module,source_vtiger_id,field_name,target_vtiger_id,target_module
FROM vtiger_archive_relations WHERE version_id <= %s
ORDER BY source_module,source_vtiger_id,field_name,target_vtiger_id""", (version_id,),
) or []
version_meta = execute_query_single(
"""SELECT id,sync_kind,status,started_at,completed_at,source_cutoff,module_counts,
warnings,critical_errors,control_report,control_approved_at
FROM vtiger_archive_versions WHERE id=%s""", (version_id,),
) or {}
export_lines = [{"record_type": "archive_version", "data": dict(version_meta)}]
export_lines.extend({"record_type": "entity", "data": dict(row)} for row in rows)
export_lines.extend({"record_type": "relation", "data": dict(row)} for row in relations)
body = "\n".join(json.dumps(item, ensure_ascii=False, default=str, sort_keys=True) for item in export_lines) + "\n"
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()
if not module:
execute_query(
"UPDATE vtiger_archive_versions SET raw_export_sha256=%s WHERE id=%s",
(digest, version_id), fetch=False,
)
filename = f"project-ct-vtiger-v{version_id}{'-' + module if module else ''}.jsonl"
return Response(
content=body,
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="{filename}"', "X-Content-SHA256": digest},
)
@router.get("/admin/vtiger-archive/readiness")
async def readiness(current_user: dict = Depends(require_hidden_superadmin)):
return termination_readiness()
@router.get("/admin/vtiger-archive/bundle.zip")
async def export_archive_bundle(
through_version_id: int | None = Query(None),
current_user: dict = Depends(require_hidden_superadmin),
):
if through_version_id is None:
latest = execute_query_single(
"SELECT id FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC LIMIT 1"
)
if not latest:
raise HTTPException(status_code=409, detail="Der findes ingen fuldført arkivversion")
through_version_id = int(latest["id"])
selected = execute_query_single(
"SELECT id,status FROM vtiger_archive_versions WHERE id=%s", (through_version_id,),
)
if not selected:
raise HTTPException(status_code=404, detail="Arkivversionen findes ikke")
if selected.get("status") != "completed":
raise HTTPException(status_code=409, detail="Kun en fuldført arkivversion kan eksporteres til produktion")
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,through_version_id,status,initiated_by)
VALUES ('export',%s,'running',%s) RETURNING id""", (through_version_id, current_user["id"]),
)["id"]
try:
path, manifest = create_archive_bundle(through_version_id, int(current_user["id"]))
counts = {name: info["count"] for name, info in manifest["entries"].items()}
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),
bundle_sha256=%s,source_instance=%s,counts=%s::jsonb WHERE id=%s""",
(manifest["bundle_sha256"], manifest["source_instance"], json.dumps(counts), transfer_id), fetch=False,
)
return FileResponse(
path, media_type="application/zip",
filename=f"project-ct-vtiger-archive-v{through_version_id}.zip",
background=BackgroundTask(lambda: os.path.exists(path) and os.unlink(path)),
headers={"X-Archive-SHA256": manifest["bundle_sha256"], "X-vTiger-Contacted": "false"},
)
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke oprettes: {exc}") from exc
@router.post("/admin/vtiger-archive/import-bundle")
async def import_bundle(
file: UploadFile = File(...),
current_user: dict = Depends(require_hidden_superadmin),
):
if not str(file.filename or "").lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Vælg en Project CT .zip-arkivpakke")
fd, path = tempfile.mkstemp(prefix="project-ct-upload-", suffix=".zip")
os.close(fd)
transfer_id = execute_query_single(
"""INSERT INTO vtiger_archive_transfers(direction,status,initiated_by)
VALUES ('import','running',%s) RETURNING id""", (current_user["id"],),
)["id"]
try:
size = 0
with open(path, "wb") as target:
while chunk := await file.read(1024 * 1024):
size += len(chunk)
if size > 20 * 1024 * 1024 * 1024:
raise ValueError("Arkivpakken må højst fylde 20 GB")
target.write(chunk)
uploaded_sha256 = file_sha256(path)
previous = execute_query_single(
"""SELECT id,through_version_id,counts FROM vtiger_archive_transfers
WHERE direction='import' AND status='completed' AND bundle_sha256=%s
ORDER BY id DESC LIMIT 1""", (uploaded_sha256,),
)
if previous:
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(uploaded_sha256, previous.get("through_version_id"), json.dumps(previous.get("counts") or {}), transfer_id),
fetch=False,
)
return {
"bundle_sha256": uploaded_sha256,
"counts": previous.get("counts") or {},
"contacted_vtiger": False,
"already_imported": True,
"original_transfer_id": previous["id"],
"transfer_id": transfer_id,
}
result = import_archive_bundle(path, int(current_user["id"]))
execute_query(
"""UPDATE vtiger_archive_transfers SET status='completed',completed_at=NOW(),bundle_sha256=%s,
source_instance=%s,through_version_id=%s,counts=%s::jsonb WHERE id=%s""",
(result["bundle_sha256"], result["manifest"].get("source_instance"),
result["manifest"].get("through_version_id"), json.dumps(result["counts"]), transfer_id), fetch=False,
)
return {**result, "transfer_id": transfer_id}
except ValueError as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_transfers SET status='failed',completed_at=NOW(),error_message=%s WHERE id=%s",
(str(exc), transfer_id), fetch=False,
)
raise HTTPException(status_code=500, detail=f"Arkivpakken kunne ikke importeres: {exc}") from exc
finally:
if os.path.exists(path):
os.unlink(path)
@router.get("/admin/hub-impact/options")
async def impact_options(current_user: dict = Depends(require_hidden_superadmin)):
versions = execute_query(
"""SELECT id,sync_kind,completed_at,source_cutoff,module_counts,control_report,control_approved_at
FROM vtiger_archive_versions WHERE status='completed' ORDER BY id DESC"""
) or []
reports = execute_query(
"""SELECT id,archive_version_id,vtiger_from,vtiger_to,hub_from,hub_to,result_sha256,generated_at
FROM hub_impact_reports ORDER BY id DESC LIMIT 30"""
) or []
return {"versions": versions, "reports": reports, "readiness": termination_readiness()}
@router.post("/admin/hub-impact/reports")
async def create_impact_report(payload: ImpactReportRequest, current_user: dict = Depends(require_hidden_superadmin)):
try:
result = build_impact_report(payload.archive_version_id, payload.vtiger_from, payload.vtiger_to,
payload.hub_from, payload.hub_to)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
report_id = save_impact_report(result, int(current_user["id"]))
return {"id": report_id, "result": result}
@router.get("/admin/hub-impact/reports/{report_id}")
async def get_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT * FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return row
def _impact_workbook(report: dict) -> bytes:
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill
wb = Workbook()
overview = wb.active
overview.title = "Overblik"
overview.append(["Project CT · Hub Impact", "vTiger", "Hub", "Forskel", "% ændring"])
for cell in overview[1]:
cell.font = Font(bold=True, color="FFFFFF")
cell.fill = PatternFill("solid", fgColor="0F4C75")
labels = {"hours": "Timer", "time_entries": "Tidsregistreringer", "cases": "Sager", "orders": "Ordrer"}
for key, label in labels.items():
overview.append([label, report["vtiger"]["totals"][key], report["hub"]["totals"][key], report["comparison"][key], (report.get("improvement") or {}).get("percent", {}).get(key)])
overview.append([])
overview.append(["Arkivversion", report["archive_version"]["id"]])
overview.append(["vTiger-periode", f"{report['periods']['vtiger']['from']} {report['periods']['vtiger']['to']}"])
overview.append(["Hub-periode", f"{report['periods']['hub']['from']} {report['periods']['hub']['to']}"])
overview.freeze_panes = "A2"
overview.column_dimensions["A"].width = 28
for col in "BCDE": overview.column_dimensions[col].width = 18
evidence = wb.create_sheet("Effekt")
evidence.append(["Dokumenteret ændring"])
for line in (report.get("improvement") or {}).get("evidence", []):
evidence.append([line])
evidence.column_dimensions["A"].width = 90
employees = wb.create_sheet("Pr medarbejder")
employees.append(["Kilde", "Medarbejder", "E-mail", "Timer", "Registreringer", "Sager", "Ordrer", "Timer/arbejdsdag"])
for source in ("vtiger", "hub"):
for row in report[source]["employees"]:
employees.append([source, row["name"], row.get("email"), row["hours"], row["time_entries"], row["cases"], row["orders"], row["productivity"]["hours_per_workday"]])
for cell in employees[1]: cell.font = Font(bold=True)
employees.freeze_panes = "A2"
employees.auto_filter.ref = employees.dimensions
anomalies = wb.create_sheet("Afvigelser")
anomalies.append(["Kilde", "Type", "ID", "Timer"])
for source in ("vtiger", "hub"):
for row in report[source]["anomalies"]:
anomalies.append([row.get("source"), row.get("type"), row.get("id"), row.get("hours")])
quality = wb.create_sheet("Datakvalitet")
quality.append(["Kilde", "Manglende datoer", "Ikke matchede medarbejdere"])
for source in ("vtiger", "hub"):
data = report[source]["data_quality"]
quality.append([source, data["missing_dates"], ", ".join(data["unmatched_employees"])])
output = io.BytesIO()
wb.save(output)
return output.getvalue()
@router.get("/admin/hub-impact/reports/{report_id}/export.xlsx")
async def export_impact_report(report_id: int, current_user: dict = Depends(require_hidden_superadmin)):
row = execute_query_single("SELECT result FROM hub_impact_reports WHERE id=%s", (report_id,))
if not row:
raise HTTPException(status_code=404, detail="Rapporten findes ikke")
return Response(
content=_impact_workbook(row["result"]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="project-ct-impact-{report_id}.xlsx"'},
)

View File

@ -1,13 +0,0 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.admin.router import require_hidden_superadmin
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/admin/hub-impact", response_class=HTMLResponse)
async def hub_impact_page(request: Request, current_user: dict = Depends(require_hidden_superadmin)):
return templates.TemplateResponse("admin/hub_impact.html", {"request": request, "current_user": current_user})

View File

@ -1,378 +0,0 @@
"""Permanent append-only vTiger archive used by Project CT."""
from __future__ import annotations
import hashlib
import asyncio
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any, Iterable
from app.core.database import execute_insert, execute_query, execute_query_single
from app.services.vtiger_service import VTigerService, get_vtiger_service
logger = logging.getLogger(__name__)
# Document metadata and original file bytes are both archived before vTiger is retired.
ARCHIVE_MODULES = (
"Users", "Groups", "Roles", "Currency", "Accounts", "Contacts", "Leads", "Potentials", "Cases", "Project",
"ProjectMilestone", "ModComments", "Timelog", "Calendar", "Events", "Emails",
"SalesOrder", "Quotes", "Invoice", "PurchaseOrder", "Vendors", "ServiceContracts",
"Subscription", "Products", "Services", "Assets", "Documents",
)
RELATION_FIELD_RE = re.compile(r"(?:^|_)(?:id|ids|account|contact|parent|related|assigned_user)$", re.I)
MODULE_BATCH_SIZES = {"Emails": 20, "Invoice": 50, "SalesOrder": 50, "Subscription": 50}
def _transient_vtiger_failure(service) -> bool:
status = service.last_query_status
error_text = json.dumps(service.last_query_error or {}).upper()
return status is None or status == 429 or (isinstance(status, int) and status >= 500) or any(
token in error_text for token in ("TOO_MANY_REQUESTS", "TIMEOUT", "TEMPORAR")
)
def _canonical_payload(record: dict[str, Any]) -> tuple[str, str]:
raw = json.dumps(record, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=str)
return raw, hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _parse_source_time(value: Any):
text = str(value or "").strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _relation_values(payload: dict[str, Any]) -> Iterable[tuple[str, str]]:
for field, value in payload.items():
if not value or not RELATION_FIELD_RE.search(str(field)):
continue
values = value if isinstance(value, list) else [value]
for candidate in values:
candidate = str(candidate or "").strip()
if re.fullmatch(r"\d+x\d+", candidate):
yield str(field), candidate
def _target_module(vtiger_id: str) -> str | None:
prefix = str(vtiger_id).split("x", 1)[0]
row = execute_query_single(
"""SELECT module FROM vtiger_archive_records
WHERE split_part(vtiger_id,'x',1)=%s ORDER BY id DESC LIMIT 1""", (prefix,),
)
return str(row["module"]) if row else None
def archive_record(version_id: int, module: str, record: dict[str, Any]) -> bool:
vtiger_id = str(record.get("id") or "").strip()
if not vtiger_id:
raise ValueError(f"{module}-post mangler id")
raw, digest = _canonical_payload(record)
previous = execute_query_single(
"""SELECT revision_no, payload_sha256 FROM vtiger_archive_records
WHERE module=%s AND vtiger_id=%s ORDER BY revision_no DESC LIMIT 1""",
(module, vtiger_id),
)
if previous and previous.get("payload_sha256") == digest:
return False
revision = int((previous or {}).get("revision_no") or 0) + 1
execute_query(
"""INSERT INTO vtiger_archive_records
(version_id,module,vtiger_id,revision_no,source_created_at,source_modified_at,is_deleted,payload,payload_sha256)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s)""",
(version_id, module, vtiger_id, revision, _parse_source_time(record.get("createdtime")),
_parse_source_time(record.get("modifiedtime")), bool(record.get("deleted")), raw, digest),
fetch=False,
)
for field, target in _relation_values(record):
execute_query(
"""INSERT INTO vtiger_archive_relations
(version_id,source_module,source_vtiger_id,field_name,target_vtiger_id,target_module)
VALUES (%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, module, vtiger_id, field, target, _target_module(target)), fetch=False,
)
return True
async def archive_document_files(version_id: int) -> dict[str, int]:
"""Archive immutable Document and Email bytes via vTiger's files_retrieve API."""
documents = execute_query(
"""SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,payload
FROM vtiger_archive_records
WHERE module IN ('Documents','Emails') AND version_id<=%s AND is_deleted=false
ORDER BY module,vtiger_id,revision_no DESC""", (version_id,),
) or []
stats = {"entities": len(documents), "resources": 0, "archived": 0, "unchanged": 0, "errors": 0}
# A small hard ceiling keeps this one-time preservation fast without flooding vTiger.
semaphore = asyncio.Semaphore(6)
jobs = []
async def archive_resource(document: dict, payload: dict, resource_id: str) -> None:
async with semaphore:
previous_resource = execute_query_single(
"""SELECT id FROM vtiger_archive_files
WHERE source_module=%s AND document_vtiger_id=%s AND resource_vtiger_id=%s
ORDER BY id DESC LIMIT 1""",
(document["module"], document["vtiger_id"], resource_id),
)
if previous_resource:
stats["unchanged"] += 1
return
service = VTigerService()
result = None
for attempt in range(8):
result = await service.retrieve_file(resource_id)
if result or not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not result:
stats["errors"] += 1
return
content = result.get("content") or b""
digest = hashlib.sha256(content).hexdigest()
filename = str(result.get("filename") or payload.get("filename") or resource_id)
execute_query(
"""INSERT INTO vtiger_archive_files
(version_id,source_module,document_vtiger_id,resource_vtiger_id,filename,content_type,size_bytes,content_sha256,content)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(version_id, document["module"], document["vtiger_id"], resource_id, filename,
result.get("filetype") or payload.get("filetype"), len(content), digest, content),
fetch=False,
)
stats["archived"] += 1
await asyncio.sleep(0.35)
for document in documents:
payload = document.get("payload") or {}
resource_ids = re.findall(r"\d+x\d+", str(payload.get("imageattachmentids") or ""))
for resource_id in resource_ids:
stats["resources"] += 1
jobs.append(archive_resource(document, payload, resource_id))
if jobs:
await asyncio.gather(*jobs)
return stats
async def _fetch_module(module: str, modified_after=None) -> list[dict[str, Any]]:
service = get_vtiger_service()
records: list[dict[str, Any]] = []
seen_ids: set[str] = set()
offset = 0
batch_size = MODULE_BATCH_SIZES.get(module, 100)
while True:
clauses = []
if modified_after:
clauses.append(f"modifiedtime >= '{modified_after.isoformat()}'")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
# This vTiger deployment paginates reliably with LIMIT offset,count.
# ID-watermarks skip records in modules whose entity IDs are compared as text.
query = f"SELECT * FROM {module}{where} ORDER BY id ASC LIMIT {offset}, {batch_size};"
batch = []
for attempt in range(8):
batch = await service.query(query)
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not batch:
if service.last_query_error or (service.last_query_status not in (None, 200)):
raise RuntimeError(f"vTiger kunne ikke hente {module}: {service.last_query_error or service.last_query_status}")
break
fresh = [row for row in batch if str(row.get("id") or "") not in seen_ids]
if not fresh:
break
records.extend(fresh)
seen_ids.update(str(row.get("id") or "") for row in fresh)
# Payload-heavy modules may return fewer than the requested 100 records
# due to a response-size cap even though later offsets still exist.
offset += len(batch)
await asyncio.sleep(0.35)
return records
async def _source_module_count(module: str) -> int:
service = get_vtiger_service()
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT count(*) FROM {module};")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if not rows or rows[0].get("count") is None:
raise RuntimeError(
f"vTiger kunne ikke kontrollere antal poster i {module}: "
f"{service.last_query_error or service.last_query_status}"
)
return int(rows[0]["count"])
async def _close_relation_targets(version_id: int) -> dict[str, int]:
"""Fetch referenced records omitted from normal module lists (notably inactive users)."""
unresolved = execute_query(
"""SELECT DISTINCT r.target_vtiger_id
FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
) ORDER BY r.target_vtiger_id""", (version_id, version_id),
) or []
prefix_rows = execute_query(
"""SELECT split_part(vtiger_id,'x',1) AS prefix,MIN(module) AS module
FROM vtiger_archive_records GROUP BY 1"""
) or []
module_by_prefix = {str(row["prefix"]): str(row["module"]) for row in prefix_rows}
module_by_prefix.update({"19": "Users", "21": "Currency", "53": "Roles"})
service = get_vtiger_service()
stats = {"requested": 0, "archived": 0, "not_found": 0, "errors": 0}
for item in unresolved:
target_id = str(item.get("target_vtiger_id") or "")
if not re.fullmatch(r"\d+x\d+", target_id):
continue
module = module_by_prefix.get(target_id.split("x", 1)[0])
if not module:
continue
stats["requested"] += 1
rows = []
for attempt in range(8):
rows = await service.query(f"SELECT * FROM {module} WHERE id='{target_id}' LIMIT 1;")
if not _transient_vtiger_failure(service):
break
await asyncio.sleep(min(1.5 * (2 ** attempt), 30))
if rows:
stats["archived"] += int(archive_record(version_id, module, rows[0]))
elif _transient_vtiger_failure(service):
stats["errors"] += 1
else:
stats["not_found"] += 1
await asyncio.sleep(0.35)
return stats
async def run_archive_sync(sync_kind: str, initiated_by: int | None = None) -> dict[str, Any]:
if sync_kind not in {"full", "incremental", "final"}:
raise ValueError("sync_kind skal være full, incremental eller final")
running = execute_query_single(
"""SELECT id,started_at FROM vtiger_archive_versions
WHERE status='running' ORDER BY id DESC LIMIT 1"""
)
if running:
age = datetime.now(timezone.utc) - running["started_at"]
if age.total_seconds() < 12 * 3600:
raise RuntimeError(f"Arkivsync version {running['id']} kører allerede")
execute_query(
"""UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),
critical_errors='[{"error":"Stale sync blev lukket automatisk"}]'::jsonb WHERE id=%s""",
(running["id"],), fetch=False,
)
version_id = int(execute_insert(
"""INSERT INTO vtiger_archive_versions(sync_kind,initiated_by,source_cutoff)
VALUES (%s,%s,NOW()) RETURNING id""", (sync_kind, initiated_by),
))
counts: dict[str, Any] = {}
errors: list[dict[str, str]] = []
try:
for module in ARCHIVE_MODULES:
await asyncio.sleep(0.45)
checkpoint = execute_query_single(
"SELECT last_modified_at FROM vtiger_archive_checkpoints WHERE module=%s", (module,),
)
watermark = checkpoint.get("last_modified_at") if checkpoint and sync_kind == "incremental" else None
try:
source_total = await _source_module_count(module) if sync_kind in {"full", "final"} else None
records = await _fetch_module(module, watermark)
inserted = sum(1 for record in records if archive_record(version_id, module, record))
counts[module] = {"fetched": len(records), "new_revisions": inserted}
if source_total is not None:
counts[module]["source_total"] = source_total
if len(records) != source_total:
raise RuntimeError(f"Paritetsfejl: vTiger={source_total}, hentet={len(records)}")
source_times = [parsed for parsed in (_parse_source_time(r.get("modifiedtime")) for r in records) if parsed]
newest = max(source_times, default=None)
execute_query(
"""INSERT INTO vtiger_archive_checkpoints(module,last_modified_at,last_vtiger_id,last_successful_version_id)
VALUES (%s,%s,%s,%s) ON CONFLICT(module) DO UPDATE SET
last_modified_at=COALESCE(EXCLUDED.last_modified_at,vtiger_archive_checkpoints.last_modified_at),
last_vtiger_id=EXCLUDED.last_vtiger_id,last_successful_version_id=EXCLUDED.last_successful_version_id,
updated_at=NOW()""",
(module, newest, str(records[-1].get("id")) if records else None, version_id), fetch=False,
)
except Exception as exc:
logger.exception("Project CT sync failed for %s", module)
errors.append({"module": module, "error": str(exc)})
document_files = await archive_document_files(version_id) if sync_kind in {"full", "final"} else {
"entities": 0, "resources": 0, "archived": 0, "unchanged": 0, "errors": 0,
}
if document_files.get("errors"):
errors.append({
"module": "DocumentFiles",
"error": f"{document_files['errors']} dokumentfiler kunne ikke arkiveres",
})
relation_backfill = await _close_relation_targets(version_id)
if relation_backfill.get("errors"):
errors.append({
"module": "relations",
"error": f"{relation_backfill['errors']} relationer kunne ikke kontrolleres mod vTiger",
})
archived_counts = execute_query(
"""SELECT module,COUNT(*) AS records FROM (
SELECT DISTINCT ON (module,vtiger_id) module,vtiger_id,is_deleted
FROM vtiger_archive_records WHERE version_id <= %s
ORDER BY module,vtiger_id,revision_no DESC
) snapshot WHERE is_deleted=false GROUP BY module ORDER BY module""", (version_id,),
) or []
unresolved = execute_query_single(
"""SELECT COUNT(*)::integer AS count FROM vtiger_archive_relations r
WHERE r.version_id <= %s AND NOT EXISTS (
SELECT 1 FROM vtiger_archive_records t
WHERE t.vtiger_id=r.target_vtiger_id AND t.version_id <= %s
)""", (version_id, version_id),
) or {"count": 0}
document_quality = execute_query_single(
"""SELECT COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE COALESCE(payload->>'filename',payload->>'notes_title','')='')::integer AS missing_name
FROM (SELECT DISTINCT ON (vtiger_id) vtiger_id,payload,is_deleted
FROM vtiger_archive_records WHERE module='Documents' AND version_id <= %s
ORDER BY vtiger_id,revision_no DESC) d WHERE is_deleted=false""", (version_id,),
) or {"total": 0, "missing_name": 0}
report = {
"modules_expected": len(ARCHIVE_MODULES), "modules_completed": len(counts),
"missing_modules": [module for module in ARCHIVE_MODULES if module not in counts],
"module_sync": counts, "archived_snapshot_counts": archived_counts,
"relations": {"unresolved": int(unresolved.get("count") or 0)},
"relation_backfill": relation_backfill,
"documents": {**document_quality, "files": document_files}, "critical_errors": errors,
}
status = "failed" if errors else "completed"
execute_query(
"""UPDATE vtiger_archive_versions SET status=%s,completed_at=NOW(),module_counts=%s::jsonb,
critical_errors=%s::jsonb,control_report=%s::jsonb WHERE id=%s""",
(status, json.dumps(counts), json.dumps(errors), json.dumps(report), version_id), fetch=False,
)
return {"version_id": version_id, "status": status, "counts": counts, "critical_errors": errors}
except Exception as exc:
execute_query(
"UPDATE vtiger_archive_versions SET status='failed',completed_at=NOW(),critical_errors=%s::jsonb WHERE id=%s",
(json.dumps([{"error": str(exc)}]), version_id), fetch=False,
)
raise
def termination_readiness() -> dict[str, Any]:
final = execute_query_single(
"""SELECT id,status,completed_at,critical_errors,control_approved_at
FROM vtiger_archive_versions WHERE sync_kind='final' ORDER BY id DESC LIMIT 1"""
)
reasons = []
if not final or final.get("status") != "completed":
reasons.append("Final sync mangler eller er ikke fuldført")
if final and final.get("critical_errors"):
reasons.append("Final sync har kritiske fejl")
if not final or not final.get("control_approved_at"):
reasons.append("Kontrolrapporten er ikke godkendt")
return {"ready": not reasons, "reasons": reasons, "final_version": final}

View File

@ -87,11 +87,6 @@
</div>
<script>
const requestedNext = new URLSearchParams(window.location.search).get('next');
const loginRedirectTarget = requestedNext && requestedNext.startsWith('/') && !requestedNext.startsWith('//')
? requestedNext
: '/';
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
@ -136,7 +131,8 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
return;
}
window.location.href = loginRedirectTarget;
// Redirect to dashboard
window.location.href = '/';
} else {
// Show error
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
@ -173,7 +169,8 @@ if (token) {
d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
window.location.href = loginRedirectTarget;
// Redirect to dashboard
window.location.href = '/';
} else {
// Token invalid, clear storage
localStorage.removeItem('access_token');

View File

@ -16,7 +16,6 @@ from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service
from app.services.template_service import template_service
from app.services.invoice2data_service import get_invoice2data_service
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
import logging
import os
import re
@ -27,11 +26,15 @@ router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address", "service_address", "monthly_cost", "sales_price", "technology",
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
"sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed",
"range_monthly_cost", "range_sales_price",
"address",
"service_address",
"monthly_cost",
"technology",
"connection_type",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
}
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -264,19 +267,90 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]],
) -> Optional[int]:
outcome = ensure_external_change_case(
connection_id=connection_id,
source_type="globalconnect_invoice",
source_key=str(invoice_number),
source_label=f"GlobalConnect faktura {invoice_number}",
source_url="/billing/supplier-invoices",
reference=reference,
connection_name=connection_name,
provider="GlobalConnect",
owner_customer_id=owner_customer_id,
changes=changes,
# Customer ownership, initial activation and internal classification are
# bookkeeping outcomes of a successful import, not operational incidents.
# Only create cases for changes that can affect delivery or billing.
relevant_changes = {
field: change
for field, change in (changes or {}).items()
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
}
if not relevant_changes:
return None
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
existing = execute_query_single(
"""
SELECT id
FROM sag_sager
WHERE deleted_at IS NULL
AND titel = %s
ORDER BY id DESC
LIMIT 1
""",
(title,),
)
return outcome.get("case_id")
if existing:
return int(existing["id"])
assigned_group_id = _resolve_group_id_by_name_tokens(["økonomi", "okonomi", "economic"])
try:
case_customer_id = owner_customer_id or _resolve_procurement_customer_id()
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: could not resolve case customer (%s)",
connection_id,
invoice_number,
exc,
)
return None
change_lines = "\n".join(
f"- {field}: {change.get('from')} -> {change.get('to')}"
for field, change in relevant_changes.items()
)
description = (
"Automatisk oprettet ved import af internetfaktura.\n"
f"Forbindelse: {connection_name}\n"
f"Reference: {reference or '-'}\n"
f"Faktura: {invoice_number}\n"
"Registrerede ændringer:\n"
f"{change_lines}"
)
try:
try:
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, type, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, _PURCHASE_CASE_TYPE, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as insert_error:
if 'column "type"' not in str(insert_error):
raise
row = execute_query_single(
"""
INSERT INTO sag_sager (
titel, beskrivelse, status, customer_id, assigned_group_id, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""",
(title, description, "åben", case_customer_id, assigned_group_id, 1),
)
except Exception as exc:
logger.warning(
"Skipping automatic internet change case for connection %s on invoice %s: %s",
connection_id,
invoice_number,
exc,
)
return None
return int(row["id"]) if row and row.get("id") else None
def _ensure_case_for_supplier_invoice(
@ -631,82 +705,6 @@ def _normalize_provider_reference(value: Optional[str]) -> str:
return re.sub(r"[^A-Z0-9]", "", raw)
def _provider_reference_match_keys(value: Optional[str]) -> set[str]:
normalized = _normalize_provider_reference(value)
if not normalized:
return set()
keys = {normalized}
if normalized.startswith("DSLEB"):
keys.add(normalized[3:])
elif normalized.startswith("EB"):
keys.add(f"DSL{normalized}")
return keys
def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]:
target_keys = _provider_reference_match_keys(reference)
if not target_keys:
return None
rows = execute_query(
"""
SELECT id, circuit_number
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%'
AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL
ORDER BY id
"""
) or []
matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))]
return int(matches[0]["id"]) if len(matches) == 1 else None
def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]:
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
normalized_reference = _normalize_provider_reference(display_reference)
if not normalized_reference:
return None
existing_id = _find_unique_globalconnect_connection_by_reference(display_reference)
if existing_id:
return existing_id
service_address = _build_service_address(line)
connection_id = execute_insert(
"""
INSERT INTO internet_connections_connections (
name, provider, customer_id, address, status, monthly_cost, sales_price,
technology, connection_type, circuit_number, notes, allocation_model,
value_type, value_label
)
VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
f"Afventer mapping · {display_reference}",
"GlobalConnect A/S",
service_address,
"Internet",
"Internet",
display_reference,
f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.",
"dedicated",
"other",
"Afventer manuel klassifikation",
),
)
return int(connection_id) if connection_id else None
def _canonical_ip_network(value: Optional[str]) -> str:
"""Canonicalize invoice IP/CIDR values before matching or persistence."""
raw = re.sub(r"\s+", "", str(value or "").strip())
if not raw:
return ""
try:
return str(ipaddress.ip_network(raw, strict=False))
except ValueError:
return ""
def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
parts = [f"Afventer mapping for {reference}."]
if end_customer_name:
@ -759,9 +757,11 @@ def _should_assign_internal_bmc_owner(
return False
if any(str(line.get("end_customer_name") or "").strip() for line in lines):
return False
description = " ".join(str(line.get("description") or "").lower() for line in lines)
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
return any(marker in description for marker in explicit_shared_markers)
if any(_looks_like_ip_range_line(line) for line in lines):
return True
if len(lines) > 1:
return True
return bool(service_address)
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str:
@ -1068,21 +1068,17 @@ def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canoni
def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
canonical_cidr = _canonical_ip_network(cidr)
candidates = execute_query(
matches = execute_query(
"""
SELECT id, cidr
SELECT id
FROM internet_connections_ip_ranges
WHERE connection_id = %s
AND cidr = %s
AND deleted_at IS NULL
ORDER BY id
""",
(connection_id,),
) or []
matches = [
row for row in candidates
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
]
(connection_id, cidr),
)
if not matches:
return None
@ -1120,19 +1116,18 @@ def _normalize_service_address_for_match(value: Optional[str]) -> str:
def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
if not reference:
return []
match_keys = sorted(_provider_reference_match_keys(reference))
rows = execute_query(
"""
SELECT id, customer_id, address, monthly_cost, technology, connection_type,
circuit_number, speed_mbps, download_mbps, upload_mbps, status,
allocation_model, value_type, value_label, is_manual_shared
allocation_model, value_type, value_label
FROM internet_connections_connections
WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%'
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s)
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
ORDER BY id
""",
(match_keys,),
(reference,),
) or []
return [dict(row) for row in rows]
@ -1221,30 +1216,44 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
if existing and merge_ids:
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
# Supplier data may suggest a customer name, but customer ownership is
# always a manual CRM decision. Existing manually selected owners survive
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
matched_customer = None
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
if not matched_customer and existing and existing.get("customer_id"):
# Preserve a previously reviewed owner when the new invoice has no
# unambiguous customer name/address instead of replacing it with BMC.
matched_customer = next(
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
None,
)
description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
# Internal BMC ownership is only a default for a newly discovered
# connection. An existing connection with no customer may deliberately be
# unassigned and must not gain an owner merely because a later invoice is
# ambiguous.
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
internal_owner = None
owner_customer = None
is_confident = False
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
internal_owner = (
_resolve_internal_bmc_customer()
if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address)
else None
)
owner_customer = matched_customer or internal_owner
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
connection_name = (
end_customer_name or service_address or f"GlobalConnect {reference}"
if is_confident
else (f"{internal_owner['name']} · {display_reference}" if internal_owner else f"Afventer mapping · {display_reference}")
)
monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0"))
note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description")))
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
if internal_owner and not matched_customer:
note_text = f"{base_note} Ejer sat til intern BMC-kunde, da forbindelsen bruges som delt hovedforbindelse eller ikke kan bindes sikkert til én slutkunde."
else:
note_text = base_note if is_confident else f"{base_note} {mapping_note}"
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
target_status = "pending"
shared_value_type = "delefiber" if is_shared_candidate else "other"
target_status = "active" if (is_confident or internal_owner) else "pending"
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
payload = (
connection_name,
"GlobalConnect A/S",
@ -1258,27 +1267,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
upload_mbps,
download_mbps,
note_text,
"shared" if is_shared_candidate else "dedicated",
"shared" if internal_owner and not matched_customer else "dedicated",
shared_value_type,
None,
)
if existing:
# Delefiber/BMCnet classification is internal operational data. A
# supplier invoice may update speed and cost, but must never undo a
# deliberate manual shared/delefiber designation.
preserve_manual_classification = bool(existing.get("is_manual_shared"))
resolved_allocation_model = (
existing.get("allocation_model") if preserve_manual_classification
else ("shared" if is_shared_candidate else "dedicated")
)
resolved_value_type = (
existing.get("value_type") if preserve_manual_classification
else shared_value_type
)
resolved_value_label = existing.get("value_label") if preserve_manual_classification else None
updated_snapshot = {
"customer_id": existing.get("customer_id"),
"customer_id": owner_customer["id"] if owner_customer else None,
"address": service_address,
"monthly_cost": monthly_cost,
"technology": _infer_technology(description),
@ -1288,9 +1284,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
"download_mbps": download_mbps,
"upload_mbps": upload_mbps,
"status": target_status,
"allocation_model": resolved_allocation_model,
"value_type": resolved_value_type,
"value_label": resolved_value_label,
"allocation_model": "shared" if internal_owner and not matched_customer else "dedicated",
"value_type": shared_value_type,
"value_label": None,
}
update_payload = (
connection_name,
@ -1305,9 +1301,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
download_mbps,
upload_mbps,
note_text,
resolved_allocation_model,
resolved_value_type,
resolved_value_label,
"shared" if internal_owner and not matched_customer else "dedicated",
shared_value_type,
None,
existing["id"],
)
execute_update(
@ -1337,7 +1333,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
update_payload[1],
update_payload[2],
update_payload[3],
target_status,
"active" if (is_confident or internal_owner) else "pending",
update_payload[4],
update_payload[5],
update_payload[6],
@ -1402,7 +1398,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
payload[1],
payload[2],
payload[3],
target_status,
"active" if (is_confident or internal_owner) else "pending",
payload[4],
payload[5],
payload[6],
@ -1433,14 +1429,13 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str):
cidr = _canonical_ip_network(line.get("ip_address"))
cidr = str(line.get("ip_address") or "").strip()
if not connection_id or not cidr:
return None
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
service_address = _build_service_address(line)
# Never infer range ownership from invoice text. A user must select it.
matched_customer = None
matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching())
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
existing = execute_query_single(
"""
@ -1512,16 +1507,12 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
{"cidr": cidr, "changes": changes},
)
reference = display_reference or cidr
connection_owner = execute_query_single(
"SELECT customer_id FROM internet_connections_connections WHERE id=%s",
(connection_id,),
) or {}
_ensure_internet_change_case(
connection_id=connection_id,
invoice_number=invoice_number,
reference=reference,
connection_name=f"IP-range {cidr}",
owner_customer_id=connection_owner.get("customer_id"),
owner_customer_id=matched_customer["id"] if matched_customer else None,
changes=changes,
)
return range_id
@ -1566,28 +1557,6 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
f"IP-range {cidr} oprettet fra faktura {invoice_number}",
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]},
)
# A range added to an existing connection is a commercial change. When the
# connection itself was created by this invoice, it is merely initial data.
created_with_invoice = execute_query_single(
"""SELECT 1 FROM internet_connections_history
WHERE connection_id=%s AND event_type='connection_created_from_supplier_invoice'
AND details->>'invoice_number'=%s LIMIT 1""",
(connection_id, invoice_number),
)
if not created_with_invoice:
connection = execute_query_single(
"""SELECT name, circuit_number, customer_id, provider
FROM internet_connections_connections WHERE id=%s""",
(connection_id,),
) or {}
_ensure_internet_change_case(
connection_id=connection_id,
invoice_number=invoice_number,
reference=str(connection.get("circuit_number") or display_reference or cidr),
connection_name=str(connection.get("name") or f"IP-range {cidr}"),
owner_customer_id=connection.get("customer_id"),
changes={"range_added": {"from": None, "to": cidr}},
)
return range_id
@ -1624,26 +1593,25 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
"""Use an existing CIDR as the strongest key, but never cross service addresses."""
cidr = _canonical_ip_network(line.get("ip_address"))
cidr = str(line.get("ip_address") or "").strip()
if not cidr:
return {"connection_id": None, "conflict_reason": None}
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line)
rows = execute_query(
"""
SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference,
SELECT range.connection_id, range.service_address, range.provider_reference,
connection.address AS connection_address,
connection.circuit_number AS connection_reference
FROM internet_connections_ip_ranges range
JOIN internet_connections_connections connection ON connection.id = range.connection_id
WHERE range.deleted_at IS NULL
WHERE range.cidr = %s
AND range.deleted_at IS NULL
AND connection.deleted_at IS NULL
AND connection.provider ILIKE 'GlobalConnect%%'
ORDER BY range.id
""",
(),
(cidr,),
) or []
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
if reference:
matching_reference = [
row for row in rows
@ -1861,51 +1829,24 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
for audit_index, line in ip_range_lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line)
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
existing_range_resolution = _resolve_existing_ip_range_connection(line)
if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
if existing_range_resolution.get("conflict_reason"):
skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
continue
mapped_reference_connection_id = connection_map.get(reference)
# An existing CIDR on the same service address is stronger evidence than
# a supplier reference. OCR/extraction can accidentally carry a circuit
# number from a neighbouring invoice line.
address_range_connection_id = existing_range_resolution.get("connection_id")
connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id
connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference)
resolved_from_existing = False
matched_by_reference = bool(
not address_range_connection_id
and (reference_connection_id or mapped_reference_connection_id)
)
if reference_connection_id or address_range_connection_id:
if existing_range_resolution.get("connection_id"):
resolved_from_existing = True
corrected_service_address = None
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
if matched_by_reference:
authoritative_connection = execute_query_single(
"""
SELECT address
FROM internet_connections_connections
WHERE id = %s AND deleted_at IS NULL
""",
(connection_id,),
) or {}
corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None
if not corrected_service_address:
connection_id = None
else:
connection_id = None
if not connection_id:
matched_by_reference = False
skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
continue
if not connection_id and reference:
connection_id, connection_conflict_reason = (None, None)
if service_address:
if not service_address:
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range"
skipped_orphan_ip_ranges += 1
continue
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
if connection_id:
connection_map[reference] = connection_id
@ -1915,29 +1856,8 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = connection_conflict_reason
continue
elif not simulate and not reference.startswith("EB"):
connection_id = _create_pending_connection_for_ip_reference(line, invoice_number)
if connection_id:
connection_map[reference] = connection_id
created_or_updated_connections += 1
created_connections += 1
line_audit[audit_index]["created_pending_connection"] = True
elif simulate and not reference.startswith("EB"):
connection_id = -(len(connection_map) + 1)
if matched_by_reference:
line_audit[audit_index]["matched_by"] = "unique_circuit_reference"
if service_address and not _connection_can_host_ip_range(connection_id, service_address):
line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt"
sync_line = dict(line)
if corrected_service_address:
sync_line["service_address"] = corrected_service_address
line_audit[audit_index]["address_warning"] = (
f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse "
f"'{corrected_service_address}'"
)
line_audit[audit_index]["service_address_corrected"] = True
if existing_range_resolution.get("canonical_reference"):
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]
@ -1963,22 +1883,6 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
connection_groups=len(grouped_connections),
ip_range_candidates=len(ip_range_lines),
)
case_creation_errors = []
if not simulate:
try:
case_creation_errors = execute_query(
"""SELECT connection_id, last_error AS error
FROM internet_connection_change_cases
WHERE source_type='globalconnect_invoice' AND source_key=%s
AND last_error IS NOT NULL
ORDER BY connection_id""",
(str(invoice_number),),
) or []
except Exception as exc:
# The invoice still completes during staggered migration rollout.
logger.warning("Could not load internet change-case control report: %s", exc)
verification["change_case_errors"] = case_creation_errors
verification["requires_manual_review"] = bool(case_creation_errors) or bool(verification.get("requires_manual_review"))
return {
"skipped": False,
@ -1993,7 +1897,6 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
"line_audit": line_audit,
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
"verification": verification,
"change_case_errors": case_creation_errors,
}
@ -2244,29 +2147,6 @@ def _smart_extract_lines(text: str) -> List[Dict]:
while i < len(lines_arr):
line = lines_arr[i].strip()
# Attach common continuation metadata to the preceding product row.
if items:
ean_match = re.match(r'^EAN\s*:\s*(\d{8,14})\s*$', line, re.IGNORECASE)
kn8_match = re.match(r'^KN8\s*:\s*(\d{6,10})\s*$', line, re.IGNORECASE)
serial_match = re.match(r'^S/N\s*:\s*(\S+)\s*$', line, re.IGNORECASE)
if ean_match:
items[-1]['ean'] = ean_match.group(1)
i += 1
continue
if kn8_match:
items[-1]['kn8'] = kn8_match.group(1)
i += 1
continue
if serial_match:
items[-1].setdefault('serial_numbers', []).append(serial_match.group(1))
i += 1
continue
if re.match(r'^Omvendt betalingspligt', line, re.IGNORECASE):
items[-1]['reverse_charge'] = True
items[-1]['vat_rate'] = 0.0
i += 1
continue
# Skip empty or header lines
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
i += 1
@ -2275,25 +2155,16 @@ def _smart_extract_lines(text: str) -> List[Dict]:
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
# Whitespace-separated columns: position item_number description quantity unit_price total_price
# Most specific pattern - try first!
amount_pattern = r'-?(?:\d{1,3}(?:[. ]\d{3})+|\d+),\d{2}|-?(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}'
layout_match = re.match(
rf'^\s*(\d{{1,3}})\s+([A-Z0-9][A-Z0-9._/-]{{2,29}})\s+(.+?)\s+(\d{{1,3}}(?:[.,]\d+)?)\s+({amount_pattern})\s+({amount_pattern})\s*$',
line,
re.IGNORECASE,
)
layout_match = re.match(r'^\s*(\d{1,2})\s+(\d{4,10})\s+(.+?)\s(\d{1,2})\s+([\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$', line)
if layout_match:
unit_price = _parse_invoice_number(layout_match.group(5))
line_total = _parse_invoice_number(layout_match.group(6))
items.append({
'line_number': len(items) + 1,
'position': layout_match.group(1),
'item_number': layout_match.group(2),
'sku': layout_match.group(2),
'description': layout_match.group(3).strip(),
'quantity': _parse_invoice_number(layout_match.group(4)),
'unit_price': unit_price,
'line_total': line_total,
'total_price': line_total,
'quantity': layout_match.group(4),
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
'raw_text': line
})
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
@ -2398,130 +2269,6 @@ def _smart_extract_lines(text: str) -> List[Dict]:
return items
def _parse_invoice_number(value):
"""Parse common Danish and English invoice number formats safely."""
if value is None or value == '':
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float, Decimal)):
return float(value)
cleaned = re.sub(r'[^0-9,.-]', '', str(value)).strip('.,')
if not cleaned:
return None
if ',' in cleaned and '.' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.') if cleaned.rfind(',') > cleaned.rfind('.') else cleaned.replace(',', '')
elif ',' in cleaned:
cleaned = cleaned.replace(',', '.')
elif cleaned.count('.') > 1 or ('.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3):
cleaned = cleaned.replace('.', '')
try:
return float(cleaned)
except ValueError:
return None
def _extract_invoice_totals(text: str) -> Dict:
"""Find explicitly labelled totals without guessing from unrelated amounts."""
labels = {
'subtotal': r'(?:Varebeløb|Subtotal|Netto(?:beløb)?)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_basis': r'(?:Momsgrundlag|VAT basis)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'vat_amount': r'(?:Moms(?:beløb)?|VAT amount)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
'total_amount': r'(?:Totalbeløb(?:\s+DKK)?|I alt(?:\s+DKK)?|Amount due)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
}
result = {}
compact = re.sub(r'[ \t]+', ' ', text or '')
summary_row = re.search(
r'Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*'
r'(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+'
r'[\d.,]+%\s+(-?[\d.\s]+,\d{2})',
compact,
re.IGNORECASE,
)
if summary_row:
result.update({
'subtotal': _parse_invoice_number(summary_row.group(1)),
'vat_basis': _parse_invoice_number(summary_row.group(2)),
'vat_amount': _parse_invoice_number(summary_row.group(3)),
'total_amount': _parse_invoice_number(summary_row.group(4)),
})
for key, pattern in labels.items():
if key in result:
continue
matches = list(re.finditer(pattern, compact, re.IGNORECASE))
if matches:
result[key] = _parse_invoice_number(matches[-1].group(1))
return result
def _hybrid_validate_invoice(extracted: Dict, text: str) -> Dict:
"""Cross-check parser and AI output and attach actionable review metadata."""
result = dict(extracted or {})
ai_lines = result.get('lines') if isinstance(result.get('lines'), list) else []
parsed_lines = _smart_extract_lines(text or '')
if len(parsed_lines) > len(ai_lines):
result['lines'] = parsed_lines
selected_lines = parsed_lines
line_source = 'layout_text'
else:
selected_lines = ai_lines
line_source = 'ai_or_template'
totals = _extract_invoice_totals(text or '')
for key in ('total_amount', 'vat_amount'):
current = _parse_invoice_number(result.get(key))
if current is None and totals.get(key) is not None:
result[key] = totals[key]
elif current is not None:
result[key] = current
warnings = []
invalid_rows = []
line_sum = 0.0
for index, line in enumerate(selected_lines, start=1):
if not isinstance(line, dict):
invalid_rows.append(index)
continue
quantity = _parse_invoice_number(line.get('quantity'))
unit_price = _parse_invoice_number(line.get('unit_price'))
line_total = _parse_invoice_number(line.get('line_total', line.get('total_price')))
if line_total is not None:
line_sum += line_total
if quantity is not None and unit_price is not None and line_total is not None:
if abs((quantity * unit_price) - line_total) > 0.05:
invalid_rows.append(index)
subtotal = totals.get('subtotal')
vat_basis = totals.get('vat_basis')
vat_amount = totals.get('vat_amount', _parse_invoice_number(result.get('vat_amount')))
total_amount = totals.get('total_amount', _parse_invoice_number(result.get('total_amount')))
if invalid_rows:
warnings.append(f"Kontrollér beregningen på varelinje(r): {', '.join(map(str, invalid_rows))}")
if selected_lines and subtotal is not None and abs(line_sum - subtotal) > 0.05:
warnings.append(f"Varelinjer summerer til {line_sum:.2f}, men varebeløbet er {subtotal:.2f}")
if subtotal is not None and vat_amount is not None and total_amount is not None and abs((subtotal + vat_amount) - total_amount) > 0.05:
warnings.append(f"Varebeløb + moms stemmer ikke med totalen ({subtotal:.2f} + {vat_amount:.2f} != {total_amount:.2f})")
if vat_basis is not None and vat_amount is not None and abs((vat_basis * 0.25) - vat_amount) > 0.05:
warnings.append(f"Moms {vat_amount:.2f} stemmer ikke med 25% af momsgrundlaget {vat_basis:.2f}")
if not selected_lines:
warnings.append("Ingen sikre varelinjer fundet")
result['_hybrid_validation'] = {
'status': 'needs_review' if warnings else 'validated',
'requires_review': bool(warnings),
'line_source': line_source,
'line_count': len(selected_lines),
'line_sum': round(line_sum, 2),
'document_totals': totals,
'warnings': warnings,
'methods': ['layout_text', 'ai'] if ai_lines else ['layout_text', 'template'],
}
if warnings:
result['_validation_warning'] = ' · '.join(warnings)
result['_validation_details'] = result['_hybrid_validation']
return result
# ========== CRUD OPERATIONS ==========
@router.get("/supplier-invoices")
@ -2994,7 +2741,6 @@ async def get_file_extracted_data(file_id: int):
"_validation_warning": llm_json_data.get('_validation_warning'),
"_vat_warning": llm_json_data.get('_vat_warning'),
"_validation_details": llm_json_data.get('_validation_details'),
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
}
elif extraction:
# Fallback to extraction table columns if no LLM JSON
@ -3794,93 +3540,6 @@ async def search_vendor_by_info(request: Dict):
raise HTTPException(status_code=500, detail=str(e))
_OWN_COMPANY_CVRS = {"29522790", "14416285"}
_OWN_COMPANY_MARKERS = ("bmc denmark", "lejrvej 39", "3500 værløse")
def _pattern_matches_value(pattern: str, text: str, expected) -> bool:
try:
match = re.search(pattern, text or "", re.IGNORECASE | re.MULTILINE)
if not match or not match.groups():
return False
actual = str(match.group(1)).strip()
if isinstance(expected, (int, float, Decimal)):
parsed = _parse_invoice_number(actual)
return parsed is not None and abs(parsed - float(expected)) < 0.01
return re.sub(r"\D", "", actual) == re.sub(r"\D", "", str(expected)) if re.search(r"\d", str(expected)) else actual == str(expected)
except re.error:
return False
def _build_template_builder_result(pdf_text: str, ai_result: Optional[Dict] = None, vendor: Optional[Dict] = None) -> Dict:
"""Build safe template suggestions; AI may suggest, but text evidence wins."""
text = pdf_text or ""
result = dict(ai_result or {})
invoice_pattern = r"(?:Faktura(?:nr\.?|nummer)?|Nummer)\s*[:#]?\s*(\d{4,})"
invoice_match = re.search(invoice_pattern, text, re.IGNORECASE | re.MULTILINE)
if invoice_match:
result["invoice_number"] = {"value": invoice_match.group(1), "pattern": invoice_pattern}
date_pattern = r"(?:Fakturadato|Dato)\s*[:#]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})"
date_match = re.search(date_pattern, text, re.IGNORECASE | re.MULTILINE)
if date_match:
result["invoice_date"] = {"value": date_match.group(1), "pattern": date_pattern}
totals = _extract_invoice_totals(text)
total = totals.get("total_amount")
total_patterns = [
(r"Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*"
r"-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+[\d.,]+%\s+(-?[\d.\s]+,\d{2})"),
r"(?:Totalbeløb|I alt|Amount due)\s*(?:DKK)?\s*:?\s*(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})",
]
if total is not None:
for pattern in total_patterns:
if _pattern_matches_value(pattern, text, total):
result["total_amount"] = {"value": f"{total:.2f}", "pattern": pattern}
break
own_cvrs = set(_OWN_COMPANY_CVRS)
configured_own_cvr = re.sub(r"\D", "", str(getattr(settings, "OWN_CVR", "")))
if configured_own_cvr:
own_cvrs.add(configured_own_cvr)
vendor_cvr = re.sub(r"\D", "", str((vendor or {}).get("cvr_number") or ""))
cvr_candidates = re.findall(r"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*(\d{8})", text, re.IGNORECASE)
safe_cvrs = [cvr for cvr in cvr_candidates if cvr not in own_cvrs]
selected_cvr = vendor_cvr if vendor_cvr and vendor_cvr in safe_cvrs else (safe_cvrs[0] if safe_cvrs else "")
if selected_cvr:
cvr_pattern = rf"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*({re.escape(selected_cvr)})"
if _pattern_matches_value(cvr_pattern, text, selected_cvr):
result["cvr"] = {"value": selected_cvr, "pattern": cvr_pattern}
else:
result.pop("cvr", None)
result.pop("vendor_cvr", None)
candidates = []
vendor_name = str((vendor or {}).get("name") or "").strip()
if vendor_name and vendor_name.lower() in text.lower():
candidates.append(vendor_name)
if selected_cvr:
candidates.append(selected_cvr)
candidates.extend(re.findall(r"\b[\w.+-]+@([\w.-]+\.[A-Za-z]{2,})\b", text))
candidates.extend(re.findall(r"\b(?:www\.)?([A-Za-z0-9-]+\.(?:dk|com|eu|net))\b", text, re.IGNORECASE))
safe_patterns = []
for candidate in candidates:
candidate = str(candidate).strip()
lowered = candidate.lower()
if not candidate or any(marker in lowered for marker in _OWN_COMPANY_MARKERS) or re.sub(r"\D", "", candidate) in own_cvrs:
continue
if candidate.lower() not in {item.lower() for item in safe_patterns}:
safe_patterns.append(candidate)
result["detection_patterns"] = safe_patterns[:5]
result["lines_start"] = {"pattern": r"Nr\.?\s+Varenr\.?\s+Tekst"}
result["lines_end"] = {"pattern": r"(?:Varebeløb|Subtotal|Totalbeløb)"}
line_items = _smart_extract_lines(text)
result["line_count"] = len(line_items)
result["line_items"] = line_items
return result
@router.post("/supplier-invoices/ai/analyze")
async def ai_analyze_invoice(request: Dict):
"""Brug AI til at analysere faktura og foreslå template felter"""
@ -3894,7 +3553,6 @@ async def ai_analyze_invoice(request: Dict):
# Build enhanced PDF text with instruction
from app.core.config import settings
excerpt = pdf_text if len(pdf_text) <= 14000 else f"{pdf_text[:7000]}\n[...midt udeladt...]\n{pdf_text[-7000:]}"
enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
@ -3904,7 +3562,7 @@ REQUIRED STRUKTUR (alle felter skal med):
"invoice_number": "5082481",
"invoice_date": "24/10-25",
"total_amount": "1471.20",
"cvr": "26686091",
"cvr": "29522790",
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
"lines_start": "Nr.VarenrTekst",
"lines_end": "Subtotal"
@ -3935,7 +3593,7 @@ VIGTIGT:
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk data
PDF TEKST:
{excerpt}
{pdf_text[:2000]}
RETURNER KUN JSON - intet andet!"""
@ -3945,21 +3603,10 @@ RETURNER KUN JSON - intet andet!"""
if not result:
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
if result.get("error"):
# extract_from_text returns operational Ollama failures as a dict.
# Do not expose that dict as a successful analysis to the browser.
raise HTTPException(status_code=502, detail=str(result["error"]))
vendor = None
if vendor_id:
rows = execute_query("SELECT id, name, cvr_number FROM vendors WHERE id = %s LIMIT 1", (vendor_id,))
vendor = rows[0] if rows else None
safe_result = _build_template_builder_result(pdf_text, result, vendor)
logger.info("✅ AI analyse gennemført og valideret mod fakturateksten")
return safe_result
logger.info(f"✅ AI analyse gennemført: {result}")
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ AI analyse fejlede: {e}")
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")
@ -5166,7 +4813,6 @@ async def reprocess_uploaded_file(file_id: int):
if template_id and confidence >= 0.5:
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
extracted_fields = template_service.extract_fields(text, template_id)
extracted_fields = _hybrid_validate_invoice(extracted_fields, text)
# Check if this is an invoice2data template (ID -1)
is_invoice2data = (template_id == -1)
@ -5243,7 +4889,7 @@ async def reprocess_uploaded_file(file_id: int):
extracted_fields.get('currency', 'DKK'),
1.0, # invoice2data always 100% confidence
json.dumps(extracted_fields), # llm_response_json
'needs_review' if extracted_fields.get('_hybrid_validation', {}).get('requires_review') else 'validated')
'extracted') # status
)
# Insert line items if extracted
@ -5318,8 +4964,7 @@ async def reprocess_uploaded_file(file_id: int):
raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
extracted_fields = _hybrid_validate_invoice(llm_result, text)
llm_result = extracted_fields
extracted_fields = llm_result
confidence = llm_result.get('confidence', 0.75)
# Post-process: clear own CVR(s) if AI mistakenly returned them
@ -5380,7 +5025,7 @@ async def reprocess_uploaded_file(file_id: int):
llm_result.get('document_type', 'invoice'),
confidence,
json.dumps(llm_result),
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated')
'extracted')
)
# Insert line items if extracted

View File

@ -2916,9 +2916,7 @@ async function reviewExtractedData(fileId) {
let aiData = null;
if (ext.llm_response_json) {
try {
aiData = typeof ext.llm_response_json === 'string'
? JSON.parse(ext.llm_response_json)
: ext.llm_response_json;
aiData = JSON.parse(ext.llm_response_json);
} catch (e) {
console.error('Failed to parse llm_response_json:', e);
}
@ -2931,14 +2929,8 @@ async function reviewExtractedData(fileId) {
.trim();
// Check for validation warnings
const hybridValidation = aiData?._hybrid_validation || null;
const hybridWarnings = hybridValidation?.warnings || [];
const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0);
const sourceLabels = {
layout_text: 'PDF-layout',
ai_or_template: 'AI/template'
};
const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
// Build modal content
let modalContent = `
@ -2954,15 +2946,14 @@ async function reviewExtractedData(fileId) {
${hasValidationIssues ? `
<div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6>
${hybridWarnings.map(warning => `<p class="mb-1">⚠️ ${escapeHtml(warning)}</p>`).join('')}
${!hybridWarnings.length && aiData._validation_warning ? `<p class="mb-1">⚠️ ${escapeHtml(aiData._validation_warning)}</p>` : ''}
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
</div>
` : allValidationsPassed ? `
<div class="alert alert-success">
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6>
<p class="mb-0">${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}</p>
<h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
<p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
</div>
` : ''}

View File

@ -207,8 +207,6 @@
3. Systemet laver automatisk patterns!
</div>
<div id="aiLineItemsResult" class="d-none mb-3"></div>
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn">
<i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
</button>
@ -1372,20 +1370,10 @@ async function autoGenerateTemplate() {
});
if (!response.ok) {
let detail = `AI analyse fejlede (HTTP ${response.status})`;
try {
const payload = await response.json();
if (payload?.detail) detail = payload.detail;
} catch (_) {
// Keep the HTTP status when the server did not return JSON.
}
throw new Error(detail);
throw new Error('AI analyse fejlede');
}
const result = await response.json();
if (result?.error) {
throw new Error(result.error);
}
// Helper to extract value (handles both nested {value, pattern} and flat formats)
const getValue = (field) => field?.value || field;
@ -1468,7 +1456,6 @@ async function autoGenerateTemplate() {
const linesEnd = result.lines_end?.pattern || result.lines_end;
document.getElementById('linesEndPattern').value = linesEnd;
}
renderAiLineItems(result.line_items || [], result.line_count || 0);
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it
btn.innerHTML = '<i class="bi bi-check-circle me-2"></i>✅ AI analyse færdig!';
@ -1481,61 +1468,10 @@ async function autoGenerateTemplate() {
console.error('AI generation failed:', error);
btn.innerHTML = originalText;
btn.disabled = false;
alert(`AI analyse fejlede: ${error.message || 'Ukendt fejl'}`);
alert('AI analyse fejlede. Prøv manuel indtastning.');
}
}
function templateEscapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderAiLineItems(lines, reportedCount) {
const container = document.getElementById('aiLineItemsResult');
if (!container) return;
const count = Array.isArray(lines) ? lines.length : Number(reportedCount || 0);
if (!count) {
container.className = 'alert alert-warning mb-3';
container.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i><strong>Ingen varelinjer fundet.</strong> Kontrollér teksten eller angiv et linjemønster manuelt.';
return;
}
const labels = {
line_number: 'Linje', position: 'Position', item_number: 'Varenummer', sku: 'SKU',
description: 'Beskrivelse / model', quantity: 'Antal', unit_price: 'Stykpris',
line_total: 'Linjebeløb', total_price: 'Samlet beløb', ean: 'EAN', kn8: 'KN8',
serial_numbers: 'Serienummer', reverse_charge: 'Omvendt betalingspligt',
vat_rate: 'Momssats', raw_text: 'Rå tekst'
};
const itemCards = (lines || []).map((line, index) => {
const fields = Object.entries(line)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => {
let displayValue = Array.isArray(value) ? value.join(', ') : value;
if (typeof value === 'boolean') displayValue = value ? 'Ja' : 'Nej';
return `<div class="col-md-6 col-xl-4 mb-2">
<div class="small text-muted">${templateEscapeHtml(labels[key] || key)}</div>
<div class="text-break">${templateEscapeHtml(displayValue)}</div>
</div>`;
}).join('');
const heading = line.item_number || line.sku || `Linje ${index + 1}`;
return `<div class="card mb-2">
<div class="card-header py-2 fw-semibold">Vare ${index + 1}: ${templateEscapeHtml(heading)}</div>
<div class="card-body py-2"><div class="row">${fields}</div></div>
</div>`;
}).join('');
container.className = 'alert alert-success mb-3 p-3';
container.innerHTML = `
<div class="fw-bold mb-2"><i class="bi bi-check-circle me-2"></i>AI fandt ${count} varelinjer</div>
<div>${itemCards}</div>`;
}
async function saveTemplate() {
const vendorId = document.getElementById('vendorSelect').value;
const templateName = document.getElementById('templateName').value;

View File

@ -94,18 +94,17 @@ def _rate_limit(user_id: int) -> None:
def _resolve_customer_id() -> int:
# Resolve our own company by identity, never by an arbitrary/old numeric ID.
# Customer IDs differ between environments and ID 1 may belong to a supplier.
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
if configured_row:
return int(configured_row["id"])
named_row = execute_query_single(
"""
SELECT c.id
FROM customers c
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
WHERE LOWER(TRIM(c.name)) = LOWER(%s)
AND c.deleted_at IS NULL
AND COALESCE(c.is_active, TRUE) = TRUE
GROUP BY c.id
ORDER BY COUNT(s.id) DESC, c.id ASC
SELECT id
FROM customers
WHERE LOWER(name) = LOWER(%s)
ORDER BY id ASC
LIMIT 1
""",
("BMC Networks",),
@ -113,23 +112,11 @@ def _resolve_customer_id() -> int:
if named_row:
return int(named_row["id"])
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
configured_row = execute_query_single(
"""
SELECT id
FROM customers
WHERE id = %s
AND (
LOWER(name) LIKE '%%bmc networks%%'
OR REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ('29522790', '14416285')
)
""",
(configured_id,),
)
if configured_row:
return int(configured_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
if fallback:
return int(fallback["id"])
raise HTTPException(status_code=500, detail="BMC Networks-kunden blev ikke fundet; bugrapporten blev ikke oprettet")
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
@router.post("/bug-reports", response_model=BugReportResult)
@ -189,10 +176,8 @@ async def create_bug_report(payload: BugReportPayload, request: Request):
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large")
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
filename = f"screenshot.{extension}"
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.{extension}")
_create_sag_file_record(sag_id, filename, content_type, size, stored_name)
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
# Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")

View File

@ -3,8 +3,8 @@ Contact API Router - Simplified (Read-Only)
Only GET endpoints for now
"""
from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
from typing import Any, Optional
from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Optional
from pydantic import BaseModel, Field
from app.core.database import (
execute_query,
@ -15,7 +15,6 @@ from app.core.database import (
)
from psycopg2.extras import RealDictCursor
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
from app.services.cvr_service import get_cvr_service
from app.customers.backend.router import (
get_customer_subscriptions,
lock_customer_subscriptions,
@ -23,14 +22,9 @@ from app.customers.backend.router import (
get_subscription_comment,
get_subscription_billing_matrix,
SubscriptionComment,
CustomerCreate,
create_customer,
)
import logging
import json
import re
import html
from email.utils import parseaddr
logger = logging.getLogger(__name__)
router = APIRouter()
@ -69,17 +63,6 @@ class ContactMergeRequest(BaseModel):
source_contact_id: int = Field(..., gt=0)
class EmailCompanyResolve(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
cvr_number: str = Field(..., pattern=r"^\d{8}$")
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
postal_code: Optional[str] = None
website: Optional[str] = None
CONTACT_MERGE_RELATIONS = (
("Firmaer", "contact_companies", "contact_id"),
("Sager", "sag_kontakter", "contact_id"),
@ -96,325 +79,6 @@ CONTACT_MERGE_RELATIONS = (
)
def _exact_email_pattern(address: str) -> str:
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _plain_email_body(parsed: dict) -> str:
text = str(parsed.get("body_text") or "").strip()
if text:
return html.unescape(text).replace("\xa0", " ")
raw_html = str(parsed.get("body_html") or "")
raw_html = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw_html)
raw_html = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", raw_html)
return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ")
def _signature_lines(body: str) -> list[str]:
"""Normalize common Outlook/Markdown decoration without losing line structure."""
lines = []
for raw_line in body.splitlines():
line = re.sub(r"[*_`]+", "", raw_line)
line = re.sub(r"\s+", " ", line).strip(" \\|")
if line:
lines.append(line)
return lines
def _labelled_phone(lines: list[str], labels: str) -> Optional[str]:
phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}")
label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$")
for line in lines:
labelled = label_pattern.search(line)
if labelled:
phone = phone_pattern.search(labelled.group(1))
if phone:
return phone.group(0).strip()
return None
def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]:
"""Conservative, review-only extraction from headers and labelled signature lines."""
body = _plain_email_body(parsed)
signature_lines = _signature_lines(body)
sender_email = str(parsed.get("sender_email") or "").strip().lower()
sender_name = str(parsed.get("sender_name") or "").strip()
parsed_sender_name, parsed_sender_address = parseaddr(sender_name)
if parsed_sender_name:
sender_name = parsed_sender_name
if not sender_email and parsed_sender_address:
sender_email = parsed_sender_address.strip().lower()
current_email = str(contact.get("email") or "").strip().lower()
candidates: dict[str, tuple[str, str]] = {}
# A received Outlook message normally represents the sender. If this looks
# like a sent message to the current contact, do not suggest our own sender.
recipient_text = str(parsed.get("recipient_email") or "").lower()
sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text
if sender_represents_contact and sender_email:
candidates["email"] = (sender_email, "Mailens afsender")
if sender_represents_contact and sender_name and "@" not in sender_name:
name_parts = sender_name.split()
if name_parts:
candidates["first_name"] = (name_parts[0], "Afsendernavn")
if len(name_parts) > 1:
candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn")
mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?")
phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte")
if mobile:
candidates["mobile"] = (mobile, "Mailens signatur")
if phone:
candidates["phone"] = (phone, "Mailens signatur")
for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")):
pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$")
for line in signature_lines:
match = pattern.search(line)
if match:
candidates[field] = (match.group(1).strip(), "Mailens signatur")
break
# Outlook signatures often put an unlabelled job title directly below the
# sender's name. This remains a review-only suggestion.
contact_full_name = " ".join(
part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part
)
signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2]
rejected_titles = {
"kind regards", "best regards", "regards", "med venlig hilsen",
"venlig hilsen", "mvh", "thanks", "thank you", "tak",
}
for signature_name in dict.fromkeys(signature_names):
for index in range(len(signature_lines) - 2, -1, -1):
if signature_lines[index].casefold() != signature_name:
continue
possible_title = signature_lines[index + 1]
if (
"title" not in candidates
and 2 <= len(possible_title) <= 100
and possible_title.casefold().rstrip(",.! ") not in rejected_titles
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title)
and not re.search(r"[@\d]|https?://", possible_title)
):
candidates["title"] = (possible_title, "Linjen under afsendernavnet")
break
if "title" in candidates:
break
labels = {
"first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail",
"phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling",
}
suggestions = []
for field, (value, source) in candidates.items():
current = str(contact.get(field) or "").strip()
if value and current.casefold() != value.casefold():
suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source})
return suggestions
def _company_from_email_body(body: str, contact_name: str = "") -> dict:
lines = _signature_lines(body)
joined = "\n".join(lines)
cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined)
cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else ""
if len(cvr_number) != 8:
cvr_number = ""
company_name = ""
labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined)
if labelled_name:
company_name = labelled_name.group(1).strip()
if not company_name:
for index, line in enumerate(lines):
if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)):
continue
if index:
candidate = lines[index - 1].strip()
if (
candidate.casefold() != contact_name.casefold()
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate)
and not re.search(r"[@+]|https?://", candidate)
):
company_name = candidate
break
return {"name": company_name or None, "cvr_number": cvr_number or None}
@router.post("/contacts/analyze-email")
async def analyze_email_for_new_contact(file: UploadFile = File(...)):
"""Return editable contact/company candidates without creating anything."""
filename = str(file.filename or "").lower()
if not filename.endswith((".eml", ".msg")):
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
content = await file.read()
if not content or len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
from app.services.email_service import EmailService
parser = EmailService()
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
if not parsed:
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")}
suggestions = _contact_suggestions_from_email(empty_contact, parsed)
contact = {item["field"]: item["suggested"] for item in suggestions}
contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
company = _company_from_email_body(_plain_email_body(parsed), contact_name)
company["existing_id"] = None
company["existing_name"] = None
company["lookup_found"] = False
if company.get("cvr_number"):
existing = execute_query_single(
"""SELECT id, name FROM customers
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
ORDER BY id LIMIT 1""",
(company["cvr_number"],),
)
if existing:
company["existing_id"] = existing.get("id")
company["existing_name"] = existing.get("name")
official = await get_cvr_service().lookup_by_cvr(company["cvr_number"])
if official:
company.update({
"name": official.get("name") or company.get("name"),
"address": official.get("address"),
"city": official.get("city"),
"postal_code": official.get("postal_code") or official.get("zipcode"),
"phone": official.get("phone"),
"email": official.get("email"),
"website": official.get("website"),
"status": official.get("status"),
"source": official.get("source"),
"lookup_found": True,
})
return {
"filename": file.filename,
"mail": {"subject": parsed.get("subject"), "sender_email": parsed.get("sender_email")},
"contact": contact,
"company": company,
}
@router.post("/contacts/resolve-email-company")
async def resolve_email_company(payload: EmailCompanyResolve):
"""Reuse a CVR match or create the approved company exactly once."""
existing = execute_query_single(
"""SELECT id, name, cvr_number FROM customers
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
ORDER BY id LIMIT 1""",
(payload.cvr_number,),
)
if existing:
return {**existing, "created": False}
created = await create_customer(CustomerCreate(
name=payload.name,
cvr_number=payload.cvr_number,
email=payload.email,
phone=payload.phone,
address=payload.address,
city=payload.city,
postal_code=payload.postal_code,
website=payload.website,
is_active=True,
))
return {**created, "created": True}
@router.post("/contacts/{contact_id}/analyze-email")
async def analyze_contact_email(contact_id: int, file: UploadFile = File(...)):
"""Parse an Outlook email and return reviewable contact update suggestions."""
contact = execute_query_single(
"SELECT id, first_name, last_name, email, phone, mobile, title, department FROM contacts WHERE id = %s",
(contact_id,),
)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
filename = str(file.filename or "").lower()
if not filename.endswith((".eml", ".msg")):
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
content = await file.read()
if not content or len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
from app.services.email_service import EmailService
parser = EmailService()
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
if not parsed:
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
suggestions = _contact_suggestions_from_email(contact, parsed)
return {
"filename": file.filename,
"mail": {"subject": parsed.get("subject"), "sender_name": parsed.get("sender_name"), "sender_email": parsed.get("sender_email")},
"suggestions": suggestions,
}
@router.get("/contacts/{contact_id}/emails")
async def get_contact_emails(
contact_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
contact = execute_query_single("SELECT id, email FROM contacts WHERE id = %s", (contact_id,))
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
address = str(contact.get("email") or "").strip().lower()
if not address:
return {"items": [], "total": 0, "limit": limit, "offset": offset}
predicate = "em.deleted_at IS NULL AND LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc)) ~ %s"
params = (_exact_email_pattern(address),)
total = execute_query_single(f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", params)
rows = execute_query(
f"""
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
em.linked_case_id, s.titel AS linked_case_title
FROM email_messages em
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
WHERE {predicate}
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
) or []
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
@router.get("/contacts/{contact_id}/cases")
async def get_contact_cases(
contact_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
if not execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
predicate = "sk.contact_id = %s AND sk.deleted_at IS NULL AND s.deleted_at IS NULL"
total = execute_query_single(
f"SELECT COUNT(*) AS count FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE {predicate}",
(contact_id,),
)
rows = execute_query(
f"""
SELECT s.id, s.titel, s.status, s.updated_at, s.customer_id,
cu.name AS customer_name,
s.ansvarlig_bruger_id,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
FROM sag_kontakter sk
JOIN sag_sager s ON s.id = sk.sag_id
LEFT JOIN customers cu ON cu.id = s.customer_id
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE {predicate}
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
LIMIT %s OFFSET %s
""",
(contact_id, limit, offset),
) or []
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
class ContactCompanyLink(BaseModel):
customer_id: int
is_primary: bool = True
@ -483,32 +147,28 @@ async def get_contacts(
where_clauses = []
params = []
normalized_search = " ".join(str(search or "").split())
if normalized_search:
for token in normalized_search.split(" "):
token_clause = """
if search:
where_clauses.append(
"""
(
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s
OR COALESCE(c.email, '') ILIKE %s
OR COALESCE(c.title, '') ILIKE %s
OR COALESCE(c.department, '') ILIKE %s
OR COALESCE(c.user_company, '') ILIKE %s
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR c.user_company ILIKE %s
OR EXISTS (
SELECT 1 FROM contact_companies cc2
SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
token_params: list[Any] = [f"%{token}%"] * 7
digits = "".join(ch for ch in token if ch.isdigit())
if len(digits) >= 2:
token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
token_params.extend([f"%{digits}%", f"%{digits}%"])
token_clause += ")"
where_clauses.append(token_clause)
params.extend(token_params)
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like, like])
if is_active is not None:
where_clauses.append("c.is_active = %s")
@ -528,33 +188,16 @@ async def get_contacts(
total = count_result[0]['count'] if count_result else 0
# Step 1: Fetch contacts only (stable pagination)
rank_order_sql = ""
rank_params: list[Any] = []
if normalized_search:
rank_sql = """
CASE
WHEN LOWER(CONCAT_WS(' ', c.first_name, c.last_name)) = LOWER(%s) THEN 0
WHEN LOWER(CONCAT_WS(' ', c.last_name, c.first_name)) = LOWER(%s) THEN 1
WHEN CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s THEN 2
WHEN CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s THEN 3
WHEN COALESCE(c.email, '') ILIKE %s THEN 4
ELSE 5
END
"""
rank_order_sql = f"{rank_sql},"
rank_params = [normalized_search, normalized_search, f"{normalized_search}%", f"{normalized_search}%", f"{normalized_search}%"]
contacts_query = f"""
SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
FROM contacts c
{where_sql}
ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id
ORDER BY c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s
"""
contacts_params = list(params)
contacts_params.extend(rank_params)
contacts_params.extend([limit, offset])
contacts = execute_query(contacts_query, tuple(contacts_params)) or []

View File

@ -116,21 +116,6 @@
top: 1rem;
right: 1rem;
}
.contact-email-dropzone {
border: 1px dashed rgba(15, 76, 117, 0.45);
border-radius: 12px;
background: rgba(15, 76, 117, 0.04);
padding: 0.8rem 1rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.contact-email-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.12);
border-color: var(--accent);
transform: translateY(-1px);
}
</style>
{% endblock %}
@ -165,18 +150,6 @@
</div>
</div>
<div id="contactEmailDropzone" class="contact-email-dropzone mb-3" role="button" tabindex="0" aria-label="Analysér Outlook-mail for kontaktopdateringer">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-envelope-arrow-down fs-4 text-primary"></i>
<div class="flex-grow-1">
<div class="fw-semibold">Opdatér kontakt fra Outlook-mail</div>
<div class="small text-muted">Træk en .msg- eller .eml-fil hertil, eller klik for at vælge. Intet ændres uden din godkendelse.</div>
</div>
<div id="contactEmailAnalyzeStatus" class="small text-muted"></div>
</div>
<input id="contactEmailFileInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
</div>
<!-- Alert Notes Container -->
<div id="alert-notes-container"></div>
@ -200,16 +173,6 @@
<i class="bi bi-people"></i>Kontakter
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#cases">
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#emails">
<i class="bi bi-envelope"></i>E-mails
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
<i class="bi bi-receipt"></i>Fakturaer
@ -380,24 +343,6 @@
</div>
</div>
<div class="tab-pane fade" id="cases">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens sager</h5>
<span id="contactCasesCount" class="badge bg-secondary">0</span>
</div>
<div id="contactCasesContainer" class="table-responsive"></div>
<div id="contactCasesPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<div class="tab-pane fade" id="emails">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontaktens e-mails</h5>
<span id="contactEmailsCount" class="badge bg-secondary">0</span>
</div>
<div id="contactEmailsContainer" class="table-responsive"></div>
<div id="contactEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5>
@ -887,27 +832,6 @@
</div>
</div>
</div>
<div class="modal fade" id="contactEmailSuggestionsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Foreslåede kontaktopdateringer</h5>
<div id="contactEmailSuggestionMeta" class="small text-muted"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="contactEmailSuggestionsBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="applyContactEmailSuggestionsBtn" onclick="applyContactEmailSuggestions()">
<i class="bi bi-check-lg me-1"></i>Godkend valgte
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
@ -925,12 +849,10 @@ let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null;
let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
document.addEventListener('DOMContentLoaded', () => {
loadContact();
loadCompaniesForSelect();
initializeContactEmailDropzone();
const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) {
@ -959,9 +881,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
document.querySelector('a[href="#cases"]')?.addEventListener('shown.bs.tab', () => loadContactCases());
document.querySelector('a[href="#emails"]')?.addEventListener('shown.bs.tab', () => loadContactEmails());
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
@ -1019,150 +938,6 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
function initializeContactEmailDropzone() {
const zone = document.getElementById('contactEmailDropzone');
const input = document.getElementById('contactEmailFileInput');
if (!zone || !input) return;
const choose = () => input.click();
zone.addEventListener('click', choose);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(); }
});
input.addEventListener('change', () => {
if (input.files?.[0]) analyzeContactEmailFile(input.files[0]);
input.value = '';
});
['dragenter', 'dragover'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
const file = event.dataTransfer?.files?.[0];
if (file) analyzeContactEmailFile(file);
});
}
async function analyzeContactEmailFile(file) {
const status = document.getElementById('contactEmailAnalyzeStatus');
const lower = String(file?.name || '').toLowerCase();
if (!lower.endsWith('.msg') && !lower.endsWith('.eml')) {
status.className = 'small text-danger';
status.textContent = 'Vælg en .msg- eller .eml-fil';
return;
}
status.className = 'small text-muted';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer…';
const form = new FormData();
form.append('file', file);
try {
const response = await fetch(`/api/v1/contacts/${contactId}/analyze-email`, { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
pendingContactEmailSuggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
renderContactEmailSuggestions(data);
bootstrap.Modal.getOrCreateInstance(document.getElementById('contactEmailSuggestionsModal')).show();
status.className = 'small text-success';
status.textContent = `${pendingContactEmailSuggestions.length} forslag fundet`;
} catch (error) {
status.className = 'small text-danger';
status.textContent = error.message;
}
}
function renderContactEmailSuggestions(data) {
const mail = data.mail || {};
document.getElementById('contactEmailSuggestionMeta').textContent = [data.filename, mail.subject, mail.sender_email].filter(Boolean).join(' · ');
const body = document.getElementById('contactEmailSuggestionsBody');
const button = document.getElementById('applyContactEmailSuggestionsBtn');
if (!pendingContactEmailSuggestions.length) {
body.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-check-circle fs-2 d-block mb-2"></i>Ingen nye kontaktoplysninger fundet.</div>';
button.disabled = true;
return;
}
button.disabled = false;
body.innerHTML = `<div class="table-responsive"><table class="table align-middle"><thead><tr><th></th><th>Felt</th><th>Nuværende</th><th>Foreslået</th><th>Kilde</th></tr></thead><tbody>${pendingContactEmailSuggestions.map((item, index) => `
<tr><td><input class="form-check-input contact-email-suggestion-check" type="checkbox" data-index="${index}" checked></td><td class="fw-semibold">${escapeHtml(item.label)}</td><td class="text-muted">${escapeHtml(item.current || '—')}</td><td>${escapeHtml(item.suggested)}</td><td><span class="badge bg-light text-dark border">${escapeHtml(item.source)}</span></td></tr>`).join('')}</tbody></table></div>`;
}
async function applyContactEmailSuggestions() {
const selected = Array.from(document.querySelectorAll('.contact-email-suggestion-check:checked'))
.map(input => pendingContactEmailSuggestions[Number(input.dataset.index)])
.filter(Boolean);
if (!selected.length) return;
const payload = Object.fromEntries(selected.map(item => [item.field, item.suggested]));
const button = document.getElementById('applyContactEmailSuggestionsBtn');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Opdateringen kunne ikke gemmes');
bootstrap.Modal.getInstance(document.getElementById('contactEmailSuggestionsModal'))?.hide();
await loadContact();
const status = document.getElementById('contactEmailAnalyzeStatus');
status.className = 'small text-success';
status.textContent = `${selected.length} felt(er) opdateret`;
} catch (error) {
alert(error.message);
button.disabled = false;
}
}
const contactRelatedPageSize = 25;
let contactCasesOffset = 0;
let contactEmailsOffset = 0;
function contactPagination(hostId, total, offset, loaderName) {
const next = offset + contactRelatedPageSize;
document.getElementById(hostId).innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${offset === 0 ? 'disabled' : ''} onclick="${loaderName}(${Math.max(0, offset - contactRelatedPageSize)})">Forrige</button>
<span class="small text-muted">${total ? offset + 1 : 0}${Math.min(next, total)} af ${total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= total ? 'disabled' : ''} onclick="${loaderName}(${next})">Næste</button>`;
}
async function loadContactCases(offset = contactCasesOffset) {
contactCasesOffset = Math.max(0, offset);
const host = document.getElementById('contactCasesContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/cases?limit=${contactRelatedPageSize}&offset=${contactCasesOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
document.getElementById('contactCasesCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Sag</th><th>Status</th><th>Kunde</th><th>Ansvarlig</th><th>Opdateret</th></tr></thead><tbody>${data.items.map(item => `
<tr role="button" onclick="window.location.href='/sag/${item.id}'"><td><strong>#${item.id}</strong> ${escapeHtml(item.titel || '')}</td><td>${escapeHtml(item.status || '-')}</td><td>${escapeHtml(item.customer_name || '-')}</td><td>${escapeHtml(item.responsible_name || 'Ikke tildelt')}</td><td>${escapeHtml(item.updated_at ? new Date(item.updated_at).toLocaleString('da-DK') : '-')}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen direkte tilknyttede sager</div>';
contactPagination('contactCasesPagination', data.total, contactCasesOffset, 'loadContactCases');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactCasesPagination').innerHTML = '';
}
}
async function loadContactEmails(offset = contactEmailsOffset) {
contactEmailsOffset = Math.max(0, offset);
const host = document.getElementById('contactEmailsContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/emails?limit=${contactRelatedPageSize}&offset=${contactEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('contactEmailsCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'"><td>${escapeHtml(email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-')}</td><td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td><td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td><td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td><td>${email.has_attachments ? '<i class="bi bi-paperclip"></i>' : ''}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen e-mails fundet for kontaktens adresse</div>';
contactPagination('contactEmailsPagination', data.total, contactEmailsOffset, 'loadContactEmails');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactEmailsPagination').innerHTML = '';
}
}
async function loadContact() {
try {
const response = await fetch(`/api/v1/contacts/${contactId}`);

View File

@ -490,24 +490,6 @@
padding: 0;
}
.create-mail-dropzone {
min-width: 210px;
border: 1px dashed rgba(15, 76, 117, 0.55);
border-radius: 12px;
background: rgba(15, 76, 117, 0.05);
color: var(--accent);
padding: 0.55rem 0.8rem;
cursor: pointer;
transition: background-color .2s, border-color .2s, transform .2s;
}
.create-mail-dropzone:hover,
.create-mail-dropzone.is-dragging {
background: rgba(15, 76, 117, 0.14);
border-color: var(--accent);
transform: translateY(-1px);
}
@media (max-width: 992px) {
.contacts-toolbar {
width: 100%;
@ -546,17 +528,10 @@
</div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<div id="createFromEmailDropzone" class="create-mail-dropzone d-flex align-items-center gap-2" role="button" tabindex="0" title="Træk en mail direkte fra Outlook hertil">
<i class="bi bi-envelope-arrow-down fs-5"></i>
<div><div class="fw-semibold small">Træk Outlook-mail hertil</div><div class="text-muted" style="font-size:.7rem">eller klik for at vælge</div></div>
</div>
<input id="createFromEmailInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
<button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button>
</div>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
@ -657,55 +632,6 @@
</div>
</div>
<!-- Create contact from Outlook email -->
<div class="modal fade" id="createFromEmailModal" tabindex="-1">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Opret kontakt ud fra mail</h5>
<div id="createFromEmailMeta" class="small text-muted"></div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="createFromEmailStatus" class="alert alert-info d-none"></div>
<h6 class="fw-bold">Kontaktoplysninger</h6>
<div class="row g-3 mb-4">
<div class="col-md-6"><label class="form-label">Fornavn</label><input id="mailContactFirstName" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Efternavn</label><input id="mailContactLastName" class="form-control"></div>
<div class="col-md-6"><label class="form-label">E-mail</label><input id="mailContactEmail" type="email" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Mobil</label><input id="mailContactMobile" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Telefon</label><input id="mailContactPhone" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Titel</label><input id="mailContactTitle" class="form-control"></div>
<div class="col-md-6"><label class="form-label">Afdeling</label><input id="mailContactDepartment" class="form-control"></div>
</div>
<div id="mailCompanySection" class="border rounded-3 p-3 d-none">
<div class="form-check mb-3">
<input id="mailUseCompany" class="form-check-input" type="checkbox" checked>
<label id="mailUseCompanyLabel" class="form-check-label fw-semibold" for="mailUseCompany">Tilknyt virksomhed</label>
</div>
<div class="row g-3">
<div class="col-md-7"><label class="form-label">Virksomhedsnavn</label><input id="mailCompanyName" class="form-control"></div>
<div class="col-md-5"><label class="form-label">CVR</label><input id="mailCompanyCvr" class="form-control" maxlength="8"></div>
<div class="col-md-7"><label class="form-label">Adresse</label><input id="mailCompanyAddress" class="form-control"></div>
<div class="col-md-2"><label class="form-label">Postnr.</label><input id="mailCompanyPostalCode" class="form-control"></div>
<div class="col-md-3"><label class="form-label">By</label><input id="mailCompanyCity" class="form-control"></div>
</div>
<div id="mailCompanyHint" class="form-text mt-2"></div>
</div>
<div id="mailNoCompany" class="text-muted small border rounded-3 p-3">Intet CVR fundet. Der oprettes ikke en virksomhed automatisk.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button id="approveCreateFromEmailBtn" type="button" class="btn btn-primary" onclick="approveCreateFromEmail()">
<i class="bi bi-check-lg me-1"></i>Godkend og opret
</button>
</div>
</div>
</div>
</div>
<!-- Create Contact Modal -->
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
<div class="modal-dialog modal-lg">
@ -893,7 +819,6 @@ let selectedCompanyIds = new Set();
let currentContactsData = [];
let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null;
let analyzedMailCompany = null;
let currentSort = {
key: 'name',
direction: 'asc'
@ -907,12 +832,6 @@ let visibleColumns = {
// Load contacts on page load
document.addEventListener('DOMContentLoaded', () => {
initializeCreateFromEmailDropzone();
document.getElementById('createFromEmailInput')?.addEventListener('change', event => {
const file = event.target.files?.[0];
if (file) analyzeEmailForNewContact(file);
event.target.value = '';
});
const urlParams = new URLSearchParams(window.location.search);
const preselectedCustomerId = Number(urlParams.get('customer_id'));
const shouldOpenCreateModal = urlParams.get('create') === '1';
@ -999,42 +918,6 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
function initializeCreateFromEmailDropzone() {
const zone = document.getElementById('createFromEmailDropzone');
const input = document.getElementById('createFromEmailInput');
if (!zone || !input) return;
const chooseFile = () => input.click();
zone.addEventListener('click', chooseFile);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
chooseFile();
}
});
['dragenter', 'dragover'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
zone.classList.add('is-dragging');
}));
['dragleave', 'dragend'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragging');
const file = event.dataTransfer?.files?.[0]
|| Array.from(event.dataTransfer?.items || []).find(item => item.kind === 'file')?.getAsFile();
if (!file) {
alert('Outlook afleverede ikke mailen som en fil. Prøv at gemme eller trække den som .msg/.eml, eller klik på boksen.');
return;
}
analyzeEmailForNewContact(file);
});
}
function setFilter(filter) {
currentFilter = filter;
currentPage = 0;
@ -1542,105 +1425,6 @@ function renderSelectedCompanies() {
`).join('');
}
async function analyzeEmailForNewContact(file) {
const form = new FormData();
form.append('file', file);
const zone = document.getElementById('createFromEmailDropzone');
const original = zone?.innerHTML;
if (zone) { zone.style.pointerEvents = 'none'; zone.innerHTML = '<span class="spinner-border spinner-border-sm"></span><span class="small fw-semibold">Analyserer…</span>'; }
try {
const response = await fetch('/api/v1/contacts/analyze-email', { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
const contact = data.contact || {};
const values = {
mailContactFirstName: contact.first_name,
mailContactLastName: contact.last_name,
mailContactEmail: contact.email,
mailContactMobile: contact.mobile,
mailContactPhone: contact.phone,
mailContactTitle: contact.title,
mailContactDepartment: contact.department,
mailCompanyName: data.company?.existing_name || data.company?.name,
mailCompanyCvr: data.company?.cvr_number,
mailCompanyAddress: data.company?.address,
mailCompanyPostalCode: data.company?.postal_code,
mailCompanyCity: data.company?.city,
};
Object.entries(values).forEach(([id, value]) => { document.getElementById(id).value = value || ''; });
analyzedMailCompany = data.company || {};
document.getElementById('createFromEmailMeta').textContent = [data.filename, data.mail?.subject, data.mail?.sender_email].filter(Boolean).join(' · ');
const hasCvr = !!data.company?.cvr_number;
document.getElementById('mailCompanySection').classList.toggle('d-none', !hasCvr);
document.getElementById('mailNoCompany').classList.toggle('d-none', hasCvr);
if (hasCvr) {
const existing = !!data.company.existing_id;
document.getElementById('mailUseCompanyLabel').textContent = existing ? 'Tilknyt eksisterende virksomhed' : 'Opret og tilknyt ny virksomhed';
document.getElementById('mailCompanyHint').textContent = existing
? `CVR findes allerede som ${data.company.existing_name}. Der oprettes ikke en dublet.`
: data.company.lookup_found
? `Virksomhedsdata er hentet fra CVR-opslaget (${data.company.source || 'CVR-registeret'}). Kontrollér oplysningerne før oprettelse.`
: 'CVR findes ikke i systemet, men kunne ikke valideres i CVR-opslaget. Kontrollér oplysningerne før oprettelse.';
document.getElementById('mailCompanyName').readOnly = existing;
document.getElementById('mailCompanyCvr').readOnly = existing;
document.getElementById('mailUseCompany').checked = true;
}
document.getElementById('createFromEmailStatus').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('createFromEmailModal')).show();
} catch (error) {
alert(error.message);
} finally {
if (zone) { zone.style.pointerEvents = ''; zone.innerHTML = original; }
}
}
async function approveCreateFromEmail() {
const value = id => document.getElementById(id).value.trim() || null;
const contactData = {
first_name: value('mailContactFirstName'), last_name: value('mailContactLastName') || '',
email: value('mailContactEmail'), mobile: value('mailContactMobile'), phone: value('mailContactPhone'),
title: value('mailContactTitle'), department: value('mailContactDepartment'), is_active: true,
};
if (!contactData.first_name) { alert('Fornavn skal udfyldes'); return; }
const button = document.getElementById('approveCreateFromEmailBtn');
const status = document.getElementById('createFromEmailStatus');
button.disabled = true;
status.className = 'alert alert-info';
status.textContent = 'Opretter…';
try {
let companyId = document.getElementById('mailUseCompany').checked ? analyzedMailCompany?.existing_id : null;
if (document.getElementById('mailUseCompany').checked && analyzedMailCompany?.cvr_number && !companyId) {
const companyName = value('mailCompanyName');
const cvrNumber = (value('mailCompanyCvr') || '').replace(/\D/g, '');
if (!companyName || cvrNumber.length !== 8) throw new Error('Virksomhedsnavn og et gyldigt CVR på 8 cifre skal udfyldes');
const companyResponse = await fetch('/api/v1/contacts/resolve-email-company', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: companyName, cvr_number: cvrNumber,
email: analyzedMailCompany?.email || contactData.email,
phone: analyzedMailCompany?.phone || null,
address: value('mailCompanyAddress'), postal_code: value('mailCompanyPostalCode'),
city: value('mailCompanyCity'), website: analyzedMailCompany?.website || null
})
});
const company = await companyResponse.json().catch(() => ({}));
if (!companyResponse.ok) throw new Error(company.detail || 'Virksomheden kunne ikke oprettes');
companyId = company.id;
}
if (companyId) { contactData.company_ids = [Number(companyId)]; contactData.is_primary = true; }
const response = await fetch('/api/v1/contacts', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contactData)
});
const contact = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
window.location.href = `/contacts/${contact.id}`;
} catch (error) {
status.className = 'alert alert-danger';
status.textContent = error.message;
button.disabled = false;
}
}
function showCreateContactModal() {
// Reset form
document.getElementById('createContactForm').reset();

View File

@ -15,21 +15,6 @@ class Settings(BaseSettings):
# Database
DATABASE_URL: str = "postgresql://bmc_hub:bmc_hub@localhost:5432/bmc_hub"
# Public website content administration API (HTTPS on Plesk)
WEBSITE_CONTENT_API_URL: str = "https://isp.bmcnetworks.dk/api/admin-content.php"
WEBSITE_CONTENT_API_HOST_HEADER: str = "ct.bmcnetworks.dk"
WEBSITE_CONTENT_API_TOKEN: str = ""
WEBSITE_CONTENT_API_TIMEOUT_SECONDS: int = 20
# Deprecated direct-MySQL settings retained temporarily for .env compatibility.
WEBSITE_CONTENT_MYSQL_HOST: str = ""
WEBSITE_CONTENT_MYSQL_PORT: int = 3306
WEBSITE_CONTENT_MYSQL_DATABASE: str = ""
WEBSITE_CONTENT_MYSQL_USER: str = ""
WEBSITE_CONTENT_MYSQL_PASSWORD: str = ""
WEBSITE_CONTENT_MYSQL_POOL_SIZE: int = 5
WEBSITE_CONTENT_MYSQL_CONNECT_TIMEOUT: int = 8
WEBSITE_CONTENT_LOGO_BASE_URL: str = ""
# API
API_HOST: str = "0.0.0.0"
API_PORT: int = 8000
@ -37,10 +22,6 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
# Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12
@ -123,9 +104,6 @@ class Settings(BaseSettings):
# Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2"
# Primary company CVR used to prevent AI invoice extraction from treating
# the buyer (BMC) as the supplier.
OWN_CVR: str = "29522790"
# Email System Configuration
# IMAP Settings
@ -232,8 +210,6 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
# Backup System Configuration
BACKUP_ENABLED: bool = True

View File

@ -1,59 +0,0 @@
"""The deliberately narrow set of external e-conomic mutations BMC Hub permits."""
from contextlib import contextmanager
from contextvars import ContextVar
import re
from fastapi import HTTPException
# Reading any endpoint is allowed. Writes are an explicit allow-list, not a
# deny-list, so a new endpoint cannot accidentally become writable.
ALLOWED_WRITES = {
('POST', 'customers'),
('POST', 'products'),
('POST', 'orders/drafts'),
}
_approved_four_eyes_write = ContextVar('approved_four_eyes_write', default=False)
_approved_order_draft_update = ContextVar('approved_order_draft_update', default=False)
_FOUR_EYES_UPDATES = re.compile(r'^(?:customers|products)/[^/]+$')
_ORDER_DRAFT_UPDATE = re.compile(r'^orders/drafts/[0-9]+$')
@contextmanager
def approved_four_eyes_write():
"""Permit one approved customer/product update in the current async context."""
token = _approved_four_eyes_write.set(True)
try:
yield
finally:
_approved_four_eyes_write.reset(token)
@contextmanager
def approved_order_draft_update():
"""Permit one user-confirmed update of an existing e-conomic order draft."""
token = _approved_order_draft_update.set(True)
try:
yield
finally:
_approved_order_draft_update.reset(token)
def assert_economic_write_allowed(method: str, path: str) -> None:
clean_path = path.split('?', 1)[0].strip('/')
operation = (method.upper(), clean_path)
approved_update = (
operation[0] == 'PUT'
and _FOUR_EYES_UPDATES.fullmatch(clean_path)
and _approved_four_eyes_write.get()
)
approved_draft_update = (
operation[0] == 'PUT'
and _ORDER_DRAFT_UPDATE.fullmatch(clean_path)
and _approved_order_draft_update.get()
)
if operation not in ALLOWED_WRITES and not approved_update and not approved_draft_update:
raise HTTPException(
status_code=403,
detail='e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder',
)

View File

@ -10,7 +10,6 @@ from pydantic import BaseModel
import logging
import asyncio
import aiohttp
import re
from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
@ -112,7 +111,6 @@ class CustomerBase(BaseModel):
is_active: Optional[bool] = True
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
supplier_service_enrolled: Optional[bool] = False
class CustomerCreate(CustomerBase):
@ -135,7 +133,6 @@ class CustomerUpdate(BaseModel):
invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None
department: Optional[str] = None
supplier_service_enrolled: Optional[bool] = None
class EmailDomainCreate(BaseModel):
@ -583,76 +580,6 @@ async def get_customer(customer_id: int):
}
def _email_address_pattern(address: str) -> str:
"""PostgreSQL regex matching one exact address inside common address lists."""
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _email_domain_pattern(domain: str) -> str:
"""PostgreSQL regex matching an address at exactly this domain."""
return rf"(^|[^a-z0-9._%+-])[a-z0-9._%+-]+@{re.escape(domain.lower())}([^a-z0-9.-]|$)"
@router.get("/customers/{customer_id}/emails")
async def get_customer_emails(
customer_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
"""List emails safely associated with a customer without mutating links."""
customer = execute_query_single("SELECT id, email FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
contact_rows = execute_query(
"""
SELECT DISTINCT LOWER(TRIM(c.email)) AS email
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s AND NULLIF(TRIM(c.email), '') IS NOT NULL
""",
(customer_id,),
) or []
domain_rows = execute_query(
"SELECT LOWER(TRIM(domain)) AS domain FROM email_domain_customer_mappings WHERE customer_id = %s",
(customer_id,),
) or []
addresses = {str(row["email"]).lower() for row in contact_rows if row.get("email")}
if customer.get("email"):
addresses.add(str(customer["email"]).strip().lower())
domains = {str(row["domain"]).lower() for row in domain_rows if row.get("domain")}
haystack = "LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc))"
match_clauses = ["em.customer_id = %s"]
match_params: list[Any] = [customer_id]
for address in sorted(addresses):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_address_pattern(address))
for domain in sorted(domains):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_domain_pattern(domain))
predicate = f"em.deleted_at IS NULL AND ({' OR '.join(match_clauses)})"
total_row = execute_query_single(
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", tuple(match_params)
)
rows = execute_query(
f"""
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
em.linked_case_id, s.titel AS linked_case_title
FROM email_messages em
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
WHERE {predicate}
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
LIMIT %s OFFSET %s
""",
tuple([*match_params, limit, offset]),
) or []
return {"items": rows, "total": int((total_row or {}).get("count") or 0), "limit": limit, "offset": offset}
@router.get("/customers/{customer_id}/utility-company")
async def get_customer_utility_company(customer_id: int):
@ -812,8 +739,8 @@ async def create_customer(customer: CustomerCreate):
customer_id = execute_insert(
"""INSERT INTO customers
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
country, website, is_active, invoice_email, mobile_phone, supplier_service_enrolled)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
country, website, is_active, invoice_email, mobile_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
customer.name,
@ -828,8 +755,7 @@ async def create_customer(customer: CustomerCreate):
customer.website,
customer.is_active,
customer.invoice_email,
customer.mobile_phone,
customer.supplier_service_enrolled
customer.mobile_phone
)
)

View File

@ -884,14 +884,9 @@
<i class="bi bi-list-check"></i>Sager
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#emails">
<i class="bi bi-envelope"></i>E-mails
</a>
</li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kommunikation
<i class="bi bi-chat-left-text"></i>Kontakt
</a>
</li>
<li class="nav-item">
@ -1052,13 +1047,6 @@
<span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span>
</div>
<div class="info-row">
<label class="info-label form-check-label" for="supplierServiceEnrolled">Benytter leverandørservice</label>
<div class="text-end">
<input class="form-check-input" type="checkbox" id="supplierServiceEnrolled" onchange="saveSupplierServiceEnrollment(this)">
<div id="supplierServiceStatus" class="small text-muted mt-1"></div>
</div>
</div>
</div>
</div>
@ -1228,27 +1216,11 @@
</div>
</div>
<!-- Emails Tab -->
<div class="tab-pane fade" id="emails">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kundens e-mails</h5>
<small class="text-muted">Koblede mails og sikre adresse-/domænematch</small>
</div>
<span id="customerEmailsCount" class="badge bg-secondary">0</span>
</div>
<div id="customerEmailsContainer" class="table-responsive"></div>
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div>
<!-- Kommunikation Tab -->
<!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h5 class="fw-bold mb-0">Kommunikationshistorik</h5>
<small class="text-muted">Opkald og SMSer med kunden</small>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kommunikationsfilter">
<h5 class="fw-bold mb-0">Kontakt historik</h5>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>
@ -2324,8 +2296,6 @@ let customerKontaktFilter = 'all';
let customerInvoicesLoaded = false;
let customerInvoicesData = [];
let customerInvoiceSearchTerm = '';
let customerEmailsOffset = 0;
const customerEmailsLimit = 25;
let eventListenersAdded = false;
@ -2357,11 +2327,6 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false });
}
const emailsTab = document.querySelector('a[href="#emails"]');
if (emailsTab) {
emailsTab.addEventListener('shown.bs.tab', () => loadCustomerEmails(), { once: false });
}
const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => {
@ -2765,7 +2730,6 @@ function displayCustomer(customer) {
document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>';
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
// Integration
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
@ -2781,68 +2745,6 @@ function displayCustomer(customer) {
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
}
async function saveSupplierServiceEnrollment(checkbox) {
const previous = !checkbox.checked;
const status = document.getElementById('supplierServiceStatus');
checkbox.disabled = true;
status.className = 'small text-muted mt-1';
status.textContent = 'Gemmer…';
try {
const response = await fetch(`/api/v1/customers/${customerId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ supplier_service_enrolled: checkbox.checked })
});
if (!response.ok) throw new Error('Kunne ikke gemme ændringen');
customerData.supplier_service_enrolled = checkbox.checked;
status.className = 'small text-success mt-1';
status.textContent = 'Gemt';
} catch (error) {
checkbox.checked = previous;
status.className = 'small text-danger mt-1';
status.textContent = error.message;
} finally {
checkbox.disabled = false;
}
}
function formatCrmDate(value) {
return value ? new Date(value).toLocaleString('da-DK') : '-';
}
async function loadCustomerEmails(offset = customerEmailsOffset) {
customerEmailsOffset = Math.max(0, offset);
const container = document.getElementById('customerEmailsContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/customers/${customerId}/emails?limit=${customerEmailsLimit}&offset=${customerEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('customerEmailsCount').textContent = data.total;
if (!data.items.length) {
container.innerHTML = '<div class="text-center text-muted py-5">Ingen e-mails fundet</div>';
} else {
container.innerHTML = `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'">
<td>${escapeHtml(formatCrmDate(email.received_date))}</td>
<td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td>
<td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td>
<td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td>
<td>${email.has_attachments ? `<i class="bi bi-paperclip" title="${email.attachment_count || 1} vedhæftning(er)"></i>` : ''}</td>
</tr>`).join('')}</tbody></table>`;
}
const prev = Math.max(0, customerEmailsOffset - customerEmailsLimit);
const next = customerEmailsOffset + customerEmailsLimit;
document.getElementById('customerEmailsPagination').innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${customerEmailsOffset === 0 ? 'disabled' : ''} onclick="loadCustomerEmails(${prev})">Forrige</button>
<span class="small text-muted">${data.total ? customerEmailsOffset + 1 : 0}${Math.min(next, data.total)} af ${data.total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= data.total ? 'disabled' : ''} onclick="loadCustomerEmails(${next})">Næste</button>`;
} catch (error) {
container.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('customerEmailsPagination').innerHTML = '';
}
}
function renderCustomerCallNumber(number) {
const clean = normalizePhoneValue(number);
if (!clean) return '-';

View File

@ -488,11 +488,6 @@
const fetchJson = async (url, options = {}) => {
const res = await fetch(url, options);
if (res.status === 401) {
const next = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
}
if (!res.ok) {
const text = await res.text();
let message = text || `HTTP ${res.status}`;
@ -530,7 +525,7 @@
};
const loadConfig = async () => {
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
const config = await fetchJson('/api/v1/also/config');
const label = !config.enabled
? 'ALSO deaktiveret'
: config.read_only
@ -672,27 +667,10 @@
renderJobLines();
};
const renderCustomerSearchResults = (lineId) => {
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateCustomerSearchResults = (lineId) => {
const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderCustomerSearchResults(lineId);
};
const renderMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return `
@ -716,8 +694,16 @@
` : `
<div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
`}
<div class="map-results mb-2" data-company-results-line-id="${lineId}">
${renderCustomerSearchResults(lineId)}
<div class="map-results mb-2">
${results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -729,27 +715,10 @@
`;
};
const renderProductSearchResults = (lineId) => {
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateProductSearchResults = (lineId) => {
const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderProductSearchResults(lineId);
};
const renderProductMapEditor = (line) => {
const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return `
@ -773,8 +742,16 @@
` : `
<div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
`}
<div class="map-results mb-2" data-product-results-line-id="${lineId}">
${renderProductSearchResults(lineId)}
<div class="map-results mb-2">
${results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -842,14 +819,13 @@
state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineCustomerSearchResults[lineId] = [];
updateCustomerSearchResults(lineId);
renderJobLines();
return;
}
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
if (state.lineCustomerSearchQueries[lineId] !== query) return;
state.lineCustomerSearchResults[lineId] = rows || [];
updateCustomerSearchResults(lineId);
renderJobLines();
};
const saveCompanyMapping = async (lineId) => {
@ -892,14 +868,13 @@
state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) {
state.lineProductSearchResults[lineId] = [];
updateProductSearchResults(lineId);
renderJobLines();
return;
}
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
if (state.lineProductSearchQueries[lineId] !== query) return;
state.lineProductSearchResults[lineId] = rows || [];
updateProductSearchResults(lineId);
renderJobLines();
};
const saveProductMapping = async (lineId) => {

View File

@ -15,7 +15,6 @@ from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService
from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier
@ -2263,37 +2262,6 @@ async def reprocess_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/recover-attachments")
async def recover_email_attachments(email_id: int):
"""Recover a missing Graph attachment, then run the normal email workflow.
Intended for the old metadata-only attachment imports. It is safe to retry:
the normal invoice checksum and workflow safeguards remain in force.
"""
try:
recovery = await EmailService().recover_graph_attachments(email_id)
if not recovery.get("success"):
raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
email_rows = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
)
if not email_rows:
raise HTTPException(status_code=404, detail="Email not found after recovery")
processing = await EmailProcessorService().process_single_email(email_rows[0])
return {
"success": True,
**recovery,
"workflows_executed": processing.get("workflows_executed", 0),
"awaiting_user_action": processing.get("awaiting_user_action", False),
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error recovering email attachments for %s", email_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/process")
async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500),
@ -2411,10 +2379,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity
await activity_logger.log_fetched(
activity_logger.log_fetched(
email_id=email_id,
source="manual_upload",
message_id=email_data.get("message_id", file.filename)
metadata={"filename": file.filename}
)
# Auto-classify

View File

@ -77,79 +77,6 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
}
.emails-v2-flow-picker {
width: 100%;
}
.emails-v2-flow-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 0.65rem;
}
.emails-v2-flow-choice {
position: relative;
min-height: 74px;
padding: 0.72rem 0.8rem;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #52606d;
color: #fff;
text-align: left;
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
}
.emails-v2-flow-choice:hover:not(:disabled) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.emails-v2-flow-choice.recommended {
border: 3px solid #fff;
outline: 3px solid var(--accent);
}
.emails-v2-flow-choice.flow-support { background: #1769aa; }
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
.emails-v2-flow-choice.flow-archive { background: #59636e; }
.emails-v2-flow-choice:disabled {
opacity: 0.42;
box-shadow: none;
}
.emails-v2-flow-choice .flow-title {
display: block;
font-weight: 700;
}
.emails-v2-flow-choice .flow-description {
display: block;
margin-top: 0.22rem;
color: rgba(255, 255, 255, 0.88);
font-size: 0.76rem;
line-height: 1.25;
}
.emails-v2-flow-choice .flow-badge {
position: absolute;
top: -0.55rem;
right: 0.45rem;
padding: 0.12rem 0.42rem;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 0.66rem;
font-weight: 700;
}
@media (max-width: 1500px) {
.emails-v2-flow-grid {
grid-template-columns: repeat(2, minmax(150px, 1fr));
}
}
.email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px;
@ -1147,19 +1074,7 @@
? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = [];
if (isApprovedSupplierInvoice) {
effects.push(
'Beholder tilknytningen til den valgte leverandør',
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
'Aflæser fakturanummer, beløb og varelinjer',
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
'Markerer emailen som behandlet'
);
} else {
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect);
});
@ -1169,36 +1084,42 @@
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
));
});
}
const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
<div class="d-flex justify-content-between small mt-2 mb-1">
<span>${escapeHtml(confidenceLabel)}</span>
<strong>${confidencePercent}%</strong>
</div>
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div>
<div class="mt-3">
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
<div class="mt-3">
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
${renderIdentityAssessment()}
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
${hasActions ? `
<ol class="email-ai-actions mb-2">
<ol class="email-ai-actions mb-3">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol>
${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : ''}
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
</div>` : `
<button id="v2ApproveActions" class="btn btn-success w-100">
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
</button>`}
` : `
<div class="alert alert-secondary py-2 small mb-0">
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
</div>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div>
`;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions();
}
@ -1208,12 +1129,7 @@
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try {
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
await runQuickAction('create_supplier_invoice', button);
} else {
await executeWorkflowsCurrent();
}
} finally {
if (document.body.contains(button)) {
button.disabled = false;
@ -1242,8 +1158,6 @@
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
@ -1255,15 +1169,7 @@
`;
}).join('');
const matchingHtml = usesSupplierApprovalFlow
? `<div class="emails-v2-kv">
<div class="k">Godkendelsesflow</div>
<div class="v">
<span class="badge bg-success">Leverandørfaktura</span>
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
</div>
</div>`
: matching.length
const matchingHtml = matching.length
? matching.map((wf) => `
<div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
@ -1698,48 +1604,26 @@
const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier;
const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase();
const isGlobalConnect = supplierIdentity.includes('globalconnect');
const classification = String(email.classification || '').toLowerCase();
const recommendedFlow = classification === 'invoice'
? 'create_supplier_invoice'
: (classification === 'customer_email' ? 'create_customer_case' : '');
const recommendationBadge = (flow) => recommendedFlow === flow
? '<span class="flow-badge">ANBEFALET</span>'
: '';
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
quickActions.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a>
` : `
<div class="emails-v2-flow-picker">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
${recommendationBadge('create_customer_case')}
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
<span class="flow-description">Opret en almindelig kundesag</span>
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
<i class="bi bi-person-workspace me-1"></i>Kundesag
</button>
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
${recommendationBadge('create_supplier_invoice')}
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
<span class="flow-description">${isGlobalConnect ? 'Internetfaktura genkendt · opdaterer Internetforbindelser' : 'Aflæs PDF og kør leverandørflowet'}</span>
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
</button>
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
${recommendationBadge('create_accounting_case')}
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
</button>
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
${recommendationBadge('archive_entity_mail')}
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
<span class="flow-description">Ingen sag eller anden behandling</span>
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
<i class="bi bi-archive me-1"></i>Arkivér mail
</button>
</div>
</div>
`;
mailBody.className = 'emails-v2-mail-body';
@ -1777,14 +1661,8 @@
</div>
</div>
<details class="emails-v2-card">
<summary class="fw-semibold" style="cursor:pointer;">
<i class="bi bi-three-dots me-1"></i>Flere handlinger
</summary>
<div class="mt-3">
${email.linked_case_id ? `
<div class="emails-primary-action rounded p-3 mb-3">
<div class="emails-v2-card emails-primary-action">
<h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
@ -1793,9 +1671,8 @@
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button>
</div>` : `
<details class="border rounded p-3 mb-3">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
<div class="mt-3">
<div class="emails-v2-card emails-primary-action">
<h6>Opret sag fra denne email</h6>
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm">
@ -1809,16 +1686,15 @@
<i class="bi bi-plus-lg me-1"></i>Opret
</button>
</div>
</div>
</details>`}
</div>`}
<div class="border rounded p-3 mb-3">
<div class="emails-v2-card">
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
<div id="v2SagResults" class="emails-v2-sag-results"></div>
</div>
<div class="border rounded p-3 mb-3">
<div class="emails-v2-card">
<h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
@ -1834,7 +1710,7 @@
</div>
</div>
<details class="border rounded p-3 mb-3">
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
@ -1845,7 +1721,7 @@
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details>
<details class="border rounded p-3 mb-3">
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
@ -1867,7 +1743,7 @@
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details>
<details class="border rounded p-3">
<details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id,
@ -1879,8 +1755,6 @@
linked_case_id: email.linked_case_id,
}, null, 2))}</pre>
</details>
</div>
</details>
`;
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));

View File

@ -29,8 +29,6 @@ async def check_reminders():
try:
logger.info("🔔 Checking for pending reminders...")
from app.modules.sag.backend.reminders import process_task_list_rules
await process_task_list_rules()
# Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue()

View File

@ -8,20 +8,14 @@ Runs daily at 04:00
import logging
from datetime import datetime, date
import json
from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection
from app.services.subscription_billing_calendar import (
advance_billing_periods,
billing_date_for_period,
prorated_30_day_factor,
)
logger = logging.getLogger(__name__)
async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
async def process_subscriptions():
"""
Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today
@ -32,13 +26,6 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
try:
logger.info("💰 Processing subscription invoices...")
from app.subscriptions.backend.router import apply_due_subscription_changes, expire_ended_subscriptions
applied_changes = apply_due_subscription_changes()
expired_subscriptions = expire_ended_subscriptions()
if applied_changes:
logger.info("✅ Applied %s scheduled subscription change request(s)", applied_changes)
if expired_subscriptions:
logger.info("✅ Expired %s ended subscription(s)", expired_subscriptions)
# Find subscriptions due for invoicing
query = """
@ -50,13 +37,8 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
c.name AS customer_name,
s.product_name,
s.billing_interval,
s.billing_schedule_type,
s.billing_day,
s.billing_direction,
s.advance_months,
s.billing_lead_months,
s.first_full_period_start,
s.proration_basis,
s.price,
s.next_invoice_date,
s.period_start,
@ -85,43 +67,15 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
),
'[]'::json
) as line_items
,COALESCE(
(
SELECT json_agg(json_build_object(
'id', fi.id,
'description', fi.description,
'quantity', fi.quantity,
'unit_price', fi.unit_price,
'line_total', fi.line_total,
'product_id', fi.product_id
) ORDER BY fi.line_no, fi.id)
FROM sag_subscription_first_invoice_items fi
WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL
),
'[]'::json
) AS first_invoice_items
FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active'
AND s.next_invoice_date <= CURRENT_DATE
AND NOT EXISTS (
SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
)
"""
params = []
if subscription_ids is not None:
selected_ids = sorted({int(item) for item in subscription_ids})
if not selected_ids:
return
query += " AND s.id = ANY(%s)"
params.append(selected_ids)
query += """
ORDER BY s.next_invoice_date, s.id
"""
subscriptions = execute_query(query, tuple(params))
subscriptions = execute_query(query)
if not subscriptions:
logger.info("✅ No subscriptions due for invoicing")
@ -192,22 +146,6 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
coverage_start = None
coverage_end = None
# Claim every subscription period. A competing worker will make this
# transaction roll back before an order draft can be duplicated.
claimed_run_ids = []
for sub in subscriptions:
cursor.execute(
"""INSERT INTO subscription_billing_runs (subscription_id, period_start)
VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING id""",
(int(sub['id']), sub.get('period_start') or sub.get('next_invoice_date')),
)
claimed = cursor.fetchone()
if not claimed:
conn.rollback()
logger.info("Subscription period already claimed by another worker")
return 0
claimed_run_ids.append(int(claimed[0]))
for sub in subscriptions:
subscription_id = int(sub['id'])
source_subscription_ids.append(subscription_id)
@ -217,13 +155,7 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
line_items = json.loads(line_items)
period_start = sub.get('period_start') or sub.get('next_invoice_date')
first_full_period_start = sub.get('first_full_period_start')
if isinstance(first_full_period_start, str):
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
advance_periods = max(1, int(sub.get('advance_months') or 1))
has_short_opening_period = bool(first_full_period_start and period_start < first_full_period_start)
full_period_start = first_full_period_start if has_short_opening_period else period_start
period_end = advance_billing_periods(full_period_start, sub['billing_interval'], advance_periods)
period_end = _calculate_next_period_start(period_start, sub['billing_interval'])
if coverage_start is None or period_start < coverage_start:
coverage_start = period_start
if coverage_end is None or period_end > coverage_end:
@ -239,53 +171,9 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
continue
product_number = str(item.get('product_id', 'SUB'))
if has_short_opening_period:
factor = prorated_30_day_factor(period_start, first_full_period_start)
if factor > 0:
prorated_unit_price = round(float(item.get('unit_price', 0)) * factor, 2)
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": f"{item.get('description', '')} skæv periode {period_start} til {first_full_period_start} (30 dage)"
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": prorated_unit_price,
"totalNetAmount": round(float(item.get('quantity', 1)) * prorated_unit_price, 2),
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"proration_basis": "30_day",
"proration_factor": factor,
"period_from": str(period_start),
"period_to": str(first_full_period_start),
}
})
full_unit_price = float(item.get('unit_price', 0)) * advance_periods
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": item.get('description', '') + (f" {advance_periods} perioder" if advance_periods > 1 else '')
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": full_unit_price,
"totalNetAmount": float(item.get('quantity', 1)) * full_unit_price,
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"asset_id": item.get('asset_id'),
"period_from": str(full_period_start),
"period_to": str(item.get('period_to') or period_end),
"advance_periods": advance_periods,
}
})
first_invoice_items = sub.get('first_invoice_items', [])
if isinstance(first_invoice_items, str):
first_invoice_items = json.loads(first_invoice_items)
for item in first_invoice_items:
ordre_lines.append({
"product": {
"productNumber": str(item.get('product_id') or 'ENGANG'),
"description": item.get('description', '')
},
"quantity": float(item.get('quantity', 1)),
@ -294,8 +182,9 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"first_invoice_item_id": item.get('id'),
"one_time": True,
"asset_id": item.get('asset_id'),
"period_from": str(item.get('period_from') or period_start),
"period_to": str(item.get('period_to') or period_end),
}
})
@ -348,22 +237,6 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
))
ordre_id = cursor.fetchone()[0]
cursor.execute(
"UPDATE subscription_billing_runs SET ordre_draft_id = %s WHERE id = ANY(%s)",
(ordre_id, claimed_run_ids),
)
for sub, run_id in zip(subscriptions, claimed_run_ids):
first_items = sub.get('first_invoice_items', [])
if isinstance(first_items, str):
first_items = json.loads(first_items)
first_item_ids = [int(item['id']) for item in first_items if item.get('id')]
if first_item_ids:
cursor.execute(
"""UPDATE sag_subscription_first_invoice_items
SET billed_at = CURRENT_TIMESTAMP, billing_run_id = %s, updated_at = CURRENT_TIMESTAMP
WHERE subscription_id = %s AND id = ANY(%s) AND billed_at IS NULL""",
(run_id, int(sub['id']), first_item_ids),
)
logger.info(
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
ordre_id,
@ -373,19 +246,8 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
for sub in subscriptions:
subscription_id = int(sub['id'])
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
first_full_period_start = sub.get('first_full_period_start')
if isinstance(first_full_period_start, str):
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
full_period_start = first_full_period_start if first_full_period_start and current_period_start < first_full_period_start else current_period_start
new_period_start = advance_billing_periods(
full_period_start, sub['billing_interval'], max(1, int(sub.get('advance_months') or 1))
)
new_next_invoice_date = billing_date_for_period(
new_period_start,
int(sub.get('billing_lead_months') or 0),
sub.get('billing_schedule_type') or 'fixed_day',
int(sub.get('billing_day') or 1),
)
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
cursor.execute(
"""

View File

@ -1,20 +0,0 @@
"""Scheduled permanent Project CT vTiger archive sync."""
import logging
from app.admin.vtiger_archive import run_archive_sync
logger = logging.getLogger(__name__)
async def run_project_ct_archive_sync() -> None:
try:
result = await run_archive_sync("incremental")
logger.info("Project CT incremental archive sync completed: %s", result)
except RuntimeError as exc:
if "kører allerede" in str(exc):
logger.info("Project CT scheduled sync skipped: %s", exc)
return
logger.exception("Project CT scheduled archive sync failed")
except Exception:
logger.exception("Project CT scheduled archive sync failed")

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
"""
from enum import Enum
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict
from typing import Optional, List
from datetime import datetime, date
@ -89,7 +89,6 @@ class VendorBase(BaseModel):
priority: Optional[int] = 100
notes: Optional[str] = None
is_active: bool = True
is_internet_provider: bool = False
class VendorCreate(VendorBase):
@ -104,16 +103,10 @@ class VendorUpdate(BaseModel):
domain: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
economic_supplier_number: Optional[int] = None
contact_person: Optional[str] = None
category: Optional[str] = None
notes: Optional[str] = None
is_active: Optional[bool] = None
is_internet_provider: Optional[bool] = None
class Vendor(VendorBase):
@ -166,15 +159,6 @@ class SolutionBase(BaseModel):
description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: str = "internal"
approval_status: str = "draft"
is_final: bool = True
tags: list[str] = Field(default_factory=list)
products: list[str] = Field(default_factory=list)
class SolutionCreate(SolutionBase):
"""Schema for creating a solution"""
@ -187,16 +171,6 @@ class SolutionUpdate(BaseModel):
description: Optional[str] = None
solution_type: Optional[str] = None
result: Optional[str] = None
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: Optional[str] = None
approval_status: Optional[str] = None
is_final: Optional[bool] = None
tags: Optional[list[str]] = None
products: Optional[list[str]] = None
change_note: Optional[str] = None
class Solution(SolutionBase):
"""Full solution schema"""
@ -205,9 +179,6 @@ class Solution(SolutionBase):
created_by_user_id: Optional[int] = None
created_at: datetime
updated_at: Optional[datetime] = None
updated_by_user_id: Optional[int] = None
approved_by_user_id: Optional[int] = None
approved_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -1,6 +1,6 @@
from typing import Optional
from fastapi import APIRouter, File, Query, Request, Response, UploadFile
from fastapi import APIRouter, File, Query, Request, UploadFile
from app.modules.also.backend.service import also_service
from app.modules.also.models.schemas import (
@ -34,8 +34,7 @@ def _user_id_from_request(request: Request) -> Optional[int]:
@router.get("/also/config")
async def also_config(response: Response) -> dict:
response.headers["Cache-Control"] = "no-store"
async def also_config() -> dict:
return also_service.get_config()

View File

@ -275,21 +275,6 @@ def _extract_period_start(value: Any) -> Optional[str]:
return _parse_date_candidate(match.group(1))
def _format_charge_period(value: Any) -> Optional[str]:
"""Return an explicit ALSO service period without inferring a missing end date."""
text = _normalized_text(value)
if not text:
return None
match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text)
if not match:
return None
start = _parse_date_candidate(match.group(1))
end = _parse_date_candidate(match.group(2))
if not start or not end:
return None
return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}"
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
product_name = _normalize_match_key(line.get("product_name"))
if "microsoftorganizationtenant" not in product_name:
@ -334,8 +319,7 @@ class AlsoService:
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
"currency": ["currency", "valuta"],
"billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"],
# The CSV's Actual Charge Interval is the authoritative service period.
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
"charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"],
"billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"],
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
@ -2024,10 +2008,6 @@ class AlsoService:
quantity = Decimal("1")
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement"
charge_period = _format_charge_period(line.get("charge_interval"))
if charge_period:
description = f"{description}\nPeriode: {charge_period}"
draft_lines.append(
{
@ -2035,7 +2015,7 @@ class AlsoService:
"source_type": "also_cloud",
"source_id": int(line["id"]),
"reference_id": int(line["import_job_id"]),
"description": description,
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
"quantity": float(quantity),
"unit": "stk",
"unit_price": float(unit_price),
@ -2052,7 +2032,6 @@ class AlsoService:
"also_material_number": line.get("material_number"),
"also_vendor": line.get("vendor"),
"also_import_job_id": int(line.get("import_job_id")),
"billing_period": charge_period,
},
}
)
@ -2088,7 +2067,7 @@ class AlsoService:
]
execute_query(
"""UPDATE ordre_drafts
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])),
)
@ -2111,10 +2090,10 @@ class AlsoService:
RETURNING id
""",
(
"Abonnementer",
f"ALSO Cloud {customer_name} - {period_key}",
customer_id,
_json_dumps(draft_lines),
None,
"Genereret fra ALSO Cloud Billing approval",
1,
approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}),

View File

@ -4,7 +4,6 @@ import logging
from typing import Optional
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
from fastapi.encoders import jsonable_encoder
from app.core.auth_service import AuthService
from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary
@ -80,14 +79,14 @@ async def bottom_bar_ws(websocket: WebSocket):
initial_status = get_dashboard_status()
initial_notifications = get_notifications(user_id, limit=20)
initial_messages = get_user_messages_summary(user_id, limit=20)
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status}))
await websocket.send_json(jsonable_encoder({
await websocket.send_json({"event": "status_delta", "data": initial_status})
await websocket.send_json({
"event": "notification_delta",
"data": {
"notifications": initial_notifications,
"messages": initial_messages,
},
}))
})
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
@ -100,7 +99,7 @@ async def bottom_bar_ws(websocket: WebSocket):
timer = get_active_timer(user_id)
elapsed = int(timer.get("elapsed") or 0)
if elapsed != last_timer_elapsed:
await websocket.send_json(jsonable_encoder({"event": "timer_tick", "data": timer}))
await websocket.send_json({"event": "timer_tick", "data": timer})
last_timer_elapsed = elapsed
status_tick += 1
@ -111,19 +110,19 @@ async def bottom_bar_ws(websocket: WebSocket):
status_json = json.dumps(status, sort_keys=True, default=str)
if status_json != last_status_json:
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": status}))
await websocket.send_json({"event": "status_delta", "data": status})
last_status_json = status_json
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
messages_json = json.dumps(messages, sort_keys=True, default=str)
if notifications_json != last_notifications_json or messages_json != last_messages_json:
await websocket.send_json(jsonable_encoder({
await websocket.send_json({
"event": "notification_delta",
"data": {
"notifications": notifications,
"messages": messages,
},
}))
})
last_notifications_json = notifications_json
last_messages_json = messages_json

View File

@ -1,8 +1,8 @@
from typing import Optional, Literal
from typing import Optional
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user
@ -69,10 +69,6 @@ class BottomBarMessageCreatePayload(BaseModel):
message: str
recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False
message_kind: Literal['message', 'phone'] = 'message'
contact_id: Optional[int] = Field(default=None, gt=0)
caller_name: str = Field(default='', max_length=200)
callback_phone: str = Field(default='', max_length=80)
class BottomBarMessageReadPayload(BaseModel):
@ -570,17 +566,13 @@ async def send_bottom_bar_message(
if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
if payload.contact_id is not None:
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
row = execute_query_single(
"""
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""",
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()),
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
) or {}
return {
@ -590,10 +582,6 @@ async def send_bottom_bar_message(
"from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text,
"message_kind": payload.message_kind,
"contact_id": payload.contact_id,
"caller_name": payload.caller_name.strip(),
"callback_phone": payload.callback_phone.strip(),
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"),
"is_own": True,

View File

@ -83,10 +83,6 @@ def ensure_bottom_bar_messages_schema() -> None:
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL,
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
caller_name VARCHAR(200) NOT NULL DEFAULT '',
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL
@ -96,12 +92,6 @@ def ensure_bottom_bar_messages_schema() -> None:
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else:
columns = set(_table_columns("bottom_bar_messages"))
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
execute_query("""ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
if "requires_manual_ack" not in columns:
execute_query(
"""
@ -153,8 +143,6 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.sender_user_id,
m.recipient_user_id,
m.message_text,
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
m.requires_manual_ack,
m.created_at,
receipt.read_at,
@ -164,10 +152,9 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN contacts contact ON contact.id = m.contact_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END
AND receipt.user_id = %s
WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS (
@ -179,7 +166,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC
LIMIT %s
""",
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or []
unread_row = execute_query_single(
@ -213,15 +200,9 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"from": row.get("sender_name") or "Ukendt",
"to": recipient_name,
"text": row.get("message_text") or "",
"message_kind": row.get("message_kind") or "message",
"contact_id": row.get("contact_id"),
"contact_name": row.get("contact_name"),
"caller_name": row.get("caller_name") or "",
"callback_phone": row.get("callback_phone") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_read": row.get("read_at") is not None,
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("acknowledged_at") is not None,
}
@ -990,10 +971,6 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
procurement_attention = execute_query_single(
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
WHERE type = 'purchase' AND status = 'draft'"""
) or {"count": 0}
urgent_cases = execute_query(
"""
@ -1178,10 +1155,6 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,

View File

@ -8,164 +8,11 @@ from psycopg2.extras import Json
from datetime import datetime, date
import os
import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class MobileRecorderProvisionRequest(BaseModel):
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
name: str = Field(min_length=1, max_length=120)
asset_type: str = "mobile_recorder"
manufacturer: str = "Apple"
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
model: Optional[str] = Field(default=None, max_length=100)
device_type: Optional[str] = Field(default=None, max_length=100)
serial_number: str = Field(min_length=1, max_length=100)
udid: Optional[str] = Field(default=None, max_length=160)
ecid: Optional[str] = Field(default=None, max_length=160)
imei: Optional[str] = Field(default=None, max_length=40)
wifi_mac: Optional[str] = Field(default=None, max_length=40)
os: str = "iOS"
os_version: Optional[str] = Field(default=None, max_length=40)
supervised: bool = False
status: str = "ready"
def _provisioning_token_or_401(
authorization: Optional[str], x_provisioning_token: Optional[str],
) -> None:
"""Authenticate a headless provisioning client without accepting user JWTs."""
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
if not expected:
logger.error("Mobile Recorder provisioning was called but no service token is configured")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provisioning endpoint is disabled: service token is not configured",
)
bearer = (authorization or "").strip()
supplied = (x_provisioning_token or "").strip()
if not supplied and bearer.lower().startswith("bearer "):
supplied = bearer[7:].strip()
if not supplied or not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provisioning token",
headers={"WWW-Authenticate": "Bearer"},
)
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
return str(value).strip() if value is not None and str(value).strip() else None
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
async def provision_mobile_recorder(
payload: MobileRecorderProvisionRequest,
authorization: Optional[str] = Header(default=None),
x_provisioning_token: Optional[str] = Header(default=None),
):
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
_provisioning_token_or_401(authorization, x_provisioning_token)
if payload.asset_type != "mobile_recorder":
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
if payload.status != "ready":
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
serial_number = _clean_provisioning_value(payload.serial_number)
if not serial_number:
raise HTTPException(status_code=422, detail="serial_number is required")
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
recorder_name = _clean_provisioning_value(payload.name)
mobile_specs = {
"recorder_number": payload.recorder_number,
"name": recorder_name,
"udid": _clean_provisioning_value(payload.udid),
"ecid": _clean_provisioning_value(payload.ecid),
"imei": _clean_provisioning_value(payload.imei),
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
"os": _clean_provisioning_value(payload.os) or "iOS",
"os_version": _clean_provisioning_value(payload.os_version),
"supervised": bool(payload.supervised),
"provisioning_status": "ready",
"source": "apple_configurator",
}
existing = execute_query(
"""SELECT id, hardware_specs FROM hardware_assets
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
ORDER BY id LIMIT 2""",
(serial_number,),
) or []
if len(existing) > 1:
raise HTTPException(
status_code=409,
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
)
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
if isinstance(prior_specs, str):
try:
prior_specs = json.loads(prior_specs)
except (TypeError, ValueError):
prior_specs = {}
if not isinstance(prior_specs, dict):
prior_specs = {}
prior_specs["mobile_recorder"] = mobile_specs
if existing:
asset_id = int(existing[0]["id"])
rows = execute_query(
"""UPDATE hardware_assets
SET asset_type = 'mobile_recorder', brand = %s,
model = COALESCE(%s, model), internal_asset_id = %s,
recorder_number = %s, status = 'ready', hardware_specs = %s,
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id""",
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
)
action = "updated"
else:
rows = execute_query(
"""INSERT INTO hardware_assets
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id""",
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
)
action = "created"
asset_id = int(rows[0]["id"])
execute_query(
"""INSERT INTO hardware_ownership_history
(hardware_id, owner_type, start_date, notes)
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
(asset_id,),
fetch=False,
)
if not rows:
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
execute_query(
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
VALUES (%s, %s, %s)""",
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
fetch=False,
)
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None:
return None

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -12,30 +12,14 @@
border-radius: 24px;
padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
position: relative;
overflow: hidden;
}
.detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; }
.detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; }
.detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; }
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
.detail-panel {
background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
}
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
.detail-metric {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
@ -43,10 +27,7 @@
border-radius: 16px;
padding: 1rem;
height: 100%;
position:relative;
overflow:hidden;
}
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
.detail-metric-label {
color: var(--text-secondary);
@ -74,31 +55,6 @@
padding: 0.85rem;
}
#ipRangesList { grid-template-columns: 1fr; }
.ip-range-card {
border-left:4px solid #3282b8;
background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45));
display:grid;
grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto;
align-items:center;
gap:1rem;
padding:1rem 1.1rem;
}
.ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; }
.ip-range-main,.ip-range-meta { min-width:0; }
.ip-range-meta .label { margin-bottom:.15rem; }
.ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; }
.ip-range-actions { justify-self:end; }
.ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; }
.ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; }
@media (max-width: 1000px) {
.ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); }
.ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; }
.ip-range-actions { justify-self:start; }
}
.ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); }
.ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; }
.detail-grid-card.editing {
border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.06);
@ -279,7 +235,6 @@
}
@media (max-width: 991.98px) {
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.detail-read-grid {
grid-template-columns: 1fr;
}
@ -290,8 +245,6 @@
gap: 0.2rem;
}
}
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
</style>
{% endblock %}
@ -299,16 +252,12 @@
<div class="container-fluid py-4">
<div class="detail-hero mb-4">
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3">
<div class="detail-title-wrap">
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
<div>
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
<div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div>
<div class="detail-circuit-badge" id="detailCircuitBadge"><i class="bi bi-diagram-3"></i><span>Henter kredsløb...</span></div>
<div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<a href="/economy/internet-connections" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage
@ -325,51 +274,11 @@
<button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()">
<i class="bi bi-save me-1"></i>Gem ændringer
</button>
<a class="btn btn-outline-primary" id="createCaseForConnectionBtn" href="/sag/new">
<i class="bi bi-plus-circle me-1"></i>Opret sag
</a>
</div>
</div>
<nav class="detail-section-nav" aria-label="Sektioner">
<a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a>
<a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a>
<a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a>
<a href="#connection-cases"><i class="bi bi-kanban"></i>Sager</a>
<a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a>
</nav>
</div>
<div class="allocation-banner p-4 mb-4 d-none" id="allocationBanner">
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
<div>
<div class="fw-bold text-warning-emphasis"><i class="bi bi-exclamation-circle-fill me-2"></i>Forbindelsen er ikke tildelt en kunde</div>
<div class="small text-muted mt-1">Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.</div>
<div class="d-flex flex-wrap gap-2 mt-3" id="allocationSuggestions"></div>
</div>
<div style="min-width:min(100%,420px);" class="vstack gap-2">
<select class="form-select" id="allocationCustomerSelect" onchange="loadAllocationSubscriptions()"><option value="">Vælg kunde…</option></select>
<select class="form-select" id="allocationSubscriptionSelect" disabled><option value="">Kun tildel kunde intet abonnement</option></select>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-warning" id="saveAllocationBtn" type="button" onclick="saveConnectionAllocation()">Tildel kunde</button>
<a class="btn btn-outline-secondary d-none" id="allocationCustomerLink" href="#">Åbn kunde</a>
</div>
<div class="small" id="allocationFeedback" role="status"></div>
</div>
</div>
</div>
<div class="alert mb-4" id="slaBanner" role="status">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
<div id="slaBannerContent"></div>
<div class="d-flex gap-2 flex-wrap align-items-center">
<select class="form-select" id="slaSubscriptionSelect" style="min-width:300px"></select>
<button class="btn btn-primary" id="saveSlaBtn" type="button" onclick="saveSlaAllocation()">Gem SLA</button>
</div>
</div>
<div class="small mt-2" id="slaFeedback"></div>
</div>
<div class="detail-metrics-grid mb-4">
<div class="row g-4 mb-4">
<div class="col-6 col-xl-3">
<div class="detail-metric">
<div class="detail-metric-label">Salgspris</div>
@ -405,7 +314,7 @@
<div class="row g-4">
<div class="col-xl-8">
<div class="detail-panel p-4 mb-4" id="connection-core">
<div class="detail-panel p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Grunddata</h5>
<span id="detailStatusBadge" class="status-pill inactive">-</span>
@ -418,7 +327,7 @@
</div>
<div class="col-md-3">
<label class="form-label">Leverandør</label>
<select class="form-select" id="fieldVendorId"><option value="">Ingen valgt</option></select>
<input type="text" class="form-control" id="fieldProvider">
</div>
<div class="col-md-3">
<label class="form-label">Kredsløb</label>
@ -474,13 +383,6 @@
<label class="form-label">Overvågning</label>
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
</div>
<div class="col-12">
<div class="form-check form-switch border rounded-3 p-3 ps-5 bg-light">
<input class="form-check-input" type="checkbox" role="switch" id="fieldManualShared" onchange="toggleManualDelefiber()">
<label class="form-check-label fw-semibold" for="fieldManualShared">BMC Delefiber</label>
<div class="small text-muted">BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.</div>
</div>
</div>
<div class="col-md-3">
<label class="form-label">Allokering</label>
<select class="form-select" id="fieldAllocationModel">
@ -522,7 +424,7 @@
</div>
</div>
<div class="detail-panel p-4 mb-4" id="connection-ip">
<div class="detail-panel p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-0">IP-ranges</h5>
@ -636,16 +538,16 @@
</div>
</div>
<div class="detail-panel p-4 mb-4" id="connection-pricing">
<div class="detail-panel p-4 mb-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Pris og kontrakt</h5>
<div class="small text-muted" id="pricingFeedback"></div>
</div>
<div class="row g-2 mb-3">
<div class="col-md-3"><label class="form-label" for="pricingEffectiveDateInput">Gældende fra</label><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><label class="form-label" for="pricingPurchaseInput">Indkøbspris</label><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><label class="form-label" for="pricingSalesInput">Salgspris</label><input type="number" class="form-control" id="pricingSalesInput" placeholder="0 kr." step="0.01"></div>
<div class="col-md-3"><label class="form-label" for="pricingNotesInput">Note</label><input type="text" class="form-control" id="pricingNotesInput" placeholder="Valgfri note"></div>
<div class="col-md-3"><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div>
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></div>
<div class="col-md-3"><input type="text" class="form-control" id="pricingNotesInput" placeholder="Note"></div>
</div>
<div class="mb-3">
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
@ -654,12 +556,6 @@
<div id="pricingHistoryList"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="delefiberProductPricesPanel">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h5 class="mb-0">BMCnet standardpriser</h5><div class="small text-muted">Priser gælder kun for denne delefiber/adresse.</div></div></div>
<div class="row g-2 mb-2"><div class="col-md-6"><select class="form-select" id="delefiberPriceProduct"></select></div><div class="col-md-3"><input class="form-control" id="delefiberPriceAmount" type="number" min="0" step="0.01" placeholder="Kr./md."></div><div class="col-md-3"><button class="btn btn-primary w-100" type="button" onclick="saveDelefiberProductPrice()">Gem pris</button></div></div>
<div id="delefiberProductPricesList" class="small"></div>
</div>
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
@ -684,15 +580,7 @@
</div>
</div>
<div class="detail-panel p-4 mb-4" id="connection-cases">
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
<div><h5 class="mb-0">Sager på forbindelsen</h5><div class="small text-muted">Kun direkte tilknyttede sager.</div></div>
<a class="btn btn-sm btn-primary" id="createCaseForConnectionPanelBtn" href="/sag/new"><i class="bi bi-plus-lg me-1"></i>Opret sag</a>
</div>
<div id="connectionCasesList"></div>
</div>
<div class="detail-panel p-4" id="connection-history">
<div class="detail-panel p-4">
<h5 class="mb-3">Historik</h5>
<div id="historyList"></div>
</div>
@ -821,7 +709,6 @@
<label class="form-label">Noter</label>
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
</div>
<div class="col-12"><details class="border rounded-3 p-3"><summary class="fw-semibold">Periode og aftalevilkår <span class="text-muted fw-normal">— valgfrit</span></summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Periode start</label><input type="date" class="form-control" id="bmcnetWizardPeriodStart"></div><div class="col-md-4"><label class="form-label">Slutdato</label><input type="date" class="form-control" id="bmcnetWizardEndDate"></div><div class="col-md-4"><label class="form-label">Opsigelsesvarsel</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardNoticeDays" min="0" value="30"><span class="input-group-text">dage</span></div></div><div class="col-md-4"><label class="form-label">Faktureringsretning</label><select class="form-select" id="bmcnetWizardBillingDirection"><option value="forward">Forud</option><option value="backward">Bagud</option></select></div><div class="col-md-4"><label class="form-label">Perioder pr. faktura</label><input type="number" class="form-control" id="bmcnetWizardAdvanceMonths" min="1" value="1"></div><div class="col-md-4"><label class="form-label">Fakturér før perioden</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardLeadMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div><div class="col-md-4"><label class="form-label">Første faktura</label><select class="form-select" id="bmcnetWizardFirstInvoicePolicy"><option value="start_date">På startdato</option><option value="next_cycle">Ved næste cyklus</option></select></div><div class="col-md-4"><label class="form-label">Binding</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardBindingMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div></div></details></div>
</div>
</div>
</div>
@ -945,13 +832,6 @@
}
}
function showDetailMessage(message, isError = false) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`;
feedback.textContent = message;
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
async function extractErrorMessage(response, fallback) {
try {
const payload = await response.clone().json();
@ -1120,12 +1000,11 @@
}
async function loadLookups() {
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse] = await Promise.all([
fetch('/api/v1/customers?limit=1000&is_active=true'),
fetch('/api/v1/internet-connections'),
fetch('/api/v1/internet-connections/subscription-options?status=active'),
fetch('/api/v1/products'),
fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'),
]);
const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
@ -1133,9 +1012,6 @@
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : [];
const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : [];
document.getElementById('fieldVendorId').innerHTML = '<option value="">Ingen valgt</option>' + internetVendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
populateDatalist('customerLookupList', customerOptions);
populateDatalist(
@ -1181,46 +1057,8 @@
)).join('');
}
let delefiberProductPrices = [];
function isDelefiber(connection = currentConnection) {
return Boolean(connection && !connection.parent_id && (connection.is_shared_head || connection.allocation_model === 'shared'));
}
async function loadDelefiberProductPrices() {
const panel = document.getElementById('delefiberProductPricesPanel');
if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
panel.classList.remove('d-none');
const select = document.getElementById('delefiberPriceProduct');
const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
select.innerHTML = '<option value="">Vælg produkt</option>' + priceProducts.map((product) => `<option value="${product.id}">${escapeHtml(product.name || '-')} · global ${formatDKK(product.sales_price || 0)}</option>`).join('');
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
delefiberProductPrices = await response.json();
const list = document.getElementById('delefiberProductPricesList');
list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `<div class="d-flex align-items-center gap-2 border-top py-2"><strong class="flex-grow-1">${escapeHtml(row.product_name || '-')}</strong><span>${formatDKK(row.monthly_price || 0)}/md.</span><button class="btn btn-sm btn-outline-danger" onclick="deleteDelefiberProductPrice(${row.product_id})" title="Fjern"><i class="bi bi-x-lg"></i></button></div>`).join('') : '<div class="text-muted py-2">Ingen lokale standardpriser endnu.</div>';
} catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `<div class="text-danger">${escapeHtml(error.message)}</div>`; }
}
async function saveDelefiberProductPrice() {
const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
const amount = document.getElementById('delefiberPriceAmount').value;
if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
document.getElementById('delefiberPriceAmount').value = '';
await loadDelefiberProductPrices();
}
async function deleteDelefiberProductPrice(productId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
await loadDelefiberProductPrices();
}
function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => !item?.parent_id);
return (connectionOptions || []).filter((item) => item?.is_shared_head);
}
async function loadRangesForSharedHead(sharedHeadId) {
@ -1321,7 +1159,7 @@
: await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => `
<option value="${range.id}" data-prefix="${String(range.cidr || '').split('/')[1] || ''}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
<option value="${range.id}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
`).join('')}`;
hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
@ -1331,10 +1169,8 @@
}
function populateBmcnetWizard(connection) {
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
if (!canCreateBmcnet) return;
const today = new Date().toISOString().slice(0, 10);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
if (!connection?.is_shared_head) return;
document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = '';
@ -1343,15 +1179,7 @@
document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1';
document.getElementById('bmcnetWizardPeriodStart').value = today;
document.getElementById('bmcnetWizardEndDate').value = '';
document.getElementById('bmcnetWizardNoticeDays').value = '30';
document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
document.getElementById('bmcnetWizardLeadMonths').value = '0';
document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
document.getElementById('bmcnetWizardBindingMonths').value = '0';
document.getElementById('bmcnetWizardStartDate').value = today;
document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = '';
@ -1369,7 +1197,7 @@
}
function openBmcnetWizard() {
if (!currentConnection || currentConnection.parent_id) return;
if (!currentConnection?.is_shared_head) return;
populateBmcnetWizard(currentConnection);
if (!bmcnetWizardModal) {
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
@ -1386,21 +1214,7 @@
input.value = '';
return;
}
const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
}
function selectIpProductForPrefix(prefixLength) {
const select = document.getElementById('bmcnetWizardIpProduct');
if (!select || !prefixLength) return;
const product = bmcnetWizardProducts.find((item) => {
const network = parseProductAttributes(item.attributes_json)?.network || {};
return network.kind === 'ip_allocation' && Number(network.ip_prefix_length) === Number(prefixLength);
});
if (!product) return;
select.value = String(product.id);
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
refreshBmcnetWizardAllocationMode();
input.value = option.dataset.price || '';
}
async function createBmcnetConnection() {
@ -1425,15 +1239,7 @@
address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
start_date: document.getElementById('bmcnetWizardStartDate').value,
period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
end_date: document.getElementById('bmcnetWizardEndDate').value || null,
notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
internet_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(document.getElementById('bmcnetWizardInternetPrice').value)
@ -1502,111 +1308,6 @@
}
}
async function renderAllocationBanner(connection) {
const banner = document.getElementById('allocationBanner');
const customerSelect = document.getElementById('allocationCustomerSelect');
const suggestionsWrap = document.getElementById('allocationSuggestions');
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null
&& connection.allocation_model === 'shared'
&& connection.value_type === 'delefiber')
);
const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;
banner.classList.toggle('d-none', !shouldSuggestCustomer);
if (!shouldSuggestCustomer) return;
const payload = await safeJson(
await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`),
{ items: [] }
);
const suggestions = Array.isArray(payload.items) ? payload.items : [];
const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id)));
const suggestedOptions = suggestions.map((item) => (
`<option value="${item.customer_id}">${escapeHtml(item.customer_name || '-')} · ${escapeHtml(item.address || '')}</option>`
)).join('');
const otherOptions = customerOptions
.filter((item) => !suggestedIds.has(Number(item.id)))
.map((item) => `<option value="${item.id}">${escapeHtml(item.name || '-')}</option>`)
.join('');
customerSelect.innerHTML = '<option value="">Vælg kunde…</option>'
+ (suggestedOptions ? `<optgroup label="Forslag fra adressen">${suggestedOptions}</optgroup>` : '')
+ `<optgroup label="Alle kunder">${otherOptions}</optgroup>`;
suggestionsWrap.innerHTML = suggestions.length
? suggestions.map((item) => `
<button class="allocation-suggestion text-start" type="button" onclick="selectAllocationCustomer(${item.customer_id})">
<span class="fw-semibold d-block">${escapeHtml(item.customer_name || '-')}</span>
<span class="small text-muted">${escapeHtml(item.address || '')}${item.location_name ? ` · ${escapeHtml(item.location_name)}` : ''}</span>
</button>`).join('')
: '<span class="small text-muted">Ingen sikre kundematch på adressen. Vælg manuelt i listen.</span>';
document.getElementById('allocationFeedback').textContent = suggestions.length > 1
? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.`
: suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : '';
}
async function selectAllocationCustomer(customerId) {
document.getElementById('allocationCustomerSelect').value = String(customerId);
await loadAllocationSubscriptions();
}
async function loadAllocationSubscriptions() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const select = document.getElementById('allocationSubscriptionSelect');
const customerLink = document.getElementById('allocationCustomerLink');
customerLink.classList.toggle('d-none', !customerId);
customerLink.href = customerId ? `/customers/${customerId}` : '#';
if (!customerId) {
select.disabled = true;
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>';
return;
}
select.disabled = true;
select.innerHTML = '<option value="">Henter abonnementer…</option>';
const subscriptions = await safeJson(
await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}&status=active`),
[]
);
select.innerHTML = '<option value="">Kun tildel kunde intet abonnement</option>'
+ subscriptions.map((item) => `<option value="${item.id}">${escapeHtml(item.subscription_number || `#${item.id}`)} · ${escapeHtml(item.product_name || '-')}</option>`).join('');
select.disabled = false;
document.getElementById('saveAllocationBtn').textContent = subscriptions.length
? 'Tildel kunde / abonnement'
: 'Tildel kunde';
}
async function saveConnectionAllocation() {
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('allocationFeedback');
const button = document.getElementById('saveAllocationBtn');
if (!customerId) {
feedback.className = 'small text-danger';
feedback.textContent = 'Vælg en kunde først.';
return;
}
const payload = { customer_id: customerId };
if (subscriptionId) {
payload.subscription_id = subscriptionId;
payload.value_type = 'subscription';
payload.value_label = null;
}
button.disabled = true;
feedback.className = 'small text-muted';
feedback.textContent = 'Gemmer tildelingen…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.'));
await loadConnection();
} catch (error) {
feedback.className = 'small text-danger';
feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.';
} finally {
button.disabled = false;
}
}
async function loadConnection() {
await loadLookups();
@ -1619,7 +1320,6 @@
pricingHistoryResponse,
contractsResponse,
historyResponse,
casesResponse,
crossFieldPortsResponse,
] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`),
@ -1630,7 +1330,6 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]);
@ -1641,28 +1340,15 @@
}
const connection = await connectionResponse.json();
const ranges = await safeJson(rangesResponse, []);
const addresses = await safeJson(addressesResponse, []);
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
const pricing = await safeJson(pricingResponse, {});
const pricingHistory = await safeJson(pricingHistoryResponse, []);
const contracts = await safeJson(contractsResponse, []);
const history = await safeJson(historyResponse, []);
const cases = await safeJson(casesResponse, []);
const ranges = await rangesResponse.json();
const addresses = await addressesResponse.json();
const summary = await summaryResponse.json();
const pricing = await pricingResponse.json();
const pricingHistory = await pricingHistoryResponse.json();
const contracts = await contractsResponse.json();
const history = await historyResponse.json();
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
const failedSections = [
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
[casesResponse, 'sager'],
].filter(([response]) => !response.ok).map(([, label]) => label);
if (failedSections.length) {
const feedback = document.getElementById('detailSaveFeedback');
feedback.className = 'small text-danger';
feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`;
}
currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : [];
currentRanges = Array.isArray(ranges) ? ranges : [];
@ -1670,35 +1356,15 @@
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
: [];
await renderAllocationBanner(connection);
renderCore(connection, pricing, summary);
renderRanges(currentRanges);
renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory);
renderHistory(history);
renderConnectionCases(cases);
renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren);
await loadDelefiberProductPrices();
renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts);
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
document.getElementById('createCaseForConnectionBtn').href = createCaseUrl;
document.getElementById('createCaseForConnectionPanelBtn').href = createCaseUrl;
}
function renderConnectionCases(cases) {
const list = document.getElementById('connectionCasesList');
if (!Array.isArray(cases) || !cases.length) {
list.innerHTML = '<div class="text-muted">Ingen sager er knyttet direkte til forbindelsen endnu.</div>';
return;
}
list.innerHTML = cases.map(item => `
<a class="d-flex justify-content-between align-items-center gap-3 border rounded-3 p-3 mb-2 text-decoration-none text-reset" href="/sag/${item.id}/v3">
<div><div class="fw-semibold">SAG-${item.id} · ${escapeHtml(item.titel || 'Uden titel')}</div><div class="small text-muted">${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.responsible_name || 'Ikke tildelt')} · ændret ${formatDateTime(item.updated_at)}</div></div>
<span class="badge text-bg-light border">${escapeHtml(item.status || '-')}</span>
</a>`).join('');
}
function renderCrossFieldPorts(payload) {
@ -1740,7 +1406,6 @@
function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
const ownerText = connection.customer_name || 'Ingen kunde koblet på';
const businessText = connection.allocation_model === 'shared'
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
@ -1755,23 +1420,19 @@
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
const totalDown = Number(connection.download_mbps || 0);
const totalUp = Number(connection.upload_mbps || 0);
// Only explicit child allocations count as consumed capacity. A nominal
// connection speed is not proof that bandwidth was allocated to a customer.
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0);
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0);
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0);
const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = childConnections.length
? `${usedDown} Mbps allokeret`
: '0 Mbps allokeret';
document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
: 'Kapacitet ikke registreret';
document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
document.getElementById('metricBandwidthSub').textContent = totalUp
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
: `${downPct}%`;
document.getElementById('ipAddressSummary').innerHTML = `Tilgængelige: <strong>${summary.available || 0}</strong> · Reserverede: <strong>${summary.reserved || 0}</strong> · I brug: <strong>${summary.in_use || 0}</strong>`;
const contractText = connection.contract_start || connection.contract_end
@ -1784,35 +1445,32 @@
? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>`
: '<span class="detail-read-muted">-</span>';
renderSla(connection);
document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip">
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${escapeHtml(connection.provider || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${escapeHtml(connection.circuit_number || '-')}</span></div>
${connection.is_shared_head ? `<div class="detail-read-chip"><span class="label">Netværksmodel</span><span class="value">Delt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}</span></div>` : ''}
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${escapeHtml(connection.connection_type || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${escapeHtml(connection.technology || '-')}</span></div>
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${connection.provider || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${connection.circuit_number || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Binding</span><span class="value">${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${connection.connection_type || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${connection.technology || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Hastighed</span><span class="value">${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps</span></div>
</div>
<div class="detail-read-grid">
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${escapeHtml(connection.name || '-')}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${escapeHtml(connection.address || '-')}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${escapeHtml(connection.customer_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${escapeHtml(connection.parent_name || '-')}</div></div>
<div class="detail-read-row"><span class="label">${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}</span><div class="value">${escapeHtml(subscriptionText)}</div></div>
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${connection.name || '-'}</div></div>
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${connection.address || '-'}</div></div>
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${connection.customer_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${connection.parent_name || '-'}</div></div>
<div class="detail-read-row"><span class="label">Abonnement</span><div class="value">${subscriptionText}</div></div>
<div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</div></div>
<div class="detail-read-row"><span class="label">Nominel hastighed</span><div class="value">${connection.speed_mbps ? `${connection.speed_mbps} Mbps` : '-'}</div></div>
<div class="detail-read-row"><span class="label">Fallback</span><div class="value">${connection.speed_mbps || 0} Mbps</div></div>
<div class="detail-read-row"><span class="label">Overvågning</span><div class="value">${monitoringText}</div></div>
<div class="detail-read-row"><span class="label">SLA</span><div class="value">${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : '<span class="text-danger fw-semibold">Ingen SLA-aftale</span>'}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes ? escapeHtml(connection.notes) : '<span class="detail-read-muted">-</span>'}</div></div>
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes || '<span class="detail-read-muted">-</span>'}</div></div>
</div>
`;
setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || '';
document.getElementById('fieldVendorId').value = connection.vendor_id || '';
document.getElementById('fieldProvider').value = connection.provider || '';
document.getElementById('fieldCircuit').value = connection.circuit_number || '';
document.getElementById('fieldAddress').value = connection.address || '';
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
@ -1826,7 +1484,6 @@
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
document.getElementById('fieldValueType').value = connection.value_type || 'other';
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
document.getElementById('fieldValueLabel').value = connection.value_label || '';
setSubscriptionValue(connection.subscription_id);
document.getElementById('fieldContractStart').value = connection.contract_start || '';
@ -1836,66 +1493,6 @@
toggleCoreEdit(false, true);
}
function renderSla(connection) {
const banner = document.getElementById('slaBanner');
const content = document.getElementById('slaBannerContent');
const select = document.getElementById('slaSubscriptionSelect');
const customerId = Number(connection.customer_id || 0);
const isBmcSharedFiber = Boolean(
connection.is_manual_shared
|| (connection.parent_id == null && connection.allocation_model === 'shared' && connection.value_type === 'delefiber')
);
if (isBmcSharedFiber) {
banner.className = 'd-none';
return;
}
const options = subscriptionOptions.filter((item) =>
Number(item.customer_id) === customerId && /\bsla\b/i.test(String(item.product_name || ''))
);
if (connection.sla_subscription_id && !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) {
options.unshift({
id: connection.sla_subscription_id,
product_name: connection.sla_product_name,
subscription_number: connection.sla_subscription_number,
price: connection.sla_price,
status: connection.sla_status,
});
}
select.innerHTML = '<option value="">Ingen SLA-aftale</option>' + options.map((item) =>
`<option value="${item.id}" ${Number(item.id) === Number(connection.sla_subscription_id) ? 'selected' : ''}>${escapeHtml(item.product_name || 'SLA')} · ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${formatDKK(item.price || 0)}</option>`
).join('');
select.disabled = !customerId;
const hasSla = Boolean(connection.sla_subscription_id);
const priceOk = Number(connection.sla_price || 0) > 0;
const statusOk = connection.sla_status === 'active';
banner.className = `alert mb-4 ${hasSla && priceOk && statusOk ? 'alert-success' : 'alert-warning'}`;
content.innerHTML = hasSla
? `<div class="fw-bold"><i class="bi bi-shield-check me-2"></i>${escapeHtml(connection.sla_product_name || 'SLA-aftale')}</div><div class="small mt-1">${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : '<strong>Prisen skal kontrolleres</strong>'}${statusOk ? '' : ' · <strong>SLA-aftalen er ikke aktiv</strong>'}</div>`
: `<div class="fw-bold"><i class="bi bi-shield-exclamation me-2"></i>Ingen SLA-aftale</div><div class="small mt-1">${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}</div>`;
}
async function saveSlaAllocation() {
const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null;
const feedback = document.getElementById('slaFeedback');
const button = document.getElementById('saveSlaBtn');
button.disabled = true;
feedback.textContent = 'Gemmer SLA-allokering…';
try {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sla_subscription_id: value }),
});
if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.'));
feedback.textContent = 'SLA-allokeringen er gemt.';
await loadConnection();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message;
} finally {
button.disabled = false;
}
}
function toggleCoreEdit(force, silent = false) {
coreEditMode = Boolean(force);
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
@ -1915,38 +1512,27 @@
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
if (!visibleRanges.length && !mismatchedRanges.length) {
list.innerHTML = '<div class="ip-empty-state" style="grid-column:1/-1"><i class="bi bi-hdd-network"></i><strong>Ingen IP-ranges registreret</strong><div class="small text-muted mt-1">Tilføj et CIDR-range eller kontrollér kredsløbsreferencen.</div></div>';
list.innerHTML = '<div class="text-muted">Ingen IP-ranges registreret endnu.</div>';
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return;
}
const visibleMarkup = visibleRanges.map((range) => `
<div class="detail-grid-card ip-range-card" id="rangeCard-${range.id}">
<div class="ip-range-main">
<div class="detail-grid-card" id="rangeCard-${range.id}">
<span class="label">${range.name || 'Range'}</span>
<div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div>
<div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div>
</div>
<div class="ip-range-meta">
<span class="label">Adresser</span>
<div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div>
<div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</div>
</div>
<div class="ip-range-meta">
<span class="label">Reference</span>
<div class="value">${range.provider_reference || '-'}</div>
<div class="small text-muted">Kontrakt ${range.contract_number || '-'}</div>
</div>
<div class="ip-range-meta">
<span class="label">Økonomi</span>
<div class="value">${formatDKK(range.monthly_cost || 0)} kost</div>
<div class="small text-muted">${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}</div>
</div>
<div class="ip-range-actions d-flex gap-2">
<div class="value">${range.cidr || '-'}</div>
<div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div>
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''}
${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `<div class="small text-muted mt-1">Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}</div>` : ''}
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
<div class="mt-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
</div>
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
<div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div>
<div class="d-none" id="rangeEdit-${range.id}">
<div class="range-edit-grid">
<div>
<label class="form-label small text-muted mb-1">Navn</label>
@ -1995,12 +1581,15 @@
`).join('');
const mismatchMarkup = mismatchedRanges.length ? `
<div class="ip-range-warning">
<i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i>
<div>
<div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div>
${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')}
<div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
<span class="label text-danger">Afvigelser</span>
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
${mismatchedRanges.map((range) => `
<div class="small mb-2">
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
</div>
`).join('')}
</div>
` : '';
@ -2072,7 +1661,7 @@
});
if (!filtered.length) {
body.innerHTML = '<tr><td colspan="5"><div class="ip-empty-state my-2"><i class="bi bi-search"></i><strong>Ingen IP-adresser matcher</strong><div class="small text-muted mt-1">Prøv at rydde søgning eller statusfilter.</div></div></td></tr>';
body.innerHTML = '<tr><td colspan="5" class="text-muted py-4">Ingen IP-adresser matcher filtrene.</td></tr>';
return;
}
@ -2324,7 +1913,7 @@
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
const payload = {
name: document.getElementById('fieldName').value.trim(),
vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
provider: document.getElementById('fieldProvider').value.trim() || null,
circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
address: document.getElementById('fieldAddress').value.trim() || null,
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
@ -2340,7 +1929,6 @@
value_type: document.getElementById('fieldValueType').value || 'other',
value_label: document.getElementById('fieldValueLabel').value.trim() || null,
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null,
is_manual_shared: document.getElementById('fieldManualShared').checked,
contract_start: document.getElementById('fieldContractStart').value || null,
contract_end: document.getElementById('fieldContractEnd').value || null,
notes: document.getElementById('fieldNotes').value.trim() || null,
@ -2388,19 +1976,6 @@
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
}
function toggleManualDelefiber() {
const enabled = document.getElementById('fieldManualShared').checked;
if (enabled) {
document.getElementById('fieldAllocationModel').value = 'shared';
document.getElementById('fieldValueType').value = 'delefiber';
} else if (document.getElementById('fieldValueType').value === 'delefiber') {
document.getElementById('fieldAllocationModel').value = 'dedicated';
document.getElementById('fieldValueType').value = 'other';
document.getElementById('fieldValueLabel').value = 'Internetforbindelse';
}
toggleValueFields();
}
async function createRange() {
const feedback = document.getElementById('rangeCreateFeedback');
const button = document.getElementById('createRangeButton');
@ -2464,7 +2039,7 @@
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
};
if (!payload.range_id || !payload.ip_address) {
showDetailMessage('Range og IP-adresse er påkrævet.', true);
alert('Range og IP-adresse er påkrævet');
return;
}
@ -2474,7 +2049,7 @@
body: JSON.stringify(payload),
});
if (!response.ok) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
alert('Kunne ikke oprette IP-adresse');
return;
}
document.getElementById('addressIpInput').value = '';
@ -2515,7 +2090,7 @@
body: JSON.stringify(payload),
});
if (!response.ok) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
alert('Kunne ikke opdatere IP-adressen');
return;
}
editingAddressId = null;
@ -2575,14 +2150,6 @@
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode();
});
document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
const option = event.target.options[event.target.selectedIndex];
selectIpProductForPrefix(option?.dataset?.prefix);
});
document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
// A single selected public address is sold as a Static WAN IP.
if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
});
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode();
});

View File

@ -137,33 +137,6 @@
padding: 1rem;
}
.quick-customer-results {
max-height: 260px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: 0 0 12px 12px;
background: var(--bg-card);
}
.quick-customer-result {
display: block;
width: 100%;
padding: 0.7rem 0.85rem;
border: 0;
border-bottom: 1px solid var(--border-color);
background: transparent;
color: var(--text-primary);
text-align: left;
}
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
.invoice-sync-status {
display:inline-flex;
align-items:center;
@ -200,14 +173,14 @@
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div>
<div class="d-flex gap-2 flex-wrap">
<button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
</a>
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
</button>
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="modal" data-bs-target="#quickSetupModal">
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</button>
</div>
</div>
@ -215,13 +188,10 @@
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
<button class="internet-tab" type="button" id="tabDedicated" onclick="setActiveTab('dedicated')">Dedikerede</button>
<button class="internet-tab" type="button" id="tabUnallocated" onclick="setActiveTab('unallocated')">Ikke allokeret</button>
<button class="internet-tab" type="button" id="tabInvoices" onclick="setActiveTab('invoices')">Behandlede internetfakturaer</button>
</div>
</div>
<div class="row g-3 mb-4" id="connectionsMetrics">
<div class="row g-3 mb-4">
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div>
@ -230,7 +200,7 @@
</div>
<div class="col-6 col-xl-3">
<div class="internet-kpi">
<div class="internet-kpi-label">Aktive / afventer</div>
<div class="internet-kpi-label">Aktive</div>
<div class="internet-kpi-value" id="metricActive">0</div>
</div>
</div>
@ -262,7 +232,7 @@
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div>
<div class="col-lg-2">
<select class="form-select" id="connectionVendorInput"><option value="">Vælg internetleverandør</option></select>
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" />
</div>
<div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
@ -326,14 +296,13 @@
</div>
</div>
<div class="internet-panel p-4 mb-4" id="connectionsOverview">
<div class="internet-panel p-4 mb-4">
<div class="internet-toolbar mb-3">
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter">
<option value="">Alle statusser</option>
<option value="active">Aktive</option>
<option value="pending">Afventer kontrol</option>
<option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option>
@ -380,14 +349,13 @@
</div>
</div>
<div class="internet-panel p-4 mb-4 d-none" id="invoiceProcessingOverview">
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div>
<div class="d-flex gap-2">
<span class="internet-mini align-self-center" id="invoiceReconcileFeedback" role="status"></span>
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option>
<option value="success">Gennemført</option>
@ -396,7 +364,6 @@
<option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option>
</select>
<button class="btn btn-sm btn-outline-primary" id="invoiceReconcileBtn" type="button" onclick="reconcileInvoiceSyncRuns()"><i class="bi bi-arrow-clockwise me-1"></i>Genkontrollér</button>
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div>
</div>
@ -415,82 +382,6 @@
</div>
</div>
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header">
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="quick-type-grid mb-3">
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
</div>
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
<div class="row g-3 d-none mt-1" id="quickSetupFields">
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
<div class="col-12" id="quickCustomerWrap">
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
<input type="hidden" id="quickCustomerId">
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
</div>
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
</div>
</div>
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
</div>
</div>
</div>
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
<table class="table table-sm align-middle">
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
<tbody id="ipNordicPreviewBody"></tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
@ -500,10 +391,6 @@
</div>
<div class="modal-body">
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="showResolvedInvoiceLines" onchange="toggleResolvedInvoiceLines()">
<label class="form-check-label" for="showResolvedInvoiceLines">Vis allerede løste linjer</label>
</div>
<div id="invoiceReviewLines"></div>
</div>
</div>
@ -515,70 +402,6 @@
let invoiceSyncItems = [];
let activeTab = 'all';
let subscriptionOptions = [];
let quickCustomerOptions = [];
let quickProductOptions = [];
let quickCustomerTimer = null;
let quickSetupType = null;
let quickSharedHeadOptions = [];
let ipNordicPreview = null;
let allocationSuggestionsByConnection = new Map();
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'
}[char]));
}
async function runIpNordicImport(commit) {
const input = document.getElementById('ipNordicFileInput');
const feedback = document.getElementById('ipNordicImportFeedback');
const previewButton = document.getElementById('ipNordicPreviewBtn');
const commitButton = document.getElementById('ipNordicCommitBtn');
const file = input.files?.[0];
if (!file) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = 'Vælg først en Excel-fil.';
return;
}
const formData = new FormData();
formData.append('file', file);
formData.append('commit', commit ? 'true' : 'false');
previewButton.disabled = true;
commitButton.disabled = true;
feedback.className = 'small mt-2 text-muted';
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
try {
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
ipNordicPreview = payload;
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
<tr>
<td>${item.action === 'skip' ? `<span class="badge text-bg-secondary">Findes #${item.existing_connection_id}</span>` : '<span class="badge text-bg-success">Ny</span>'}</td>
<td>${escapeHtml(item.reported_company || '-')}<div class="internet-mini">Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer</div></td>
<td>${escapeHtml(item.address || '-')}</td>
<td>${escapeHtml(item.start_date || '-')}</td>
<td class="text-end">${formatDKK(item.monthly_cost)}</td>
<td class="text-end">${formatDKK(item.sales_price)}</td>
</tr>`).join('');
feedback.className = 'small mt-2 text-success';
feedback.textContent = commit
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
if (commit) await loadInternetPage();
} catch (error) {
feedback.className = 'small mt-2 text-danger';
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
} finally {
previewButton.disabled = false;
commitButton.disabled = false;
}
}
function previewIpNordicImport() { return runIpNordicImport(false); }
function commitIpNordicImport() { return runIpNordicImport(true); }
function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
@ -608,19 +431,17 @@
function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
return `<span class="internet-tag">${label}</span>`;
}
function valueBadge(item) {
const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
return `<span class="internet-tag">${label}</span>`;
}
function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet';
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
return '';
}
@ -730,27 +551,6 @@
}
}
async function reconcileInvoiceSyncRuns() {
const button = document.getElementById('invoiceReconcileBtn');
const feedback = document.getElementById('invoiceReconcileFeedback');
button.disabled = true;
feedback.className = 'internet-mini align-self-center text-muted';
feedback.textContent = 'Genkontrollerer…';
try {
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
const payload = await response.json();
feedback.className = 'internet-mini align-self-center text-success';
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
await loadInvoiceSyncRuns();
} catch (error) {
feedback.className = 'internet-mini align-self-center text-danger';
feedback.textContent = error.message || 'Genkontrollen fejlede.';
} finally {
button.disabled = false;
}
}
function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
@ -758,33 +558,14 @@
.join('');
}
function toggleResolvedInvoiceLines() {
const show = document.getElementById('showResolvedInvoiceLines').checked;
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
element.classList.toggle('d-none', !show);
});
}
async function openInvoiceReview(runId) {
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!summaryItem) return;
const linesWrap = document.getElementById('invoiceReviewLines');
linesWrap.innerHTML = '<div class="text-muted py-4">Henter kontrollinjer…</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
if (!response.ok) {
linesWrap.innerHTML = `<div class="text-danger py-4">${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}</div>`;
return;
}
const detail = await response.json();
const item = { ...summaryItem, ...detail };
function openInvoiceReview(runId) {
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
document.getElementById('showResolvedInvoiceLines').checked = false;
const groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line);
@ -796,7 +577,7 @@
<div class="vstack gap-2">
${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number));
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}">
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}">
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
@ -819,6 +600,7 @@
</div>
</section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
}
async function submitInvoiceReview(runId, lineNumber, action) {
@ -863,43 +645,22 @@
if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
if (activeTab === 'dedicated') {
params.set('allocation_model', 'dedicated');
params.set('allocated_only', 'true');
}
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : [];
allocationSuggestionsByConnection = new Map();
if (activeTab === 'unallocated') {
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
const allocationPayload = await safeJson(allocationResponse, { items: [] });
allocationSuggestionsByConnection = new Map(
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
);
}
const total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').length;
const pending = allConnections.filter((item) => item.status === 'pending').length;
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
const focusMetric = activeTab === 'bmcnet'
? ['BMCnet', bmcnetConnections]
: activeTab === 'dedicated'
? ['Dedikerede', total]
: activeTab === 'unallocated'
? ['Uden kunde', total]
: ['Delte hoveder', sharedHeads];
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
document.getElementById('metricShared').textContent = String(focusMetric[1]);
document.getElementById('metricActive').textContent = String(active);
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections);
@ -918,33 +679,23 @@
return;
}
body.innerHTML = connections.map((item) => {
const allocation = allocationSuggestionsByConnection.get(Number(item.id));
const uniqueSuggestion = allocation?.unique_suggestion;
const suggestionCount = Number(allocation?.suggestions?.length || 0);
const isOwnedSharedFiber = Boolean(item.is_shared_head && (item.value_type === 'delefiber' || item.is_manual_shared));
return `
body.innerHTML = connections.map((item) => `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td>
<div class="fw-semibold">${escapeHtml(item.name || '-')}</div>
<div class="fw-semibold">${item.name || '-'}</div>
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${escapeHtml(item.address || '-')}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${escapeHtml(item.parent_name)}</div>` : ''}
<div class="internet-mini">${item.address || '-'}</div>
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''}
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
</td>
<td>
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div>
${!isOwnedSharedFiber && uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
${isOwnedSharedFiber ? '' : item.sla_subscription_id
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
<div>${item.customer_name || '-'}</div>
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
</td>
<td>
<div>${escapeHtml(item.provider || '-')}</div>
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}</div>` : (item.value_label ? `<div class="internet-mini">${escapeHtml(item.value_label)}</div>` : '')}
<div>${item.provider || '-'}</div>
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div>
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')}
</td>
<td>
<div>${formatSpeed(item)}</div>
@ -962,19 +713,7 @@
</a>
</td>
</tr>
`}).join('');
}
async function assignSuggestedCustomer(connectionId, customerId) {
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ customer_id: customerId }),
});
if (!response.ok) {
document.getElementById('pageSummaryText').textContent = await extractErrorMessage(response, 'Kunden kunne ikke tildeles.');
return;
}
await loadInternetPage();
`).join('');
}
async function submitConnectionForm() {
@ -982,7 +721,7 @@
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = {
name: document.getElementById('connectionNameInput').value.trim(),
vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
provider: document.getElementById('connectionProviderInput').value.trim() || null,
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null,
@ -998,8 +737,6 @@
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
&& document.getElementById('connectionValueTypeInput').value === 'delefiber',
};
if (!payload.name) {
@ -1032,7 +769,7 @@
feedback.textContent = 'Forbindelse oprettet.';
[
'connectionNameInput',
'connectionVendorInput',
'connectionProviderInput',
'connectionCustomerIdInput',
'connectionCircuitInput',
'connectionAddressInput',
@ -1074,23 +811,7 @@
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
const invoiceMode = tab === 'invoices';
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
document.getElementById('createConnectionBlock').classList.add('collapse');
document.getElementById('createConnectionBlock').classList.remove('show');
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
if (invoiceMode) loadInvoiceSyncRuns();
else loadInternetPage();
}
function refreshActiveTab() {
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
return loadInternetPage();
loadInternetPage();
}
function toggleCreateValueFields() {
@ -1124,315 +845,6 @@
.join('');
}
async function loadInternetVendors() {
const select = document.getElementById('connectionVendorInput');
try {
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
const vendors = response.ok ? await response.json() : [];
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
const quickVendor = document.getElementById('quickVendor');
if (quickVendor) quickVendor.innerHTML = '<option value="">Vælg leverandør…</option>' + vendors
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
}
}
function quickCustomerLabel(item) {
return `${item.id} · ${item.name || '-'}`;
}
function resolveQuickCustomerId() {
const raw = document.getElementById('quickCustomerLookup').value.trim();
const normalized = raw.toLowerCase();
const match = quickCustomerOptions.find((item) =>
quickCustomerLabel(item).toLowerCase() === normalized
|| String(item.name || '').trim().toLowerCase() === normalized
|| String(item.id) === raw
);
const id = match ? Number(match.id) : null;
document.getElementById('quickCustomerId').value = id || '';
if (match && !document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
}
if (match) {
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
}
updateQuickSetupSummary();
return id;
}
async function searchQuickCustomers(query) {
const q = String(query || '').trim();
const results = document.getElementById('quickCustomerResults');
if (q.length < 2) {
quickCustomerOptions = [];
results.innerHTML = '';
results.classList.add('d-none');
return;
}
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
if (response.status === 401) {
quickCustomerOptions = [];
results.innerHTML = '<div class="p-3 text-danger"><strong>Din session er udløbet.</strong><br><a href="/login">Log ind igen</a> og åbn derefter guiden på ny.</div>';
results.classList.remove('d-none');
return;
}
if (!response.ok) {
quickCustomerOptions = [];
results.innerHTML = `<div class="p-3 text-danger">Kunderne kunne ikke hentes (fejl ${response.status}).</div>`;
results.classList.remove('d-none');
return;
}
quickCustomerOptions = response.ok ? await response.json() : [];
results.innerHTML = quickCustomerOptions.length
? quickCustomerOptions.map((item) => `
<button class="quick-customer-result" type="button" data-quick-customer-id="${item.id}">
<strong>${escapeHtml(item.name || '-')}</strong>
<span class="small text-muted ms-2">${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}</span>
${item.address ? `<div class="small text-muted">${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}</div>` : ''}
</button>`).join('')
: '<div class="small text-muted p-3">Ingen kunder fundet</div>';
results.classList.remove('d-none');
}
function selectQuickCustomer(customerId) {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
if (!customer) return;
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
document.getElementById('quickCustomerId').value = String(customer.id);
document.getElementById('quickCustomerResults').classList.add('d-none');
if (!document.getElementById('quickConnectionName').value.trim()) {
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
}
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
suggestQuickSharedHead();
updateQuickSetupSummary();
}
async function loadQuickProducts() {
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
const products = response.ok ? await response.json() : [];
quickProductOptions = products.filter((item) => {
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
const network = item.attributes_json?.network || {};
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
});
document.getElementById('quickProduct').innerHTML = '<option value="">Vælg internetprodukt…</option>'
+ quickProductOptions.map((item) => `<option value="${item.id}" data-price="${Number(item.sales_price || 0)}">${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}</option>`).join('');
}
function selectQuickSetupType(type) {
quickSetupType = type;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
document.getElementById('quickTypeHint').classList.add('d-none');
document.getElementById('quickSetupFields').classList.remove('d-none');
document.getElementById('quickSetupSubmit').classList.remove('d-none');
const isBmcnet = type === 'bmcnet';
const isShared = type === 'shared';
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
? '<i class="bi bi-check2-circle me-1"></i>Opret BMCnet og abonnement'
: isShared
? '<i class="bi bi-check2-circle me-1"></i>Opret delt hovedfiber'
: '<i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement';
toggleQuickConnectionMode();
}
function toggleQuickConnectionMode() {
const existing = document.getElementById('quickConnectionMode').value === 'existing';
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
updateQuickSetupSummary();
}
async function populateQuickConnections() {
let connections = [];
try {
const response = await fetch('/api/v1/internet-connections');
connections = response.ok ? await response.json() : [];
} catch (error) {
connections = allConnections;
}
document.getElementById('quickExistingConnection').innerHTML = '<option value="">Vælg forbindelse…</option>'
+ connections.filter((item) => !item.subscription_id).map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}</option>`).join('');
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
document.getElementById('quickSharedHead').innerHTML = '<option value="">Vælg hovedforbindelse…</option>'
+ quickSharedHeadOptions.map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}</option>`).join('');
}
function normalizeQuickAddress(value) {
return String(value || '')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function quickEditDistance(left, right) {
const previous = Array.from({length: right.length + 1}, (_, index) => index);
for (let i = 1; i <= left.length; i += 1) {
let diagonal = previous[0];
previous[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const above = previous[j];
previous[j] = Math.min(
previous[j] + 1,
previous[j - 1] + 1,
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
);
diagonal = above;
}
}
return previous[right.length];
}
function quickTokenSimilarity(left, right) {
if (left === right) return 1;
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
}
function quickAddressMatchScore(input, candidate) {
const wanted = normalizeQuickAddress(input);
const existing = normalizeQuickAddress(candidate);
if (!wanted || !existing) return 0;
if (wanted === existing) return 100;
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const existingParts = existing.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
const tokenScore = wantedParts.length
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
: 0;
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
const samePostal = Boolean(wantedPostal && existingNumbers.has(wantedPostal));
const sameHouseNumber = Boolean(wantedHouse && existingNumbers.has(wantedHouse));
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
}
function suggestQuickSharedHead() {
if (quickSetupType !== 'bmcnet') return;
const address = document.getElementById('quickAddress').value.trim();
const select = document.getElementById('quickSharedHead');
const hint = document.getElementById('quickSharedHeadSuggestion');
if (address.length < 4) {
hint.className = 'small text-muted mt-1';
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
return;
}
const matches = quickSharedHeadOptions
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
.filter((match) => match.score >= 55)
.sort((a, b) => b.score - a.score);
if (!matches.length) {
hint.className = 'small text-warning mt-1';
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
return;
}
const best = matches[0];
select.value = String(best.item.id);
hint.className = 'small text-success fw-semibold mt-1';
hint.innerHTML = `<i class="bi bi-check-circle me-1"></i>Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
updateQuickSetupSummary();
}
function updateQuickSetupSummary() {
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const connectionText = existing
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
? `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
: `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
}
async function submitQuickSetup() {
const feedback = document.getElementById('quickSetupFeedback');
const button = document.getElementById('quickSetupSubmit');
const customerId = resolveQuickCustomerId();
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
const existing = document.getElementById('quickConnectionMode').value === 'existing';
const payload = {
customer_id: customerId,
product_id: productId,
start_date: document.getElementById('quickStartDate').value,
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
address: document.getElementById('quickAddress').value.trim() || null,
technology: document.getElementById('quickTechnology').value.trim() || null,
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
monthly_cost: Number(document.getElementById('quickCost').value || 0),
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
billing_interval: document.getElementById('quickInterval').value,
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
shared_fiber: quickSetupType === 'shared',
};
if (!quickSetupType) {
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
return;
}
const missingSubscription = quickSetupType !== 'shared' && (!payload.customer_id || !payload.product_id || !payload.start_date);
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
feedback.textContent = quickSetupType === 'shared'
? 'Skriv et navn til hovedforbindelsen.'
: 'Vælg kunde, produkt, startdato og forbindelse.';
return;
}
button.disabled = true;
feedback.textContent = 'Opretter og forbinder…';
try {
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
if (quickSetupType === 'bmcnet' && !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
const endpoint = quickSetupType === 'bmcnet'
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
: '/api/v1/internet-connections/quick-setup';
const requestPayload = quickSetupType === 'bmcnet' ? {
customer_id: payload.customer_id,
case_title: payload.connection_name,
service_label: payload.connection_name,
address: payload.address,
billing_interval: payload.billing_interval,
billing_day: payload.billing_day,
start_date: payload.start_date,
internet_product_id: payload.product_id,
internet_unit_price: payload.unit_price,
notes: payload.notes,
} : payload;
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
const connectionLink = `<a href="/economy/internet-connections/${result.connection.id}">Åbn forbindelsen</a>`;
const subscriptionLink = result.sag?.id ? ` · <a href="/sag/${result.sag.id}/v3?tab=subscription">Åbn abonnementet</a>` : '';
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
await loadInternetPage();
} catch (error) {
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
} finally {
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage();
@ -1440,36 +852,8 @@
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
});
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
document.getElementById('quickCustomerId').value = '';
clearTimeout(quickCustomerTimer);
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
});
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
const button = event.target.closest('[data-quick-customer-id]');
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
});
document.getElementById('quickProduct').addEventListener('change', (event) => {
const option = event.target.selectedOptions[0];
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
updateQuickSetupSummary();
});
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
quickSetupType = null;
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
document.getElementById('quickTypeHint').classList.remove('d-none');
document.getElementById('quickSetupFields').classList.add('d-none');
document.getElementById('quickSetupSubmit').classList.add('d-none');
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
await loadQuickProducts();
await populateQuickConnections();
toggleQuickConnectionMode();
});
toggleCreateValueFields();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
await loadSubscriptionOptions();
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
});
</script>

View File

@ -2,77 +2,16 @@ import json
import logging
from datetime import date
from typing import Any, Dict, List, Optional
from urllib.parse import quote
import aiohttp
from fastapi import HTTPException
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.core.economic_write_policy import assert_economic_write_allowed
logger = logging.getLogger(__name__)
def _economic_error_message(status: int, response_text: str) -> str:
"""Return a useful, bounded e-conomic validation error without headers or credentials."""
raw = str(response_text or "").strip()
messages: List[str] = []
try:
payload = json.loads(raw)
except (TypeError, ValueError):
payload = None
def collect(value: Any, prefix: str = "") -> None:
if isinstance(value, dict):
for key, child in value.items():
if str(key).lower() in {"developerhint", "logid", "httpstatuscode"}:
continue
collect(child, f"{prefix}.{key}".strip("."))
elif isinstance(value, list):
for child in value:
collect(child, prefix)
elif value not in (None, "") and str(value) not in messages:
label = f"{prefix}: " if prefix else ""
messages.append(f"{label}{value}")
if payload is not None:
collect(payload)
elif raw:
messages.append(raw)
detail = " · ".join(messages)[:1200]
return f"e-conomic afviste ordren ({status})" + (f": {detail}" if detail else "")
def _require_valid_product_numbers(product_numbers: set[str], valid_product_numbers: set[str]) -> List[str]:
"""Reject priced lines before POST when e-conomic cannot resolve their product."""
missing = sorted(product_numbers - valid_product_numbers)
if missing:
raise HTTPException(
status_code=409,
detail=(
"Ordren blev ikke sendt. Følgende varenumre findes ikke i e-conomic: "
+ ", ".join(missing)
+ ". Knyt eller opret varerne under Varer og e-conomic, og prøv igen."
),
)
return missing
def _product_creation_payload(proposal: Dict[str, Any], group_number: int) -> Dict[str, Any]:
payload = {
"productNumber": proposal["product_number"],
"name": str(proposal["name"])[:300],
"description": str(proposal.get("description") or proposal["name"])[:2500],
"salesPrice": proposal["sales_price"],
"barred": False,
"productGroup": {"productGroupNumber": group_number},
}
if proposal.get("ean"):
payload["barCode"] = str(proposal["ean"])[:50]
return payload
class OrdreEconomicExportService:
"""e-conomic export service for global ordre page."""
@ -81,17 +20,8 @@ class OrdreEconomicExportService:
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
configured_fields = getattr(settings, "model_fields_set", set())
self.read_only = (
settings.ORDRE_ECONOMIC_READ_ONLY
if "ORDRE_ECONOMIC_READ_ONLY" in configured_fields
else settings.ECONOMIC_READ_ONLY
)
self.dry_run = (
settings.ORDRE_ECONOMIC_DRY_RUN
if "ORDRE_ECONOMIC_DRY_RUN" in configured_fields
else settings.ECONOMIC_DRY_RUN
)
self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY
self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
@ -127,21 +57,7 @@ class OrdreEconomicExportService:
notes: Optional[str] = None,
layout_number: Optional[int] = None,
user_id: Optional[int] = None,
document_key: Optional[str] = None,
currency: str = 'DKK',
create_missing_products: Optional[Dict[str, int]] = None,
export_reference: Optional[str] = None,
external_id: Optional[int] = None,
) -> Dict[str, Any]:
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
connection = active_connection()
if connection:
if not self._check_write_permission(f'Export ordre for customer {customer_id}') or settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
checked = await preflight(connection, customer_id, lines, layout_number, currency, notes)
return {'success': True, 'dry_run': True, 'message': 'Safety mode: valideret uden ekstern skrivning', 'details': checked}
return await export_document(connection, 'order', document_key or unsaved_key(customer_id, lines, notes, layout_number),
customer_id, lines, layout_number, currency, notes, user_id,
external_id=external_id)
customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,),
@ -165,9 +81,7 @@ class OrdreEconomicExportService:
product_map: Dict[int, str] = {}
if product_ids:
product_rows = execute_query(
"""SELECT id, name, sku_internal, ean, short_description, long_description,
sales_price, economic_product_group_number
FROM products WHERE id = ANY(%s)""",
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
(product_ids,),
) or []
product_map = {
@ -175,13 +89,8 @@ class OrdreEconomicExportService:
for row in product_rows
if row.get("sku_internal")
}
product_details = {int(row["id"]): row for row in product_rows}
else:
product_details = {}
economic_lines: List[Dict[str, Any]] = []
creation_candidates: Dict[str, Dict[str, Any]] = {}
created_product_numbers: List[str] = []
for line in selected_lines:
try:
quantity = float(line.get("quantity") or 0)
@ -214,150 +123,29 @@ class OrdreEconomicExportService:
if product_number:
line_payload["product"] = {"productNumber": str(product_number)}
local_product = product_details.get(int(product_id)) if product_id is not None and str(product_id).isdigit() else None
creation_candidates.setdefault(str(product_number), {
"product_number": str(product_number),
"product_id": int(product_id) if product_id is not None and str(product_id).isdigit() else None,
"name": (local_product or {}).get("name") or line_payload["description"],
"description": (local_product or {}).get("long_description") or (local_product or {}).get("short_description") or line_payload["description"],
"ean": (local_product or {}).get("ean") or line.get("ean"),
"sales_price": float((local_product or {}).get("sales_price") or unit_price),
"suggested_group_number": (local_product or {}).get("economic_product_group_number"),
})
if discount > 0:
line_payload["discountPercentage"] = discount
economic_lines.append(line_payload)
operation = f"Export ordre for customer {customer_id} to e-conomic"
write_allowed = self._check_write_permission(operation)
async with aiohttp.ClientSession() as session:
customer_number = int(customer["economic_customer_number"])
async with session.get(
f"{self.api_url}/customers/{customer_number}",
headers=self._headers(),
timeout=aiohttp.ClientTimeout(total=30),
) as customer_response:
customer_text = await customer_response.text()
if customer_response.status != 200:
logger.error("❌ e-conomic customer lookup failed (%s): %s", customer_response.status, customer_text)
raise HTTPException(
status_code=502,
detail=_economic_error_message(customer_response.status, customer_text),
)
economic_customer = await customer_response.json(content_type=None)
payment_terms = economic_customer.get("paymentTerms")
vat_zone = economic_customer.get("vatZone")
if not payment_terms or not vat_zone:
raise HTTPException(
status_code=502,
detail="e-conomic-kunden mangler betalingsbetingelser eller momszone",
)
product_numbers = {
str(line.get("product", {}).get("productNumber") or "").strip()
for line in economic_lines
if line.get("product")
}
valid_product_numbers = set()
for product_number in product_numbers:
if not product_number:
continue
async with session.get(
f"{self.api_url}/products/{quote(product_number, safe='')}",
headers=self._headers(),
timeout=aiohttp.ClientTimeout(total=30),
) as product_response:
if product_response.status == 200:
valid_product_numbers.add(product_number)
elif product_response.status == 404:
logger.warning("e-conomic product %s does not exist", product_number)
else:
product_text = await product_response.text()
raise HTTPException(
status_code=502,
detail=_economic_error_message(product_response.status, product_text),
)
missing_product_numbers = sorted(product_numbers - valid_product_numbers)
if missing_product_numbers:
async with session.get(
f"{self.api_url}/product-groups?pagesize=1000",
headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30),
) as groups_response:
groups_text = await groups_response.text()
if groups_response.status != 200:
raise HTTPException(502, _economic_error_message(groups_response.status, groups_text))
groups_data = await groups_response.json(content_type=None)
groups = [
{"number": row.get("productGroupNumber"), "name": row.get("name") or ""}
for row in (groups_data.get("collection") or []) if row.get("productGroupNumber") is not None
]
approved_groups = create_missing_products or {}
if any(number not in approved_groups for number in missing_product_numbers):
raise HTTPException(status_code=409, detail={
"code": "economic_products_missing",
"message": "Godkend oprettelse af de manglende varer før ordren eksporteres.",
"products": [creation_candidates[number] for number in missing_product_numbers],
"product_groups": groups,
})
if self.read_only or self.dry_run:
raise HTTPException(409, "Safety mode blokerer oprettelse af varer i e-conomic")
available_groups = {int(group["number"]) for group in groups}
for number in missing_product_numbers:
group_number = int(approved_groups[number])
if group_number not in available_groups:
raise HTTPException(409, f"Varegruppe {group_number} findes ikke i e-conomic")
proposal = creation_candidates[number]
product_payload = _product_creation_payload(proposal, group_number)
assert_economic_write_allowed("POST", "/products")
async with session.post(
f"{self.api_url}/products", headers=self._headers(), json=product_payload,
timeout=aiohttp.ClientTimeout(total=30),
) as create_response:
create_text = await create_response.text()
if create_response.status not in (200, 201):
raise HTTPException(502, _economic_error_message(create_response.status, create_text))
created = await create_response.json(content_type=None)
if str(created.get("productNumber") or "") != number:
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
valid_product_numbers.add(number)
created_product_numbers.append(number)
_require_valid_product_numbers(product_numbers, valid_product_numbers)
customer_layout = economic_customer.get("layout") or {}
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
payload: Dict[str, Any] = {
"date": date.today().isoformat(),
"currency": str(economic_customer.get("currency") or "DKK"),
"customer": {"customerNumber": customer_number},
"paymentTerms": payment_terms,
"recipient": {
"name": str(economic_customer.get("name") or customer.get("name") or "Kunde"),
"address": str(economic_customer.get("address") or ""),
"zip": str(economic_customer.get("zip") or ""),
"city": str(economic_customer.get("city") or ""),
"country": str(economic_customer.get("country") or ""),
"vatZone": vat_zone,
"currency": "DKK",
"customer": {
"customerNumber": int(customer["economic_customer_number"]),
},
"layout": {
"layoutNumber": int(resolved_layout_number),
"layoutNumber": int(layout_number or self.default_layout),
},
"lines": economic_lines,
}
if notes:
payload["notes"] = {"textLine1": str(notes)[:1000]}
if external_id is not None:
payload["references"] = {"other": str(external_id)[:100]}
elif export_reference:
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
payload["notes"] = {"textLine1": str(notes)[:250]}
if not write_allowed:
operation = f"Export ordre for customer {customer_id} to e-conomic"
if not self._check_write_permission(operation):
return {
"success": True,
"dry_run": True,
@ -369,15 +157,13 @@ class OrdreEconomicExportService:
"read_only": self.read_only,
"dry_run": self.dry_run,
"user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"payload": payload,
},
}
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
assert_economic_write_allowed("POST", "/orders/drafts")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.api_url}/orders/drafts",
headers=self._headers(),
@ -389,7 +175,7 @@ class OrdreEconomicExportService:
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
raise HTTPException(
status_code=502,
detail=_economic_error_message(response.status, response_text),
detail=f"e-conomic export fejlede ({response.status})",
)
export_result = await response.json(content_type=None)
@ -406,15 +192,7 @@ class OrdreEconomicExportService:
"customer_name": customer.get("name"),
"selected_line_count": len(selected_lines),
"user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"economic_response": export_result,
"request_payload": payload,
"economic_customer_number": customer_number,
"currency": payload["currency"],
"layout_number": int(resolved_layout_number),
"economic_order_url": export_result.get("self"),
"created_product_numbers": created_product_numbers,
},
}

View File

@ -1,182 +0,0 @@
"""Safe updates and daily read-only lifecycle checks for e-conomic order drafts."""
import json
import logging
from copy import deepcopy
from datetime import datetime, timezone, timedelta
from decimal import Decimal
from uuid import UUID
from fastapi import HTTPException
from psycopg2.extras import Json
from app.core.economic_write_policy import approved_order_draft_update
from app.products.backend.economic_catalog import EconomicClient, RemoteError, query, transaction
from app.products.backend.economic_documents import active_connection
logger = logging.getLogger(__name__)
def _number(value):
try:
return str(Decimal(str(value or 0)).quantize(Decimal('.01')))
except Exception:
return '0.00'
def _editable_snapshot(payload):
return {
'notes': ((payload.get('notes') or {}).get('textLine1') or ''),
'lines': [{
'product_number': ((line.get('product') or {}).get('productNumber')),
'description': line.get('description') or '',
'quantity': _number(line.get('quantity')),
'unit_price': _number(line.get('unitNetPrice')),
'discount_percentage': _number(line.get('discountPercentage')),
} for line in payload.get('lines') or []],
}
def build_update_payload(remote, local_lines, notes):
"""Keep external identity fields and only replace user-editable order content."""
allowed = ('date', 'currency', 'customer', 'paymentTerms', 'recipient', 'layout', 'references')
payload = {key: deepcopy(remote[key]) for key in allowed if key in remote}
remote_lines = remote.get('lines') or []
if len(remote_lines) != len(local_lines):
raise HTTPException(409, 'Antallet af linjer er ændret. Opret en ny ordre for at tilføje eller fjerne varer.')
changed, payload_lines = [], []
for remote_line, local in zip(remote_lines, local_lines):
if not (remote_line.get('product') or {}).get('productNumber'):
raise HTTPException(409, 'En eksisterende e-conomic-linje mangler varenummer og kan ikke opdateres sikkert.')
output = {'product': deepcopy(remote_line['product']),
'description': str(local.get('description') or 'Ordrelinje'),
'quantity': float(local.get('quantity') or 0),
'unitNetPrice': float(local.get('unit_price') or 0),
'discountPercentage': float(local.get('discount_percentage') or 0)}
if remote_line.get('unit'):
output['unit'] = deepcopy(remote_line['unit'])
if output['quantity'] <= 0 or output['unitNetPrice'] < 0 or not 0 <= output['discountPercentage'] <= 100:
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
payload_lines.append(output)
changed.append(dict(local))
payload['lines'] = payload_lines
if notes:
payload['notes'] = {'textLine1': str(notes)[:1000]}
else:
payload.pop('notes', None)
return payload, changed
async def create_update_preview(draft_id, local_lines, notes, actor_id):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row:
raise HTTPException(404, 'Ordren findes ikke')
if not row.get('economic_order_number') or row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Kun en åben e-conomic-ordrekladde kan opdateres')
connection = active_connection()
client = EconomicClient()
if connection:
await client.verify(connection['id'])
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status == 404:
raise HTTPException(409, 'Ordren er ikke længere en åben kladde. Hent status for at finde fakturaen.') from exc
raise HTTPException(502, str(exc)) from exc
proposed, saved_lines = build_update_payload(remote, local_lines, notes)
before, after = _editable_snapshot(remote), _editable_snapshot(proposed)
with transaction() as cur:
cur.execute("UPDATE economic_order_update_requests SET status='expired' WHERE draft_id=%s AND status='pending'", (draft_id,))
cur.execute('''INSERT INTO economic_order_update_requests
(draft_id,economic_order_number,before_snapshot,proposed_payload,proposed_local_lines,requested_by_user_id)
VALUES(%s,%s,%s,%s,%s,%s) RETURNING id,expires_at''',
(draft_id, str(row['economic_order_number']), Json(before), Json(proposed), Json(saved_lines), actor_id))
request_row = cur.fetchone()
return {'request_id': str(request_row['id']), 'expires_at': request_row['expires_at'], 'before': before, 'after': after}
async def apply_update(request_id, draft_id, actor_id):
try:
UUID(str(request_id))
except ValueError as exc:
raise HTTPException(422, 'Ugyldig godkendelsesreference') from exc
row = query('''SELECT r.*,d.sync_status,d.economic_invoice_number FROM economic_order_update_requests r
JOIN ordre_drafts d ON d.id=r.draft_id WHERE r.id=%s AND r.draft_id=%s''', (request_id, draft_id), one=True)
if not row or row['status'] != 'pending' or row['expires_at'] <= datetime.now(timezone.utc):
raise HTTPException(409, 'Godkendelsen er udløbet eller allerede brugt')
if row.get('economic_invoice_number') or row.get('sync_status') in ('posted', 'paid'):
raise HTTPException(409, 'Ordren er faktureret og kan ikke længere opdateres')
client = EconomicClient()
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
if _editable_snapshot(remote) != row['before_snapshot']:
raise HTTPException(409, 'Ordren er ændret i e-conomic siden forhåndsvisningen. Lav en ny forhåndsvisning.')
try:
with approved_order_draft_update():
response = await client.request('PUT', f"orders/drafts/{int(row['economic_order_number'])}", row['proposed_payload'])
except RemoteError as exc:
query("UPDATE economic_order_update_requests SET status='failed',error=%s WHERE id=%s", (str(exc), request_id))
raise HTTPException(502, str(exc)) from exc
with transaction() as cur:
cur.execute("SELECT set_config('bmc.economic_order_update',%s,true)", (str(draft_id),))
cur.execute('''UPDATE ordre_drafts SET lines_json=%s,notes=%s,export_request_json=%s,
export_response_json=%s,last_sync_at=now(),updated_at=now() WHERE id=%s''',
(Json(row['proposed_local_lines']), (row['proposed_payload'].get('notes') or {}).get('textLine1'),
Json(row['proposed_payload']), Json(response), draft_id))
cur.execute("UPDATE economic_order_update_requests SET status='applied',applied_at=now(),response=%s WHERE id=%s", (Json(response), request_id))
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload,created_by_user_id)
VALUES(%s,'economic_draft_updated',%s,%s,%s,%s)''',
(draft_id, row['sync_status'], row['sync_status'], Json({'request_id': str(request_id)}), actor_id))
return {'status': 'updated', 'economic_order_number': row['economic_order_number']}
async def sync_one_draft(draft_id, force=False):
row = query('SELECT * FROM ordre_drafts WHERE id=%s', (draft_id,), one=True)
if not row or not row.get('economic_order_number'):
raise HTTPException(404, 'Ordren har intet e-conomic-ordrenummer')
if not force and row.get('economic_last_checked_at') and row['economic_last_checked_at'] > datetime.now(timezone.utc) - timedelta(days=1):
return {'status': row.get('economic_document_state') or 'unchanged', 'skipped': True}
client = EconomicClient()
marker = 'BMC-HUB:' + str(row.get('economic_export_id') or row.get('export_idempotency_key') or '')
state, invoice, remote = 'draft', None, None
try:
remote = await client.request('GET', f"orders/drafts/{int(row['economic_order_number'])}")
except RemoteError as exc:
if exc.status != 404:
query('UPDATE ordre_drafts SET economic_last_checked_at=now(),economic_status_error=%s WHERE id=%s', (str(exc), draft_id))
raise
booked = await client.collection('invoices/booked')
order_number = str(row['economic_order_number'])
matches = [x for x in booked if (
(x.get('references') or {}).get('other') == marker
or str(x.get('orderNumber') or (x.get('references') or {}).get('orderNumber') or '') == order_number
)]
if len(matches) == 1:
remote, invoice, state = matches[0], str(matches[0].get('bookedInvoiceNumber') or matches[0].get('invoiceNumber')), 'posted'
paid = await client.collection('invoices/paid')
if any(str(x.get('bookedInvoiceNumber') or x.get('invoiceNumber')) == invoice for x in paid):
state = 'paid'
else:
state = 'not_found'
payment = 'paid' if state == 'paid' else ('unpaid' if state == 'posted' else None)
with transaction() as cur:
cur.execute('''UPDATE ordre_drafts SET economic_invoice_number=COALESCE(%s,economic_invoice_number),
sync_status=CASE WHEN %s='paid' THEN 'paid' WHEN %s='posted' THEN 'posted' ELSE sync_status END,
economic_document_state=%s,economic_payment_status=%s,economic_status_response=%s,
economic_status_error=NULL,economic_last_checked_at=now(),last_sync_at=now(),updated_at=now() WHERE id=%s''',
(invoice,state,state,state,payment,Json(remote or {}),draft_id))
if state in ('posted','paid') and state != row.get('sync_status'):
cur.execute('''INSERT INTO ordre_draft_sync_events(draft_id,event_type,from_status,to_status,event_payload)
VALUES(%s,'economic_status_sync',%s,%s,%s)''', (draft_id,row.get('sync_status'),state,Json({'invoice_number':invoice,'payment_status':payment})))
return {'status': state, 'invoice_number': invoice, 'payment_status': payment, 'skipped': False}
async def sync_due_drafts():
rows = query("""SELECT id FROM ordre_drafts WHERE economic_order_number IS NOT NULL
AND sync_status IN ('exported','posted')
AND (economic_last_checked_at IS NULL OR economic_last_checked_at < now()-interval '1 day')
ORDER BY economic_last_checked_at NULLS FIRST LIMIT 100""") or []
for row in rows:
try:
await sync_one_draft(row['id'])
except Exception:
logger.exception('Daglig e-conomic-statuskontrol fejlede for ordre %s', row['id'])
return {'checked': len(rows)}

View File

@ -12,42 +12,7 @@ from app.modules.orders.backend.service import aggregate_order_lines
logger = logging.getLogger(__name__)
router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
"""Normalize legacy and catalog-aware e-conomic export response fields."""
return (
export_result.get("economic_order_number")
or export_result.get("economic_draft_id")
or export_result.get("draftOrderNumber")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
def _snapshot_lines(lines):
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
return snapshot_draft_lines(lines) if active_connection() else lines
async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
from app.products.backend.economic_documents import active_connection
if not active_connection():
return
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
from app.products.backend.economic_pricing import validate_manual_prices
from app.core.database import execute_query_single
actor = await get_current_user(http_request, await security(http_request))
saved = execute_query_single('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (draft_id,)) if draft_id else None
saved_lines = _safe_json_field(saved['lines_json']) if saved and saved['customer_id'] == customer_id else []
validate_manual_prices(lines, customer_id, actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], 'economic.pricing.override'), saved_lines)
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
class OrdreLineInput(BaseModel):
@ -60,9 +25,7 @@ class OrdreLineInput(BaseModel):
discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None
product_id: Optional[int] = None
ean: Optional[str] = None
selected: bool = True
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
class OrdreExportRequest(BaseModel):
@ -72,8 +35,6 @@ class OrdreExportRequest(BaseModel):
layout_number: Optional[int] = None
draft_id: Optional[int] = None
force_export: bool = False
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
create_missing_products: Dict[str, int] = Field(default_factory=dict)
class OrdreDraftUpsertRequest(BaseModel):
@ -88,15 +49,6 @@ class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2)
class EconomicDraftUpdatePreviewRequest(BaseModel):
lines: List[Dict[str, Any]]
notes: Optional[str] = None
class EconomicDraftUpdateConfirmRequest(BaseModel):
request_id: str
def _safe_json_field(value: Any) -> Any:
if value is None:
return None
@ -186,16 +138,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"""Export selected ordre lines to e-conomic draft order."""
try:
user_id = _get_user_id_from_request(http_request)
from app.products.backend.economic_documents import active_connection
if active_connection() or request.create_missing_products:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'):
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic')
user_id = actor['id']
previous_status = None
export_idempotency_key = None
@ -231,18 +173,12 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
)
line_payload = [line.model_dump() for line in request.lines]
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id,
lines=line_payload,
notes=request.notes,
layout_number=request.layout_number,
user_id=user_id,
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
currency=request.currency,
create_missing_products=request.create_missing_products,
export_reference=export_idempotency_key,
external_id=request.draft_id,
)
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
@ -263,22 +199,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"timestamp": datetime.utcnow().isoformat(),
}
economic_order_number = _economic_order_number(export_result)
economic_order_number = (
export_result.get("economic_order_number")
or export_result.get("order_number")
or export_result.get("orderNumber")
)
economic_invoice_number = (
export_result.get("economic_invoice_number")
or export_result.get("invoice_number")
or export_result.get("invoiceNumber")
)
if not export_result.get("dry_run") and not economic_order_number:
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
details = export_result.get("details") or {}
request_payload = details.get("request_payload") or {}
response_payload = details.get("economic_response") or {}
net_amount = sum(
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
* (1 - float(line.get("discountPercentage") or 0) / 100)
for line in (request_payload.get("lines") or [])
)
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query(
@ -289,17 +219,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_number),
economic_export_id = %s,
economic_order_url = %s,
exported_economic_customer_number = %s,
exported_currency = %s,
exported_layout_number = %s,
exported_net_amount = %s,
export_request_json = %s::jsonb,
export_response_json = %s::jsonb,
exported_created_products = %s::jsonb,
last_export_error = NULL,
exported_by_user_id = %s,
last_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
@ -311,16 +230,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None,
str(economic_invoice_number) if economic_invoice_number is not None else None,
export_result.get("export_id") or export_idempotency_key,
details.get("economic_order_url"),
str(details.get("economic_customer_number") or "") or None,
details.get("currency"),
details.get("layout_number"),
net_amount,
json.dumps(request_payload, ensure_ascii=False),
json.dumps(response_payload, ensure_ascii=False),
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
user_id,
request.draft_id,
),
)
@ -335,33 +244,12 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_number,
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
"economic_customer_number": details.get("economic_customer_number"),
"currency": details.get("currency"),
"layout_number": details.get("layout_number"),
"net_amount": net_amount,
"created_product_numbers": details.get("created_product_numbers") or [],
},
user_id,
)
return export_result
except HTTPException as exc:
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
if request.draft_id and not is_preview:
try:
from app.core.database import execute_query
execute_query(
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
(str(exc.detail)[:2000], request.draft_id),
)
_log_sync_event(
request.draft_id, "export_failed", previous_status, previous_status,
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
"idempotency_key": export_idempotency_key}, user_id,
)
except Exception as log_exc:
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
@ -412,13 +300,7 @@ async def list_ordre_drafts(
async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering)."""
try:
query = """SELECT d.*, c.name AS customer_name,
c.economic_customer_number AS current_economic_customer_number,
u.full_name AS exported_by_name
FROM ordre_drafts d
LEFT JOIN customers c ON c.id = d.customer_id
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
WHERE d.id = %s LIMIT 1"""
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
params = (draft_id,)
from app.core.database import execute_query_single
@ -436,41 +318,10 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
actor = await get_current_user(http_request, await security(http_request))
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'):
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
return actor
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import create_update_preview
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
actor = await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import apply_update
return await apply_update(payload.request_id, draft_id, actor['id'])
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
await _require_economic_order_update(http_request)
from app.modules.orders.backend.economic_sync import sync_one_draft
return await sync_one_draft(draft_id, force=True)
@router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft."""
try:
await _check_draft_prices(http_request, request.customer_id, request.lines)
user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query
@ -491,7 +342,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
params = (
request.title,
request.customer_id,
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
user_id,
@ -499,8 +350,6 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
)
result = execute_query(query, params)
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
@ -515,7 +364,6 @@ async def get_ordre_draft_sync_summary(http_request: Request):
query = """
SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
@ -525,7 +373,6 @@ async def get_ordre_draft_sync_summary(http_request: Request):
"""
return execute_query_single(query, ()) or {
"pending_count": 0,
"uncertain_count": 0,
"exported_count": 0,
"failed_count": 0,
"posted_count": 0,
@ -570,13 +417,12 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
from app.core.database import execute_query_single
previous = execute_query_single(
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
(draft_id,)
)
if not previous:
raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower()
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
values.append(draft_id)
from app.core.database import execute_query
@ -679,20 +525,6 @@ async def list_ordre_draft_sync_events(
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft."""
try:
from app.core.database import execute_query_single
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
if not existing:
raise HTTPException(status_code=404, detail="Draft not found")
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
content_changed = (
existing.get('customer_id') != request.customer_id
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
or (existing.get('notes') or None) != (request.notes or None)
or existing.get('layout_number') != request.layout_number
)
if content_changed:
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
from app.core.database import execute_query
query = """
@ -709,7 +541,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht
params = (
request.title,
request.customer_id,
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
json.dumps(request.lines, ensure_ascii=False),
request.notes,
request.layout_number,
draft_id,

View File

@ -344,9 +344,9 @@
const index = line.originalIndex;
const isManual = line.source_type === 'manual';
const descriptionField = isManual
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
onchange="ordreLines[${index}].description = this.value;">`
: escapeHtml(line.description || '-');
const manualActions = isManual
? `

View File

@ -88,8 +88,6 @@
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
<button class="btn btn-outline-primary" onclick="previewPrices()">Kontrollér nye priser</button>
<a class="btn btn-outline-primary" href="/settings/economic-products">Varekoblinger og eksportfejl</a>
</div>
</div>
@ -97,9 +95,8 @@
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
<div class="d-flex justify-content-end mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
@ -118,7 +115,6 @@
<div class="info-item">
<div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div>
</div>
<div class="col-md-3">
@ -149,37 +145,13 @@
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div>
<div id="exportReceiptCard" class="card mb-3 d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
</div></div>
</div>
<div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
</div>
<div class="d-flex gap-2">
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
</button>
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button>
@ -194,7 +166,6 @@
<div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option>
<option value="failed">failed</option>
<option value="posted">posted</option>
@ -205,7 +176,7 @@
<div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div>
<div class="col-md-3" id="invoiceNumberEditWrap">
<div class="col-md-3">
<div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div>
@ -229,19 +200,15 @@
<div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3" id="invoiceNumberViewWrap">
<div class="col-md-3">
<div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div>
</div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div>
<div class="row g-2 align-items-end mb-3">
<div class="col-md-3">
<label class="form-label mb-1">Hændelse</label>
<label class="form-label mb-1">Event type</label>
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div>
<div class="col-md-2">
@ -325,37 +292,6 @@
</div>
</div>
</div>
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Varer oprettes før eksport</h5>
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
<div id="economicProductPreviewRows"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
</div></div>
</div>
</div>
{% endblock %}
@ -368,8 +304,6 @@
let syncEventsOffset = 0;
let syncEventsTotal = 0;
let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast');
@ -388,8 +322,8 @@
detailToast.show();
}
function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
}
function formatDate(dateStr) {
@ -420,7 +354,6 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
return '<span class="badge bg-warning text-dark">pending</span>';
}
@ -433,32 +366,6 @@
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
if (hasReceipt) {
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
const period = orderData.coverage_start && orderData.coverage_end
? `${orderData.coverage_start} ${orderData.coverage_end}`
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
document.getElementById('receiptPeriod').textContent = period;
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
const link = document.getElementById('economicOrderLink');
link.classList.toggle('d-none', !orderData.economic_order_url);
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
}
}
function renderLines() {
@ -471,11 +378,9 @@
tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
const lockLine = isExportedLine && !mayEditExportedDraft;
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">`;
const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported'
@ -501,17 +406,17 @@
<td>${descriptionField}</td>
<td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${lockLine ? 'disabled' : ''}
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${lockLine ? 'disabled' : ''}
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${lockLine ? 'disabled' : ''}
${isExportedLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -597,7 +502,7 @@
const product = data.product;
line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || '';
if (line.unit_price == null && product.sales_price != null) {
if (!Number(line.unit_price || 0) && product.sales_price != null) {
line.unit_price = Number(product.sales_price || 0);
}
if (product.ean) {
@ -637,8 +542,7 @@
discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk',
product_id: null,
economic_product_number: line.product.productNumber || null,
product_id: line.product.productNumber || null,
selected: line.selected !== false,
export_status: line.export_status || null,
};
@ -665,25 +569,13 @@
document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
const hasExported = orderData.last_exported_at ? true : false;
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>'
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
const errorBanner = document.getElementById('exportErrorBanner');
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -774,18 +666,9 @@
}
body.innerHTML = items.map((event) => {
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
let payload = String(event.event_payload || '');
if (event.event_type === 'export_success') {
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
} else if (event.event_type === 'export_attempt') {
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
} else if (event.event_type === 'sync_status_manual_update') {
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
} else if (typeof event.event_payload === 'object') {
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
}
const payload = typeof event.event_payload === 'object'
? JSON.stringify(event.event_payload, null, 2)
: String(event.event_payload || '');
return `
<tr>
@ -793,7 +676,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td>
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
</tr>
`;
}).join('');
@ -878,99 +761,7 @@
}
}
async function previewEconomicUpdate() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
pendingEconomicUpdateId = data.request_id;
const before = data.before || {}, after = data.after || {};
const rows = (after.lines || []).map((line, i) => {
const old = (before.lines || [])[i] || {};
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td style="white-space:pre-line">${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
}).join('');
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')} → ${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();
} catch (err) { showToast(err.message, 'danger'); }
}
async function confirmEconomicUpdate() {
if (!pendingEconomicUpdateId) return;
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
pendingEconomicUpdateId = null;
showToast('e-conomic-ordrekladden er opdateret', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function refreshEconomicStatus() {
try {
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
async function previewPrices() {
try {
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
const preview = await res.json();
if (!res.ok) throw Error(preview.detail || 'Prisberegning fejlede');
const summary = preview.changes.map(x => `${x.description}: ${x.before} → ${x.after}`).join('\n');
if (!preview.changes.length) { showToast('Ingen prisændringer foreslået. Abonnementspriser bevares.', 'info'); return; }
if (!confirm('Genberegn den GEMTE kladde? Ikke-gemte ændringer indgår ikke.\nAbonnementspriser bevares.\n\n'+summary)) return;
const saved = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:preview.token})});
const data = await saved.json();
if (!saved.ok) throw Error(data.detail || 'Kunne ikke gemme priser');
await loadOrder();
} catch (err) { showToast(err.message, 'danger'); }
}
function showEconomicProductPreview(detail) {
pendingEconomicProductPreview = detail;
const groups = detail.product_groups || [];
document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => {
const options = groups.map(group => {
const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : '';
return `<option value="${Number(group.number)}"${selected}>${escapeHtml(group.number)} · ${escapeHtml(group.name)}</option>`;
}).join('');
return `<div class="card mb-3" data-economic-product="${escapeHtml(product.product_number)}">
<div class="card-header d-flex justify-content-between align-items-center">
<strong>Opret produkt ${escapeHtml(product.product_number)}</strong>
<span class="badge bg-success">${formatCurrency(product.sales_price)}</span>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-4"><div class="small text-muted">Navn</div><div>${escapeHtml(product.name || '-')}</div></div>
<div class="col-md-4"><div class="small text-muted">EAN / stregkode</div><div>${escapeHtml(product.ean || '-')}</div></div>
<div class="col-md-4"><label class="small text-muted" for="economicGroup${index}">Varegruppe</label><select id="economicGroup${index}" class="form-select form-select-sm" data-product-group>${options}</select></div>
<div class="col-12"><div class="small text-muted">Beskrivelse</div><div>${escapeHtml(product.description || '-')}</div></div>
</div></div>
</div>`;
}).join('');
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show();
}
async function confirmEconomicProductCreation() {
if (!pendingEconomicProductPreview) return;
const approvals = {};
document.querySelectorAll('[data-economic-product]').forEach(card => {
approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value);
});
bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide();
await exportOrder(approvals);
}
async function exportOrder(createMissingProducts = {}) {
async function exportOrder() {
const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning');
@ -991,18 +782,15 @@
description: line.description,
quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0),
currency: line.currency || 'DKK',
discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk',
product_id: line.product_id || null,
ean: line.ean || null,
selected: true,
})),
notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
};
try {
@ -1013,10 +801,6 @@
});
const data = await res.json();
if (!res.ok) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede');
}

View File

@ -1 +0,0 @@
"""Case planning and capacity module."""

View File

@ -1 +0,0 @@
"""Planner API package."""

View File

@ -1,34 +0,0 @@
"""Provider-neutral, read-only busy-time interface."""
from typing import Protocol
from app.core.database import execute_query
class BusyTimeProvider(Protocol):
def get_busy(self, user_ids: list[int], starts_at, ends_at) -> list[dict]: ...
def statuses(self, user_ids: list[int]) -> list[dict]: ...
class DatabaseBusyTimeProvider:
"""Cache adapter used by Outlook/Google sync jobs without exposing meeting data."""
def get_busy(self, user_ids, starts_at, ends_at):
if not user_ids:
return []
return execute_query(
"""SELECT user_id, starts_at, ends_at, provider FROM planner_external_busy
WHERE user_id = ANY(%s) AND starts_at < %s AND ends_at > %s""",
(user_ids, ends_at, starts_at),
) or []
def statuses(self, user_ids):
if not user_ids:
return []
return execute_query(
"""SELECT user_id, provider, status, last_success_at, last_error_at
FROM planner_integrations WHERE user_id = ANY(%s)""",
(user_ids,),
) or []
busy_time_provider: BusyTimeProvider = DatabaseBusyTimeProvider()

View File

@ -1,460 +0,0 @@
from __future__ import annotations
from datetime import date, datetime, time, timedelta, timezone
from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field, model_validator
from psycopg2.extras import RealDictCursor, Json
from app.core.auth_dependencies import get_current_user
from app.core.database import execute_query, execute_query_single, get_db_connection, release_db_connection
from .providers import busy_time_provider
router = APIRouter(prefix="/planner")
TZ = ZoneInfo("Europe/Copenhagen")
CLOSED_STATUSES = {"afsluttet", "lukket", "closed", "done", "arkiveret", "archived"}
class AllocationInput(BaseModel):
sag_id: int
user_id: int
starts_at: datetime
ends_at: datetime
note: str | None = Field(default=None, max_length=2000)
source: str = Field(default="manual", max_length=40)
confirm_conflicts: bool = False
locked: bool = False
@model_validator(mode="after")
def valid_period(self):
if self.ends_at <= self.starts_at:
raise ValueError("Sluttid skal ligge efter starttid")
return self
class AllocationUpdate(BaseModel):
user_id: int | None = None
starts_at: datetime | None = None
ends_at: datetime | None = None
note: str | None = Field(default=None, max_length=2000)
version: int
confirm_conflicts: bool = False
locked: bool | None = None
class CaseEstimateInput(BaseModel):
estimated_minutes: int | None = Field(default=None, ge=0, le=525600)
class PlannerSettingsInput(BaseModel):
warning_percent: int = Field(ge=1, le=99)
full_percent: int = Field(ge=100, le=300)
default_allocation_minutes: int = Field(ge=15, le=1440)
def _permissions(user: dict) -> set[str]:
return set(user.get("permissions") or [])
def _can_view(user: dict) -> bool:
return bool(user.get("is_superadmin") or "planner.view" in _permissions(user))
def _can_edit(user: dict, target_id: int) -> bool:
if user.get("is_superadmin") or "planner.admin" in _permissions(user):
return True
if target_id == int(user["id"]) and "planner.edit_own" in _permissions(user):
return True
if "planner.edit_team" not in _permissions(user):
return False
return bool(execute_query_single(
"SELECT 1 FROM planner_team_scope WHERE editor_user_id=%s AND target_user_id=%s",
(user["id"], target_id),
))
def _require_view(user: dict):
if not _can_view(user):
raise HTTPException(403, "Du har ikke adgang til Planlæggeren")
def _aware(value: datetime) -> datetime:
return value.replace(tzinfo=TZ) if value.tzinfo is None else value
def _minutes(start, end) -> int:
return max(0, round((end - start).total_seconds() / 60))
def _merge_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
clipped = sorted((max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
merged: list[list[datetime]] = []
for a, b in clipped:
if not merged or a > merged[-1][1]:
merged.append([a, b])
else:
merged[-1][1] = max(merged[-1][1], b)
return sum(_minutes(a, b) for a, b in merged)
def _sum_minutes(intervals: list[tuple[datetime, datetime]], start: datetime, end: datetime) -> int:
"""Count every allocation; overlapping jobs each consume assigned capacity."""
return sum(_minutes(max(a, start), min(b, end)) for a, b in intervals if a < end and b > start)
def _capacity(user_ids: list[int], starts_at: datetime, ends_at: datetime, allocations=None) -> list[dict]:
settings = execute_query_single("SELECT * FROM planner_settings WHERE id=1") or {
"warning_percent": 80, "full_percent": 100
}
schedules = execute_query(
"""SELECT * FROM planner_work_schedules WHERE user_id=ANY(%s) AND active
AND (valid_from IS NULL OR valid_from <= %s) AND (valid_to IS NULL OR valid_to >= %s)""",
(user_ids, ends_at.date(), starts_at.date()),
) or []
absences = execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_absences WHERE user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
external = busy_time_provider.get_busy(user_ids, starts_at, ends_at)
registered = execute_query(
"""SELECT t.medarbejder_id AS user_id,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS registered_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
WHERE t.medarbejder_id=ANY(%s)
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)""",
(user_ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
if allocations is None:
allocations = execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s",
(user_ids, ends_at, starts_at),
) or []
result = []
current = starts_at.astimezone(TZ).date()
final = (ends_at - timedelta(microseconds=1)).astimezone(TZ).date()
schedule_map = {(r["user_id"], r["weekday"]): r for r in schedules}
while current <= final:
for uid in user_ids:
row = schedule_map.get((uid, current.weekday()))
if row:
day_start = datetime.combine(current, row["start_time"], TZ)
day_end = datetime.combine(current, row["end_time"], TZ)
gross = _minutes(day_start, day_end)
unavailable = _merge_minutes(
[( _aware(r["starts_at"]), _aware(r["ends_at"]) ) for r in absences + external if r["user_id"] == uid],
day_start, day_end,
)
available = max(0, gross - int(row["break_minutes"] or 0) - unavailable)
else:
day_start = datetime.combine(current, time(0), TZ)
day_end = day_start + timedelta(days=1)
available = 0
allocated = _sum_minutes(
[(_aware(r["starts_at"]), _aware(r["ends_at"])) for r in allocations if r["user_id"] == uid],
day_start, day_end,
)
percent = round(allocated * 100 / available) if available else (100 if allocated else 0)
registered_minutes = sum(
int(r.get("minutes") or 0) for r in registered
if r["user_id"] == uid and _aware(r["registered_at"]).astimezone(TZ).date() == current
)
state = "gray" if available == 0 else (
"red" if percent >= settings["full_percent"] else
"yellow" if percent >= settings["warning_percent"] else "green"
)
result.append({"user_id": uid, "date": current.isoformat(), "available_minutes": available,
"allocated_minutes": allocated, "percent": percent, "state": state,
"registered_minutes": registered_minutes,
"label": f"{allocated / 60:g} af {available / 60:g} timer planlagt",
"registered_label": f"{registered_minutes / 60:g} timer registreret"})
current += timedelta(days=1)
return result
def _conflicts(user_id, starts_at, ends_at, exclude_id=None) -> tuple[str, dict]:
query = """SELECT id FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s
AND starts_at < %s AND ends_at > %s"""
params: list = [user_id, ends_at, starts_at]
if exclude_id:
query += " AND id <> %s"
params.append(exclude_id)
overlaps = execute_query(query, tuple(params)) or []
prospective = (execute_query(
"SELECT user_id, starts_at, ends_at FROM planner_allocations WHERE deleted_at IS NULL AND user_id=%s AND starts_at<%s AND ends_at>%s" + (" AND id<>%s" if exclude_id else ""),
tuple([user_id, ends_at, starts_at] + ([exclude_id] if exclude_id else [])),
) or []) + [{"user_id": user_id, "starts_at": starts_at, "ends_at": ends_at}]
cap = _capacity([user_id], starts_at, ends_at, prospective)
overbooked = any(x["state"] == "red" and x["allocated_minutes"] > x["available_minutes"] for x in cap)
status = "overlap_and_overbooked" if overlaps and overbooked else "overlap" if overlaps else "overbooked" if overbooked else "none"
return status, {"overlapping_ids": [r["id"] for r in overlaps], "capacity": cap}
def _serialize(row: dict | None):
if not row: return None
return {k: (v.isoformat() if isinstance(v, (datetime, date, time)) else v) for k, v in row.items()}
@router.get("/metadata")
def metadata(current_user: dict = Depends(get_current_user)):
_require_view(current_user)
users = execute_query("SELECT user_id AS id, COALESCE(full_name, username) AS name FROM users WHERE is_active ORDER BY name") or []
settings = execute_query_single("SELECT warning_percent, full_percent, default_allocation_minutes FROM planner_settings WHERE id=1")
departments = execute_query("SELECT id, name FROM groups ORDER BY name") or []
statuses = busy_time_provider.statuses([r["id"] for r in users])
editable_user_ids = [r["id"] for r in users if _can_edit(current_user, r["id"])]
return {"users": users, "departments": departments, "settings": settings, "integration_statuses": statuses,
"current_user_id": current_user["id"], "permissions": list(_permissions(current_user)),
"editable_user_ids": editable_user_ids}
@router.put("/settings")
def update_settings(payload: PlannerSettingsInput, current_user: dict = Depends(get_current_user)):
if not (current_user.get("is_superadmin") or "planner.admin" in _permissions(current_user)):
raise HTTPException(403, "Kun administratorer kan ændre kapacitetsgrænser")
if payload.warning_percent >= payload.full_percent:
raise HTTPException(422, "Gul-grænsen skal ligge under rød-grænsen")
return execute_query_single(
"""UPDATE planner_settings SET warning_percent=%s,full_percent=%s,default_allocation_minutes=%s,
updated_by=%s,updated_at=NOW() WHERE id=1 RETURNING *""",
(payload.warning_percent,payload.full_percent,payload.default_allocation_minutes,current_user["id"]),
)
@router.get("/cases")
def cases(q: str = "", status: list[str] = Query(default=[]), priority: list[str] = Query(default=[]),
case_type: list[str] = Query(default=[]), responsible_id: int | None = None,
department_id: int | None = None,
planning: Literal["all", "unplanned", "partial", "planned"] = "all", mine: bool = False,
urgent: bool = False, sort: Literal["priority", "remaining", "updated"] = "priority",
limit: int = Query(100, ge=1, le=300), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
sql = """SELECT s.id, s.titel AS title, s.status, s.type, s.priority::text, s.ansvarlig_bruger_id AS responsible_id,
(LOWER(COALESCE(s.status,'')) = ANY(%s)) AS is_closed,
COALESCE(u.full_name,u.username) AS responsible_name, c.name AS customer_name,
COALESCE(s.estimated_minutes, 0) AS estimated_minutes,
COALESCE(used.used_minutes, 0) AS used_minutes,
used.latest_registration_at,
COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)::int AS planned_minutes
FROM sag_sager s JOIN customers c ON c.id=s.customer_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id LEFT JOIN planner_allocations a ON a.sag_id=s.id
LEFT JOIN groups department ON department.id=s.assigned_group_id
LEFT JOIN LATERAL (
SELECT COALESCE(SUM(COALESCE(t.faktisk_tid_min,
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))),0)::int AS used_minutes,
MAX(COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at)) AS latest_registration_at
FROM tmodule_times t WHERE t.sag_id=s.id
) used ON TRUE
WHERE s.deleted_at IS NULL
AND NOT (LOWER(COALESCE(s.status,'')) = ANY(%s) AND COALESCE(used.used_minutes,0) > 0)"""
params: list = [list(CLOSED_STATUSES), list(CLOSED_STATUSES)]
if q:
sql += " AND (s.id::text ILIKE %s OR s.titel ILIKE %s OR c.name ILIKE %s OR COALESCE(u.full_name,u.username,'') ILIKE %s)"
params += [f"%{q}%"] * 4
if status: sql += " AND s.status = ANY(%s)"; params.append(status)
if priority: sql += " AND s.priority::text = ANY(%s)"; params.append(priority)
if case_type: sql += " AND s.type = ANY(%s)"; params.append(case_type)
if responsible_id: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(responsible_id)
if department_id: sql += " AND s.assigned_group_id=%s"; params.append(department_id)
if mine: sql += " AND s.ansvarlig_bruger_id=%s"; params.append(current_user["id"])
if urgent: sql += " AND s.priority::text IN ('urgent','high')"
sql += " GROUP BY s.id,u.full_name,u.username,c.name,department.name,used.used_minutes,used.latest_registration_at"
if planning == "unplanned": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)=0"
elif planning == "partial": sql += " HAVING COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)<COALESCE(s.estimated_minutes,0)"
elif planning == "planned": sql += " HAVING COALESCE(s.estimated_minutes,0)>0 AND COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)>=s.estimated_minutes"
order = {"priority": "CASE s.priority::text WHEN 'urgent' THEN 1 WHEN 'high' THEN 2 WHEN 'normal' THEN 3 ELSE 4 END, s.updated_at DESC",
"remaining": "(COALESCE(s.estimated_minutes,0)-COALESCE(SUM(EXTRACT(EPOCH FROM (a.ends_at-a.starts_at))/60) FILTER (WHERE a.deleted_at IS NULL),0)) DESC",
"updated": "s.updated_at DESC"}[sort]
rows = execute_query(sql + f" ORDER BY {order} LIMIT %s", tuple(params + [limit])) or []
for row in rows: row["remaining_minutes"] = max(0, row["estimated_minutes"] - row["planned_minutes"])
return {"items": rows}
@router.get("/cases/{sag_id}/time-entries")
def case_time_entries(sag_id: int, current_user: dict = Depends(get_current_user)):
"""Show actual registered time separately from planned allocations."""
_require_view(current_user)
rows = execute_query(
"""SELECT t.id, t.worked_date, t.start_tid, t.slut_tid,
COALESCE(t.faktisk_tid_min, ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, t.entry_status, t.status,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS employee_name
FROM tmodule_times t LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.sag_id=%s ORDER BY COALESCE(t.slut_tid,t.start_tid,t.worked_date::timestamp,t.created_at) DESC, t.id DESC""",
(sag_id,),
) or []
return {"sag_id": sag_id, "total_minutes": sum(int(r.get("minutes") or 0) for r in rows), "items": rows}
@router.patch("/cases/{sag_id}/estimate")
def update_case_estimate(sag_id: int, payload: CaseEstimateInput, current_user: dict = Depends(get_current_user)):
permissions = _permissions(current_user)
allowed = {"planner.edit_own", "planner.edit_team", "planner.admin", "cases.edit"}
if not (current_user.get("is_superadmin") or permissions.intersection(allowed)):
raise HTTPException(403, "Du må ikke ændre forventet tid")
old = execute_query_single("SELECT id,titel,estimated_minutes FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,))
if not old:
raise HTTPException(404, "Sagen findes ikke")
row = execute_query_single(
"UPDATE sag_sager SET estimated_minutes=%s,updated_at=NOW() WHERE id=%s RETURNING id,estimated_minutes",
(payload.estimated_minutes, sag_id),
)
execute_query(
"""INSERT INTO audit_log(user_id,username,action,resource_type,resource_id,resource_name,before_value,after_value)
VALUES(%s,%s,'planner.estimate.update','case',%s,%s,%s,%s)""",
(current_user["id"], current_user.get("username"), sag_id, old.get("titel"),
str(old.get("estimated_minutes")), str(payload.estimated_minutes)),
)
return row
@router.get("/period")
def period(starts_at: datetime, ends_at: datetime, user_id: list[int] = Query(default=[]), current_user: dict = Depends(get_current_user)):
_require_view(current_user)
if ends_at <= starts_at or ends_at - starts_at > timedelta(days=370): raise HTTPException(400, "Ugyldig periode")
ids = user_id or [current_user["id"]]
allocations = execute_query(
"""SELECT a.*, s.titel AS case_title, s.priority::text AS priority, c.name AS customer_name,
COALESCE(u.full_name,u.username) AS user_name
FROM planner_allocations a JOIN sag_sager s ON s.id=a.sag_id JOIN customers c ON c.id=s.customer_id
JOIN users u ON u.user_id=a.user_id WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s)
AND a.starts_at<%s AND a.ends_at>%s ORDER BY a.starts_at""", (ids, ends_at, starts_at)) or []
time_entries = execute_query(
"""SELECT t.id, t.sag_id, t.medarbejder_id AS user_id,
(t.start_tid IS NOT NULL) AS has_time,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at) AS starts_at,
COALESCE(t.slut_tid,
COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)
+ make_interval(mins => GREATEST(COALESCE(t.faktisk_tid_min,
ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int,15))) AS ends_at,
COALESCE(t.faktisk_tid_min,ROUND(COALESCE(t.approved_hours,t.original_hours,0)*60))::int AS minutes,
t.description, s.titel AS case_title, c.name AS customer_name,
COALESCE(u.full_name,u.username,t.user_name,'Ukendt') AS user_name
FROM tmodule_times t JOIN sag_sager s ON s.id=t.sag_id
JOIN customers c ON c.id=s.customer_id LEFT JOIN users u ON u.user_id=t.medarbejder_id
WHERE t.medarbejder_id=ANY(%s)
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)>=%s
AND COALESCE(t.start_tid,t.worked_date::timestamp,t.created_at)<%s
AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) <> ALL(%s)
ORDER BY starts_at""",
(ids, starts_at, ends_at, list(CLOSED_STATUSES)),
) or []
return {"allocations": allocations, "capacity": _capacity(ids, _aware(starts_at), _aware(ends_at), allocations),
"time_entries": time_entries,
"external_busy": busy_time_provider.get_busy(ids, starts_at, ends_at),
"integration_statuses": busy_time_provider.statuses(ids)}
@router.post("/allocations", status_code=201)
def create_allocation(payload: AllocationInput, current_user: dict = Depends(get_current_user)):
if not _can_edit(current_user, payload.user_id): raise HTTPException(403, "Du må ikke redigere denne medarbejders plan")
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s", (payload.sag_id,))
if not case or case["deleted_at"] or str(case["status"]).lower() in CLOSED_STATUSES: raise HTTPException(409, "Lukkede eller arkiverede sager skal genåbnes før planlægning")
conflict, details = _conflicts(payload.user_id, payload.starts_at, payload.ends_at)
if conflict != "none" and not payload.confirm_conflicts: raise HTTPException(409, detail={"message": "Tiden overlapper eller overbooker", "conflict_status": conflict, **details})
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""INSERT INTO planner_allocations(sag_id,user_id,starts_at,ends_at,note,source,conflict_status,locked,created_by,updated_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *""",
(payload.sag_id,payload.user_id,payload.starts_at,payload.ends_at,payload.note,payload.source,conflict,payload.locked,current_user["id"],current_user["id"]))
row=cur.fetchone(); cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,after_value) VALUES(%s,%s,'created',%s,%s)", (row["id"],row["sag_id"],current_user["id"],Json(_serialize(row))))
conn.commit(); return row
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.patch("/allocations/{allocation_id}")
def update_allocation(allocation_id: int, payload: AllocationUpdate, current_user: dict = Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
target=payload.user_id or old["user_id"]
if not _can_edit(current_user,target): raise HTTPException(403,"Du må ikke redigere denne medarbejders plan")
if old["version"] != payload.version: raise HTTPException(409,detail={"message":"Allokeringen er ændret af en anden bruger","current":_serialize(old)})
moving = bool({"user_id", "starts_at", "ends_at"}.intersection(payload.model_fields_set))
if old.get("locked") and moving and payload.locked is not False:
raise HTTPException(409, "Tidspunktet er låst. Fjern låsen før blokken flyttes eller ændres")
start=payload.starts_at or old["starts_at"]; end=payload.ends_at or old["ends_at"]
if end<=start: raise HTTPException(422,"Sluttid skal ligge efter starttid")
conflict,details=_conflicts(target,start,end,allocation_id)
if conflict!="none" and not payload.confirm_conflicts: raise HTTPException(409,detail={"message":"Tiden overlapper eller overbooker","conflict_status":conflict,**details})
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,conflict_status=%s,locked=%s,
version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s AND version=%s RETURNING *""",
(target,start,end,payload.note if "note" in payload.model_fields_set else old["note"],conflict,payload.locked if payload.locked is not None else old.get("locked",False),current_user["id"],allocation_id,payload.version))
row=cur.fetchone()
if not row: raise HTTPException(409,"Allokeringen blev ændret samtidigt")
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'updated',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
conn.commit(); return row
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.delete("/allocations/{allocation_id}")
def delete_allocation(allocation_id:int, version:int, current_user:dict=Depends(get_current_user)):
conn=get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL FOR UPDATE",(allocation_id,)); old=cur.fetchone()
if not old: raise HTTPException(404,"Allokeringen findes ikke")
if not _can_edit(current_user,old["user_id"]): raise HTTPException(403,"Du må ikke slette denne allokering")
if old["version"]!=version: raise HTTPException(409,"Allokeringen er ændret af en anden bruger")
if old.get("locked"): raise HTTPException(409,"Tidspunktet er låst. Fjern låsen før blokken slettes")
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s WHERE id=%s AND version=%s RETURNING *",(current_user["id"],allocation_id,version)); row=cur.fetchone()
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'deleted',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(old)),Json(_serialize(row))))
conn.commit(); return {"deleted":True,"allocation":row}
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.post("/allocations/{allocation_id}/undo")
def undo_allocation(allocation_id: int, version: int, current_user: dict = Depends(get_current_user)):
"""Undo the latest persisted allocation mutation, guarded by its current version."""
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT * FROM planner_allocations WHERE id=%s FOR UPDATE", (allocation_id,))
current = cur.fetchone()
if not current: raise HTTPException(404, "Allokeringen findes ikke")
if not _can_edit(current_user, current["user_id"]): raise HTTPException(403, "Du må ikke fortryde denne ændring")
if current["version"] != version: raise HTTPException(409, "Kan ikke fortryde: allokeringen er ændret siden")
cur.execute("SELECT * FROM planner_allocation_history WHERE allocation_id=%s ORDER BY id DESC LIMIT 1", (allocation_id,))
history = cur.fetchone()
if not history: raise HTTPException(409, "Der er ingen ændring at fortryde")
before = history.get("before_value")
if history["action"] == "created":
cur.execute("UPDATE planner_allocations SET deleted_at=NOW(),version=version+1,updated_by=%s,updated_at=NOW() WHERE id=%s RETURNING *", (current_user["id"],allocation_id))
elif before:
cur.execute("""UPDATE planner_allocations SET user_id=%s,starts_at=%s,ends_at=%s,note=%s,
conflict_status=%s,locked=%s,deleted_at=%s,version=version+1,updated_by=%s,updated_at=NOW()
WHERE id=%s RETURNING *""",
(before["user_id"],before["starts_at"],before["ends_at"],before.get("note"),before.get("conflict_status","none"),before.get("locked",False),before.get("deleted_at"),current_user["id"],allocation_id))
else: raise HTTPException(409, "Ændringen kan ikke fortrydes")
row=cur.fetchone()
cur.execute("INSERT INTO planner_allocation_history(allocation_id,sag_id,action,changed_by,before_value,after_value) VALUES(%s,%s,'undo',%s,%s,%s)",(row["id"],row["sag_id"],current_user["id"],Json(_serialize(current)),Json(_serialize(row))))
conn.commit(); return row
except HTTPException: conn.rollback(); raise
except Exception: conn.rollback(); raise
finally: release_db_connection(conn)
@router.post("/allocations/{allocation_id}/copy", status_code=201)
def copy_allocation(allocation_id:int, starts_at:datetime,user_id:int|None=None,confirm_conflicts:bool=False,current_user:dict=Depends(get_current_user)):
old=execute_query_single("SELECT * FROM planner_allocations WHERE id=%s AND deleted_at IS NULL",(allocation_id,))
if not old: raise HTTPException(404,"Allokeringen findes ikke")
duration=old["ends_at"]-old["starts_at"]
return create_allocation(AllocationInput(sag_id=old["sag_id"],user_id=user_id or old["user_id"],starts_at=starts_at,ends_at=starts_at+duration,note=old["note"],source="copy",confirm_conflicts=confirm_conflicts),current_user)
@router.get("/cases/{sag_id}/history")
def case_history(sag_id:int,current_user:dict=Depends(get_current_user)):
_require_view(current_user)
return {"items":execute_query("SELECT * FROM planner_allocation_history WHERE sag_id=%s ORDER BY created_at DESC",(sag_id,)) or []}

View File

@ -1 +0,0 @@
"""Planner frontend package."""

View File

@ -1,11 +0,0 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.auth_dependencies import get_current_user
router = APIRouter()
templates = Jinja2Templates(directory="app")
@router.get("/planner", response_class=HTMLResponse)
async def planner(request: Request, current_user: dict = Depends(get_current_user)):
return templates.TemplateResponse("modules/planner/templates/index.html", {"request": request, "current_user": current_user})

File diff suppressed because one or more lines are too long

View File

@ -1,195 +0,0 @@
"""Creation helpers: transactional associations and advisory case lookups."""
import json
import re
from difflib import SequenceMatcher
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, ConfigDict, Field, StrictInt
from psycopg2.extras import Json
from app.core.auth_dependencies import require_any_permission
from app.core.database import execute_query, execute_query_single
read_access = require_any_permission('cases.view', 'tickets.view', 'cases.create', 'tickets.create', 'users.manage', 'system.admin')
admin_access = require_any_permission('users.manage', 'system.admin')
router = APIRouter(prefix='/case-create', dependencies=[Depends(read_access)])
def ids(value, field):
if not isinstance(value, list) or any(isinstance(v, bool) or not isinstance(v, int) or v <= 0 for v in value):
raise HTTPException(400, f'{field} skal være en liste med positive heltal')
return list(dict.fromkeys(value))
def attach_create_relations(cursor, case_id, data, user_id):
hardware_ids = ids(data.get('hardware_ids', []), 'hardware_ids')
tag_ids = ids(data.get('tag_ids', []), 'tag_ids')
if hardware_ids:
cursor.execute('SELECT id FROM hardware_assets WHERE id = ANY(%s) AND deleted_at IS NULL', (hardware_ids,))
if {r['id'] for r in cursor.fetchall()} != set(hardware_ids):
raise HTTPException(400, 'En valgt hardware findes ikke længere')
for hardware_id in hardware_ids:
cursor.execute('INSERT INTO sag_hardware (sag_id, hardware_id) VALUES (%s, %s) ON CONFLICT DO NOTHING', (case_id, hardware_id))
actions = []
if tag_ids:
cursor.execute('SELECT t.id, t.name, t.tag_group_id, g.behavior FROM tags t LEFT JOIN tag_groups g ON g.id=t.tag_group_id WHERE t.id=ANY(%s) AND t.is_active=TRUE', (tag_ids,))
tags = {r['id']: r for r in cursor.fetchall()}
if set(tags) != set(tag_ids):
raise HTTPException(400, 'Et valgt tag findes ikke længere eller er inaktivt')
# Same single/toggle group semantics as the global picker: last choice wins.
chosen = []
for tag_id in tag_ids:
tag = tags[tag_id]
if tag['behavior'] in ('single', 'toggle'):
chosen = [i for i in chosen if tags[i]['tag_group_id'] != tag['tag_group_id']]
chosen.append(tag_id)
for tag_id in chosen:
cursor.execute("INSERT INTO entity_tags (entity_type, entity_id, tag_id, tagged_by) VALUES ('case', %s, %s, %s) ON CONFLICT DO NOTHING", (case_id, tag_id, user_id))
cursor.execute("SELECT action_type, action_config FROM tag_workflows WHERE tag_id=%s AND trigger_event='on_add' AND is_active=TRUE ORDER BY id DESC LIMIT 1", (tag_id,))
action = cursor.fetchone()
if action:
actions.append({'tag': {'id': tag_id, 'name': tags[tag_id]['name']}, 'action': {'type': action['action_type'], 'config': action['action_config'] or {}}, 'entity_type': 'case', 'entity_id': case_id})
return actions
def closed_statuses():
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
try:
configured = json.loads((row or {}).get('value') or '[]')
values = [str(s['value']).strip().lower() for s in configured if isinstance(s, dict) and s.get('is_closed') and s.get('value')]
except (ValueError, TypeError):
values = []
return values or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done']
def title_similarity(left, right):
def normalize(value):
return ' '.join(re.findall(r'\w+', value.casefold()))
left, right = normalize(left), normalize(right)
if not left or not right:
return 0
a, b = set(left.split()), set(right.split())
return max(SequenceMatcher(None, left, right).ratio(), len(a & b) / len(a | b))
@router.get('/duplicates')
def duplicates(customer_id: int, title: str = Query(min_length=5, max_length=1000)):
rows = execute_query("""SELECT s.id,s.titel,s.status,COALESCE(u.full_name,u.username) AS ansvarlig_navn
FROM sag_sager s LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE s.customer_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))""", (customer_id, closed_statuses())) or []
ranked = [(title_similarity(title, r['titel'] or ''), r) for r in rows]
ranked.sort(key=lambda item: (-item[0], -item[1]['id']))
return [dict(row, similarity=round(score, 3)) for score, row in ranked if score >= 0.45][:5]
@router.get('/workload')
def workload(user_id: int):
rows = execute_query("""SELECT s.id,s.titel,s.status,s.deadline,c.name AS customer_name,COUNT(*) OVER() AS total
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.ansvarlig_bruger_id=%s AND s.deleted_at IS NULL AND NOT (LOWER(TRIM(s.status))=ANY(%s))
ORDER BY s.deadline ASC NULLS LAST,s.id DESC LIMIT 10""", (user_id, closed_statuses())) or []
return {'total': rows[0]['total'] if rows else 0, 'items': rows}
@router.get('/contacts-open-cases')
def contacts_open_cases(contact_ids: list[int] = Query(min_length=1, max_length=20)):
"""A small, advisory view used while selecting contacts on a new case."""
contact_ids = ids(contact_ids, 'contact_ids')
rows = execute_query("""WITH ranked AS (
SELECT sc.contact_id, s.id, s.titel, s.status, s.deadline,
COALESCE(u.full_name, u.username, 'Ingen ansvarlig') AS ansvarlig_navn,
COUNT(*) OVER (PARTITION BY sc.contact_id) AS total,
ROW_NUMBER() OVER (PARTITION BY sc.contact_id ORDER BY s.deadline ASC NULLS LAST, s.id DESC) AS position
FROM sag_kontakter sc
JOIN sag_sager s ON s.id=sc.sag_id
LEFT JOIN users u ON u.user_id=s.ansvarlig_bruger_id
WHERE sc.contact_id=ANY(%s) AND sc.deleted_at IS NULL AND s.deleted_at IS NULL
AND NOT (LOWER(TRIM(s.status))=ANY(%s))
) SELECT contact_id,id,titel,status,deadline,ansvarlig_navn,total
FROM ranked WHERE position<=5 ORDER BY contact_id,position""", (contact_ids, closed_statuses())) or []
result = {contact_id: {'total': 0, 'items': []} for contact_id in contact_ids}
for row in rows:
bucket = result[row['contact_id']]
bucket['total'] = row['total']
bucket['items'].append({key: row[key] for key in ('id', 'titel', 'status', 'deadline', 'ansvarlig_navn')})
return result
class PipelineDefaults(BaseModel):
model_config = ConfigDict(extra='forbid')
stage_id: Optional[int] = Field(default=None, gt=0)
amount: Optional[float] = Field(default=None, ge=0, allow_inf_nan=False)
probability: Optional[int] = Field(default=None, ge=0, le=100)
description: Optional[str] = Field(default=None, max_length=10000)
class TemplateValues(BaseModel):
model_config = ConfigDict(extra='forbid')
type: str = Field(default='ticket', min_length=1, max_length=80)
titel: str = Field(default='', max_length=1000)
beskrivelse: str = Field(default='', max_length=50000)
status: str = Field(default='åben', min_length=1, max_length=80)
assigned_group_id: Optional[int] = Field(default=None, gt=0)
tag_ids: list[StrictInt] = Field(default_factory=list, max_length=100)
pipeline: Optional[PipelineDefaults] = None
class CaseTemplate(BaseModel):
model_config = ConfigDict(extra='forbid')
name: str = Field(min_length=1, max_length=120)
icon: str = Field(default='bi-lightning', pattern=r'^bi-[a-z0-9-]+$', max_length=80)
is_active: bool = True
sort_order: int = 0
values: TemplateValues
@router.get('/templates')
def templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates WHERE is_active=TRUE ORDER BY sort_order,name,id') or []
@router.get('/template-options', dependencies=[Depends(admin_access)])
def template_options():
return execute_query('SELECT id,name FROM groups ORDER BY name') or []
@router.get('/templates/admin', dependencies=[Depends(admin_access)])
def admin_templates():
return execute_query('SELECT *, template_values AS "values" FROM case_create_templates ORDER BY sort_order,name,id') or []
def template_args(data):
values = data.values.model_dump()
values['tag_ids'] = ids(values['tag_ids'], 'tag_ids')
if values['tag_ids']:
found = execute_query('SELECT id FROM tags WHERE id=ANY(%s) AND is_active=TRUE', (values['tag_ids'],)) or []
if {r['id'] for r in found} != set(values['tag_ids']):
raise HTTPException(400, 'Ugyldige tags')
if values['assigned_group_id'] and not execute_query_single('SELECT id FROM groups WHERE id=%s', (values['assigned_group_id'],)):
raise HTTPException(400, 'Ugyldig gruppe')
if values['pipeline'] and values['pipeline']['stage_id'] and not execute_query_single('SELECT id FROM pipeline_stages WHERE id=%s', (values['pipeline']['stage_id'],)):
raise HTTPException(400, 'Ugyldig pipeline-stage')
if not data.name.strip():
raise HTTPException(400, 'Navn er påkrævet')
return (data.name.strip(), data.icon, data.is_active, data.sort_order, Json(values))
@router.post('/templates', dependencies=[Depends(admin_access)])
def create_template(data: CaseTemplate):
return execute_query_single('INSERT INTO case_create_templates (name,icon,is_active,sort_order,template_values) VALUES (%s,%s,%s,%s,%s) RETURNING *, template_values AS "values"', template_args(data))
@router.put('/templates/{template_id}', dependencies=[Depends(admin_access)])
def update_template(template_id: int, data: CaseTemplate):
row = execute_query_single('UPDATE case_create_templates SET name=%s,icon=%s,is_active=%s,sort_order=%s,template_values=%s,updated_at=NOW() WHERE id=%s RETURNING *, template_values AS "values"', template_args(data) + (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row
@router.delete('/templates/{template_id}', dependencies=[Depends(admin_access)])
def delete_template(template_id: int):
row = execute_query_single('DELETE FROM case_create_templates WHERE id=%s RETURNING id', (template_id,))
if not row:
raise HTTPException(404, 'Skabelonen findes ikke')
return row

View File

@ -5,13 +5,11 @@ CRUD operations, user preferences, snooze/dismiss functionality
import logging
from typing import List, Optional
import json
from datetime import datetime, timedelta
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.config import settings
from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service
@ -21,94 +19,6 @@ router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
def _ensure_task_list_rules():
execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules (
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL,
times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true,
notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False)
execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False)
def _task_list(user_id: int, include_groups: bool):
group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else ""
params = (user_id, user_id) if include_groups else (user_id,)
return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred')
AND (s.ansvarlig_bruger_id=%s {group_filter})
ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or []
async def _send_task_list(rule, user_id: int):
tasks = _task_list(user_id, bool(rule.get('include_groups')))
if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'}
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/')
def cell(value, fallback=''):
value = str(value or fallback).replace('|', '\\|').replace('\n', ' ')
return value[:120]
lines = [
'| Sag | Kunde | Status | Prioritet | Deadline |',
'| :-- | :-- | :-- | :-- | :-- |',
]
for row in tasks:
title = cell(row.get('titel'), 'Uden titel')
link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)"
deadline = row.get('deadline')
if hasattr(deadline, 'strftime'):
deadline = deadline.strftime('%d.%m.%Y')
lines.append(
f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} "
f"| {cell(row.get('priority'))} | {cell(deadline)} |"
)
result = await reminder_notification_service.send_reminder(
reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None,
reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id],
recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')),
notify_email=False, notify_frontend=True, override_user_preferences=True)
return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result}
async def process_task_list_rules():
_ensure_task_list_rules()
now = datetime.now()
window_start = now - timedelta(minutes=5)
for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []:
times = rule.get('times_json') or []
if isinstance(times, str): times = json.loads(times)
for scheduled_time in times:
try:
hour, minute = (int(value) for value in scheduled_time.split(':', 1))
scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
except (AttributeError, TypeError, ValueError):
logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id'])
continue
last_sent = rule.get('last_sent_at')
if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at):
await _send_task_list(rule, int(rule['user_id']))
execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False)
break
@router.get('/api/v1/reminder-task-rules')
async def list_task_rules(request: Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or []
@router.post('/api/v1/reminder-task-rules')
async def save_task_rule(request: Request, data: dict):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)]
if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt')
rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost)
VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True))))
return rows[0]
@router.post('/api/v1/reminder-task-rules/{rule_id}/send-now')
async def send_task_rule_now(rule_id:int, request:Request):
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id))
if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke')
return await _send_task_list(rule[0],user_id)
# ============================================================================
# Helper Functions

View File

@ -6,10 +6,9 @@ import re
import hashlib
import base64
import html
import math
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional, Dict
from typing import List, Optional, Dict
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
@ -90,23 +89,6 @@ def _get_user_id_from_request(request: Request) -> int:
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
"""Mark a case as internet-related without creating duplicate tags."""
existing = execute_query_single(
"""SELECT id FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
LIMIT 1""",
(sag_id,),
)
if not existing:
execute_query(
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
(sag_id,),
fetch=False,
)
def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = []
seen = set()
@ -437,15 +419,7 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(BaseModel):
type_filters: Optional[List[str]] = None
column_order: Optional[List[str]] = None
hidden_columns: Optional[List[str]] = None
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
)
type_filters: List[str] = Field(default_factory=list)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -1028,34 +1002,15 @@ async def create_sag(request: Request, data: dict):
status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
current_user_id = _get_user_id_from_request(request)
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
_validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline")
order_items = data.get("order_items", [])
raw_contact_ids = data.get("contact_ids") or []
if not isinstance(raw_contact_ids, list):
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
contact_ids = []
for raw_contact_id in raw_contact_ids:
contact_id = _coerce_optional_int(raw_contact_id, "contact_id")
if contact_id and contact_id not in contact_ids:
contact_ids.append(contact_id)
telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
raw_connection_ids = data.get("internet_connection_ids") or []
if not isinstance(raw_connection_ids, list):
raise HTTPException(status_code=400, detail="internet_connection_ids skal være en liste")
internet_connection_ids = []
for raw_connection_id in raw_connection_ids:
connection_id = _coerce_optional_int(raw_connection_id, "internet_connection_id")
if connection_id and connection_id not in internet_connection_ids:
internet_connection_ids.append(connection_id)
pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items") if case_type == "ordre" else []
if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list):
@ -1070,8 +1025,6 @@ async def create_sag(request: Request, data: dict):
if pipeline_values["amount"] not in (None, ""):
try:
pipeline_values["amount"] = float(pipeline_values["amount"])
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
raise ValueError("Invalid amount")
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else:
@ -1126,8 +1079,6 @@ async def create_sag(request: Request, data: dict):
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
cursor.execute(
"""
@ -1138,71 +1089,13 @@ async def create_sag(request: Request, data: dict):
RETURNING *
""",
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
assigned_group_id, current_user_id, 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()
if not result:
raise HTTPException(status_code=500, detail="Failed to create case")
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
if missing_contact_ids:
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
for index, contact_id in enumerate(contact_ids):
cursor.execute(
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
VALUES (%s, %s, 'Kontakt', %s)""",
(result["id"], contact_id, index == 0),
)
cursor.execute(
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, 'Kontakt')
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, data.get("customer_id")),
)
if internet_connection_ids:
cursor.execute(
"""SELECT id FROM internet_connections_connections
WHERE id = ANY(%s) AND deleted_at IS NULL""",
(internet_connection_ids,),
)
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
if missing_connection_ids:
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
for connection_id in internet_connection_ids:
cursor.execute(
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id),
)
cursor.execute(
"""INSERT INTO sag_tags (sag_id, tag_navn)
SELECT %s, 'internet forbindelse'
WHERE NOT EXISTS (
SELECT 1 FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
)""",
(result["id"], result["id"]),
)
if telefoni_opkald_id:
cursor.execute(
"""UPDATE telefoni_opkald
SET sag_id = %s,
kontakt_id = COALESCE(%s, kontakt_id)
WHERE id = %s
RETURNING id""",
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
)
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items:
if has_purchase_columns:
@ -1219,11 +1112,7 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
)
from app.modules.sag.backend.create_support import attach_create_relations
tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
conn.commit()
result = dict(result)
result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"])
return dict(result)
except Exception:
@ -1237,46 +1126,6 @@ async def create_sag(request: Request, data: dict):
logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case")
@router.get("/sag/{sag_id}/internet-connections")
async def list_sag_internet_connections(sag_id: int):
return execute_query(
"""SELECT link.connection_id,link.created_at,ic.name,ic.circuit_number,ic.provider_reference,
ic.address,ic.customer_id
FROM sag_internet_connections link
JOIN internet_connections_connections ic ON ic.id=link.connection_id AND ic.deleted_at IS NULL
WHERE link.sag_id=%s ORDER BY link.created_at DESC""", (sag_id,),
) or []
@router.post("/sag/{sag_id}/internet-connections", dependencies=[Depends(case_edit_access)])
async def link_sag_internet_connection(sag_id: int, request: Request, data: dict):
connection_id = _coerce_optional_int(data.get("connection_id"), "connection_id")
if not connection_id:
raise HTTPException(status_code=400, detail="connection_id er påkrævet")
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Internetforbindelsen findes ikke")
execute_query(
"""INSERT INTO sag_internet_connections (sag_id,connection_id,linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
)
_ensure_case_internet_connection_tag(sag_id)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@router.delete("/sag/{sag_id}/internet-connections/{connection_id}", dependencies=[Depends(case_edit_access)])
async def unlink_sag_internet_connection(sag_id: int, connection_id: int):
execute_query(
"DELETE FROM sag_internet_connections WHERE sag_id=%s AND connection_id=%s",
(sag_id, connection_id), fetch=False,
)
return {"unlinked": True}
@router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int):
"""Get a specific case."""
@ -1408,14 +1257,16 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request)
try:
has_columns = table_has_column("user_sag_list_preferences", "column_order")
select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
rows = execute_query(
f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
"""
SELECT type_filters
FROM user_sag_list_preferences
WHERE user_id = %s
""",
(user_id,),
) or []
if not rows:
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
return {"type_filters": []}
raw = rows[0].get("type_filters")
parsed = []
@ -1438,43 +1289,20 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item)
normalized.append(item)
raw_order = rows[0].get("column_order") if has_columns else None
raw_hidden = rows[0].get("hidden_columns") if has_columns else None
if isinstance(raw_order, str):
try: raw_order = json.loads(raw_order)
except Exception: raw_order = []
if isinstance(raw_hidden, str):
try: raw_hidden = json.loads(raw_hidden)
except Exception: raw_hidden = []
order = [str(key) for key in (raw_order or []) if str(key) in SAG_LIST_COLUMN_KEYS]
order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in order)
hidden = [str(key) for key in (raw_hidden or []) if str(key) in SAG_LIST_COLUMN_KEYS]
return {"type_filters": normalized, "column_order": order, "hidden_columns": hidden}
return {"type_filters": normalized}
except Exception as e:
if "user_sag_list_preferences" in str(e):
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
return {"type_filters": []}
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences")
@router.get("/sag/me/quick-filter-context")
async def get_my_sag_quick_filter_context(request: Request):
"""Return the authenticated employee and their groups for list quick filters."""
user_id = _get_user_id_from_request(request)
rows = execute_query(
"SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
(user_id,),
) or []
return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
@router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request)
try:
normalized = []
seen = set()
existing = await get_my_sag_list_preferences(request)
for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
for value in payload.type_filters or []:
item = str(value or "").strip().lower()
if not item or item in seen:
continue
@ -1483,31 +1311,18 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item)
normalized.append(item)
requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
if not table_has_column("user_sag_list_preferences", "column_order"):
raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
execute_query(
"""
INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (user_id)
DO UPDATE SET
type_filters = EXCLUDED.type_filters,
column_order = EXCLUDED.column_order,
hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW()
""",
(user_id, json.dumps(normalized), json.dumps(column_order), json.dumps(hidden_columns)),
(user_id, json.dumps(normalized)),
)
return {"type_filters": normalized, "column_order": column_order, "hidden_columns": hidden_columns}
except HTTPException:
raise
return {"type_filters": normalized}
except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences")
@ -1724,7 +1539,6 @@ async def delete_todo_step(step_id: int):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case."""
try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists
check = execute_query(
"""
@ -1746,23 +1560,6 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
(sag_id,),
) or {}
if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
raise HTTPException(
status_code=409,
detail={
"code": "close_without_time_confirmation_required",
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates:
@ -2613,28 +2410,6 @@ async def list_case_contacts(sag_id: int):
logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts")
@router.get("/sag/{sag_id}/reply-recipients")
async def list_case_reply_recipients(sag_id: int, q: str = ""):
"""Email-capable case contacts first, followed by contacts from the case customer."""
rows = execute_query(
"""
WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
UNION
SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
) SELECT DISTINCT ON (id) * FROM candidates
WHERE NULLIF(TRIM(email),'') IS NOT NULL
AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
ORDER BY id, linked DESC
""",
(sag_id, sag_id, q.strip(), q.strip()),
) or []
return rows
@router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case."""
@ -3302,44 +3077,6 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.get("/procurement/overview")
async def procurement_overview():
"""Operational queue for purchase lines awaiting order, receipt or delivery."""
rows = execute_query(
"""
SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
p.status, p.line_date, p.external_ref, p.purchase_purpose,
p.supplier_invoice_id, p.supplier_invoice_line_id,
s.titel AS case_title, c.name AS customer_name,
EXISTS (
SELECT 1 FROM sag_salgsvarer sale
WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
AND sale.status <> 'cancelled'
) AS has_sales_line,
CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
WHEN p.status = 'confirmed' THEN 'ordered'
ELSE 'to_order'
END AS fulfilment_state
FROM sag_salgsvarer p
JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled'
ORDER BY CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
p.line_date NULLS LAST, p.id DESC
"""
) or []
counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
for row in rows:
state = row.get("fulfilment_state") or "to_order"
counts[state] = counts.get(state, 0) + 1
if not row.get("has_sales_line"):
counts["missing_sales_order"] += 1
return {"items": rows, "counts": counts}
@router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case."""
@ -3732,100 +3469,6 @@ async def get_kommentarer(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to get comments")
def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]:
"""Return non-billable source activities linked directly to cases."""
if not case_ids:
return []
placeholders = ",".join(["%s"] * len(case_ids))
activities: List[Dict[str, Any]] = []
if _table_exists("telefoni_opkald"):
call_query = f"""
SELECT t.id, t.sag_id, t.direction, t.ekstern_nummer, t.intern_extension,
t.started_at, t.ended_at, t.duration_sec,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name,
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
WHERE t.sag_id IN ({placeholders})
ORDER BY t.started_at DESC, t.id DESC
LIMIT 600
"""
for row in execute_query(call_query, tuple(case_ids)) or []:
started_at = row.get("started_at")
if not started_at:
continue
direction = str(row.get("direction") or "inbound").lower()
duration_seconds = row.get("duration_sec")
if duration_seconds is not None and not 0 <= float(duration_seconds) <= 43200:
duration_seconds = None
if duration_seconds is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime):
calculated_seconds = int((row["ended_at"] - started_at).total_seconds())
duration_seconds = calculated_seconds if 0 <= calculated_seconds <= 43200 else None
activities.append({
"id": f"call:{row.get('id')}",
"source_id": row.get("id"),
"activity_type": "call",
"sag_id": row.get("sag_id"),
"timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at),
"ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"),
"duration_minutes": round(float(duration_seconds or 0) / 60, 1),
"actor_name": str(row.get("actor_name") or "Ukendt medarbejder"),
"contact_name": row.get("contact_name"),
"number": row.get("ekstern_nummer"),
"direction": direction,
"title": "Udgående opkald" if direction == "outbound" else "Indgående opkald",
"status": "completed" if row.get("ended_at") else "started",
})
if _table_exists("anydesk_sessions"):
anydesk_query = f"""
SELECT a.id, a.anydesk_session_id, a.sag_id, a.started_at, a.ended_at,
a.duration_minutes, a.status,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name,
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name
FROM anydesk_sessions a
LEFT JOIN users u ON u.user_id = a.created_by_user_id
LEFT JOIN contacts c ON c.id = a.contact_id
WHERE a.sag_id IN ({placeholders})
ORDER BY a.started_at DESC, a.id DESC
LIMIT 600
"""
for row in execute_query(anydesk_query, tuple(case_ids)) or []:
started_at = row.get("started_at")
if not started_at:
continue
duration_minutes = row.get("duration_minutes")
if duration_minutes is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime):
duration_minutes = max(0, round((row["ended_at"] - started_at).total_seconds() / 60, 1))
activities.append({
"id": f"anydesk:{row.get('id')}",
"source_id": row.get("id"),
"external_id": row.get("anydesk_session_id"),
"activity_type": "anydesk",
"sag_id": row.get("sag_id"),
"timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at),
"ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"),
"duration_minutes": float(duration_minutes or 0),
"actor_name": str(row.get("actor_name") or "Ukendt medarbejder"),
"contact_name": row.get("contact_name"),
"title": "AnyDesk-session",
"status": str(row.get("status") or "unknown"),
})
activities.sort(key=lambda item: str(item.get("timestamp") or ""), reverse=True)
return activities
@router.get("/sag/{sag_id}/linked-activities")
async def get_case_linked_activities(sag_id: int):
_assert_sag_exists(sag_id)
activities = _get_case_linked_activities([sag_id])
return {"activities": activities, "total": len(activities)}
@router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""Return a unified timeline for the case with optional child cases."""
@ -3933,27 +3576,6 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
events = []
for activity in _get_case_linked_activities(case_ids):
duration = float(activity.get("duration_minutes") or 0)
duration_text = f"{duration:g} min" if duration > 0 else "Varighed ikke registreret"
contact = str(activity.get("contact_name") or activity.get("number") or "Ukendt kontakt")
activity_type = str(activity.get("activity_type") or "activity")
description = f"{contact}\n{duration_text}"
if activity_type == "call" and activity.get("number") and activity.get("contact_name"):
description = f"{contact} · {activity.get('number')}\n{duration_text}"
events.append({
"id": activity.get("id"),
"event_type": activity_type,
"event_subtype": activity.get("direction") or activity.get("status"),
"source": "call" if activity_type == "call" else "anydesk",
"timestamp": activity.get("timestamp"),
"sag_id": activity.get("sag_id"),
"sag_titel": next((row.get("titel") for row in case_rows if row.get("id") == activity.get("sag_id")), None),
"forfatter": activity.get("actor_name") or "System",
"title": activity.get("title"),
"description": description,
})
case_details_query = f"""
SELECT s.id, s.titel, s.created_at,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name

View File

@ -1,500 +1,118 @@
import json
import logging
import re
from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import execute_query, execute_query_single
from app.core.database import execute_query
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
from app.services.case_analysis_service import CaseAnalysisService
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")
VISIBILITIES = {"internal", "general", "customer"}
APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
SECRET_PATTERNS = (
(re.compile(r"(?i)\b(password|passwd|kodeord|api[_ -]?key|secret|token)\s*[:=]\s*([^\s,;]+)"), r"\1: [FJERNET]"),
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"), "Bearer [FJERNET]"),
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[PRIVAT NØGLE FJERNET]"),
(re.compile(r"(?i)(?:postgres|mysql|mongodb(?:\+srv)?)://[^\s]+"), "[DATABASEFORBINDELSE FJERNET]"),
)
def _user_id(current_user: dict) -> Optional[int]:
value = current_user.get("id") or current_user.get("user_id")
return int(value) if value is not None else None
def _clean_list(values) -> list[str]:
cleaned, seen = [], set()
for value in values or []:
item = str(value or "").strip()
key = item.casefold()
if item and key not in seen:
seen.add(key)
cleaned.append(item[:100])
return cleaned[:30]
def _normalize_payload(data: dict) -> dict:
if "title" in data:
data["title"] = str(data.get("title") or "").strip()
if not data["title"]:
raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
if "visibility" in data:
data["visibility"] = str(data.get("visibility") or "internal").lower()
if data["visibility"] not in VISIBILITIES:
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
if "approval_status" in data:
data["approval_status"] = str(data.get("approval_status") or "draft").lower()
if data["approval_status"] not in APPROVAL_STATUSES:
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
if data.get("result") is not None:
raw = str(data["result"]).strip()
data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
if data.get("solution_type") is not None:
raw = str(data["solution_type"]).strip()
data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
for field in ("tags", "products"):
if field in data:
data[field] = _clean_list(data[field])
return data
def _redact_sensitive(text: str) -> tuple[str, list[str]]:
cleaned = str(text or "")
warnings = []
for pattern, replacement in SECRET_PATTERNS:
cleaned, count = pattern.subn(replacement, cleaned)
if count:
warnings.append(f"{count} mulig(e) hemmelighed(er) blev fjernet før AI-behandling")
return cleaned, warnings
def _knowledge_tokens(text: str) -> list[str]:
ignored = {"eller", "ikke", "med", "den", "det", "der", "som", "for", "fra", "til", "har", "kan", "skal", "sag", "sagen", "test", "viden"}
tokens, seen = [], set()
for token in re.findall(r"[A-Za-zÀ-ÿ0-9_.-]{3,}", str(text or "").lower()):
if token in ignored or token in seen:
continue
seen.add(token)
tokens.append(token)
return tokens[:24]
def _normalize_source_refs(raw_refs, allowed_refs: set[str], sag_id: int) -> list[str]:
if isinstance(raw_refs, str):
raw_refs = re.split(r"[,;\n]+", raw_refs)
normalized = [f"Sag {sag_id}"]
for raw in raw_refs or []:
value = str(raw or "").strip().strip("[]")
match = re.search(r"(?i)\b(sag|kommentar|mail|tid|artikel)\s*#?\s*(\d+)\b", value)
if not match:
continue
kind = match.group(1).capitalize()
canonical = f"{kind} {int(match.group(2))}"
if canonical in allowed_refs and canonical not in normalized:
normalized.append(canonical)
return normalized
def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
snapshot = dict(solution)
for key, value in list(snapshot.items()):
if hasattr(value, "isoformat"):
snapshot[key] = value.isoformat()
execute_query(
"INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
(solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
)
def _case_knowledge_context(sag_id: int, limit: int = 6) -> tuple[dict, list[dict]]:
case = execute_query_single(
"""SELECT s.id,s.titel,s.beskrivelse,s.status,
(SELECT sk.customer_id FROM sag_kunder sk WHERE sk.sag_id=s.id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1) AS customer_id,
COALESCE((SELECT string_agg(t.name,' ') FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=s.id),'') AS tag_text
FROM sag_sager s WHERE s.id=%s AND s.deleted_at IS NULL""",
(sag_id,),
)
if not case:
raise HTTPException(status_code=404, detail="Sagen findes ikke")
tokens = _knowledge_tokens(" ".join([str(case.get("titel") or ""), str(case.get("beskrivelse") or ""), str(case.get("tag_text") or "")]))
if not tokens:
return case, []
search_query = " OR ".join(tokens)
articles = execute_query(
"""SELECT ka.id,ka.title,ka.summary,ka.problem,ka.root_cause,ka.solution,ka.workaround,
ka.visibility,ka.customer_id,ka.tags,ka.products,ka.sag_id,ka.updated_at,
ts_rank_cd(ka.search_document,websearch_to_tsquery('simple',%s)) AS relevance
FROM knowledge_articles ka
WHERE ka.status='published' AND ka.archived_at IS NULL
AND (ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))
AND ka.search_document @@ websearch_to_tsquery('simple',%s)
ORDER BY relevance DESC,ka.updated_at DESC LIMIT %s""",
(search_query, case.get("customer_id"), search_query, limit),
) or []
if articles:
top_relevance = float(articles[0].get("relevance") or 0)
relative_floor = max(0.05, top_relevance * 0.35)
articles = [row for row in articles if float(row.get("relevance") or 0) >= relative_floor]
return case, articles
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
return result[0] if result else None
@router.get("/sag/{sag_id}/solution/versions")
async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
if not solution:
return {"items": [], "total": 0}
items = execute_query(
"""SELECT v.id, v.version_number, v.change_note, v.created_at,
COALESCE(u.full_name, u.username) AS changed_by
FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id
WHERE v.solution_id=%s ORDER BY v.version_number DESC""",
(solution["id"],),
) or []
return {"items": items, "total": len(items)}
@router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,))
if existing:
detail = "Sagen har en arkiveret løsning, som skal gendannes" if existing.get("deleted_at") else "Der findes allerede en løsning på sagen"
raise HTTPException(status_code=409, detail=detail)
data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
user_id = _user_id(current_user)
result = execute_query(
"""INSERT INTO sag_solutions (
sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
(sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
)
created = result[0]
_version_solution(created, user_id, "Løsning oprettet")
return created
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
data = _normalize_payload(updates.model_dump(exclude_unset=True))
change_note = data.pop("change_note", None)
allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
fields, params = [], []
for key, value in data.items():
if key not in allowed:
continue
fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
params.append(json.dumps(value) if key in {"tags", "products"} else value)
if not fields:
raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
user_id = _user_id(current_user)
fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
params.extend([user_id, sag_id])
updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
_version_solution(updated, user_id, change_note or "Løsning redigeret")
return updated
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
action = str((await request.json()).get("action") or "").lower()
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
user_id = _user_id(current_user)
if action == "submit":
status = "pending"
elif action == "approve":
if not str(solution.get("description") or "").strip():
raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
status = "approved"
elif action in {"reject", "outdate"}:
status = "rejected" if action == "reject" else "outdated"
else:
raise HTTPException(status_code=422, detail="Ukendt handling")
if status == "approved":
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
else:
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
_version_solution(updated, user_id, f"Status ændret til {status}")
return updated
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
if solution.get("approval_status") != "approved":
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
publish_text = "\n".join(str(solution.get(field) or "") for field in ("title", "problem", "root_cause", "investigation", "description", "workaround"))
_clean_publish_text, secret_warnings = _redact_sensitive(publish_text)
if secret_warnings:
raise HTTPException(status_code=422, detail="Løsningen indeholder muligvis password, token eller anden hemmelighed. Fjern det før udgivelse.")
customer_id = None
if solution.get("visibility") == "customer":
customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
if not customer:
raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
customer_id = customer["customer_id"]
description = str(solution.get("description") or "").strip()
summary = description[:300] + ("" if len(description) > 300 else "")
article = execute_query_single(
"""INSERT INTO knowledge_articles (
solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
visibility,status,tags,products,published_by_user_id,reviewed_at
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
reviewed_at=NOW(),updated_at=NOW(),archived_at=NULL,archived_by_user_id=NULL RETURNING *""",
(solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
)
return article
@router.get("/sag/{sag_id}/knowledge-suggestions")
async def case_knowledge_suggestions(
sag_id: int,
limit: int = Query(6, ge=1, le=20),
_current_user: dict = Depends(get_current_user),
):
_case, articles = _case_knowledge_context(sag_id, limit)
items = []
for article in articles:
item = dict(article)
item["reason"] = "Matcher sagens titel, beskrivelse eller tags"
item["relevance_percent"] = min(99, max(1, round(float(item.get("relevance") or 0) * 100)))
items.append(item)
return {"items": items, "total": len(items), "source": "approved_knowledge_only"}
@router.post("/sag/{sag_id}/solution/ai-draft", dependencies=[Depends(case_edit_access)])
async def generate_solution_ai_draft(sag_id: int, current_user: dict = Depends(get_current_user)):
case, articles = _case_knowledge_context(sag_id, 5)
comments = execute_query(
"""SELECT id,forfatter,indhold,created_at FROM sag_kommentarer
WHERE sag_id=%s AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 60""",
(sag_id,),
) or []
emails = execute_query(
"""SELECT DISTINCT e.id,e.subject,e.sender_email,e.recipient_email,e.body_text,e.received_date
FROM email_messages e LEFT JOIN sag_emails se ON se.email_id=e.id
WHERE e.deleted_at IS NULL AND (se.sag_id=%s OR e.linked_case_id=%s)
ORDER BY e.received_date DESC NULLS LAST LIMIT 25""",
(sag_id, sag_id),
) or []
time_entries = execute_query(
"""SELECT id,description,original_hours,worked_date FROM tmodule_times
WHERE sag_id=%s ORDER BY worked_date DESC NULLS LAST LIMIT 30""",
(sag_id,),
) or []
sections = [f"SAG #{sag_id}\nTitel: {case.get('titel') or ''}\nBeskrivelse: {case.get('beskrivelse') or ''}"]
if comments:
sections.append("KOMMENTARER:\n" + "\n".join(f"[Kommentar {row['id']}] {row.get('forfatter') or 'Ukendt'}: {row.get('indhold') or ''}" for row in reversed(comments)))
if emails:
sections.append("MAILS:\n" + "\n".join(f"[Mail {row['id']}] {row.get('subject') or '(uden emne)'}: {row.get('body_text') or ''}" for row in reversed(emails)))
if time_entries:
sections.append("TIDSNOTER:\n" + "\n".join(f"[Tid {row['id']}] {row.get('description') or ''}" for row in time_entries))
if articles:
sections.append("GODKENDT VIDEN:\n" + "\n".join(f"[Artikel {row['id']}] {row['title']}\nProblem: {row.get('problem') or ''}\nLøsning: {row.get('solution') or ''}" for row in articles))
context, warnings = _redact_sensitive("\n\n".join(sections))
context = context[:24000]
prompt = """Du laver et UDKAST til dokumentation af en IT-supportløsning.
Returner KUN gyldig JSON med felterne title, problem, root_cause, investigation, description, workaround, solution_type, result, tags, products, confidence, uncertainties og source_refs.
Regler:
- Brug kun fakta fra den vedlagte sag og de nummererede kilder.
- Opfind aldrig en årsag eller handling. Sæt feltet tomt og beskriv manglen i uncertainties.
- description er den endelige løsning. Hvis sagen endnu ikke dokumenterer en løsning, skal description være tom.
- source_refs skal indeholde de præcise kilder, fx "Kommentar 12" eller "Artikel 4".
- Medtag aldrig passwords, tokens, API-nøgler eller andre hemmeligheder.
- Skriv kort, teknisk og professionelt dansk.
- confidence er et tal mellem 0 og 1.
"""
service = CaseAnalysisService()
# A full case history is materially larger than QuickCreate input. Keep the
# global QuickCreate timeout unchanged, but allow the local model time to
# produce a source-grounded solution draft.
service.ai_timeout = max(service.ai_timeout, 60)
result = await service._call_ollama(prompt, context)
async def get_solution(sag_id: int):
"""Get the solution associated with a case."""
try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
if not result:
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner")
result = {
"title": case.get("titel") or "",
"problem": case.get("beskrivelse") or "",
"root_cause": "",
"investigation": "",
"description": "",
"workaround": "",
"solution_type": "Support",
"result": "Ej løst",
"tags": _knowledge_tokens(case.get("tag_text") or "")[:10],
"products": [],
"confidence": 0.2,
"uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"],
"source_refs": [f"Sag {sag_id}"],
}
allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"}
source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id)
draft = {
"title": str(result.get("title") or case.get("titel") or "")[:255],
"problem": str(result.get("problem") or ""),
"root_cause": str(result.get("root_cause") or ""),
"investigation": str(result.get("investigation") or ""),
"description": str(result.get("description") or ""),
"workaround": str(result.get("workaround") or ""),
"solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"),
"result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"),
"tags": _clean_list(result.get("tags")),
"products": _clean_list(result.get("products")),
"confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))),
"uncertainties": _clean_list(result.get("uncertainties")),
"source_refs": source_refs,
}
if not draft["description"]:
warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning")
if not source_refs:
warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger")
return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True}
return None
return result[0]
except Exception as e:
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
raise HTTPException(status_code=500, detail="Failed to get solution")
@router.get("/solutions")
async def list_solutions(
q: str = Query("", max_length=200),
approval_status: Optional[str] = None,
visibility: Optional[str] = None,
include_archived: bool = False,
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
_current_user: dict = Depends(get_current_user),
):
where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"]
params: list = []
if approval_status:
if approval_status not in APPROVAL_STATUSES:
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
where.append("s.approval_status=%s")
params.append(approval_status)
if visibility:
if visibility not in VISIBILITIES:
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
where.append("s.visibility=%s")
params.append(visibility)
term = q.strip()
if term:
where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)")
like = f"%{term}%"
params.extend([like] * 7)
where_sql = " AND ".join(where)
count = execute_query_single(
f"""SELECT COUNT(*) AS total FROM sag_solutions s
JOIN sag_sager sg ON sg.id=s.sag_id
LEFT JOIN LATERAL (
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
) c ON TRUE WHERE {where_sql}""",
tuple(params),
) or {"total": 0}
items = execute_query(
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
COALESCE(creator.full_name,creator.username) AS created_by,
COALESCE(updater.full_name,updater.username) AS updated_by,
ka.id AS article_id, ka.status AS article_status
FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id
LEFT JOIN LATERAL (
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
) c ON TRUE
LEFT JOIN users creator ON creator.user_id=s.created_by_user_id
LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id
LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id
WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""",
tuple(params + [limit, offset]),
) or []
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived}
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)])
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,))
if not solution:
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret")
user_id = _user_id(current_user)
execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id))
archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
_version_solution(archived, user_id, "Løsning arkiveret")
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]}
@router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)])
async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,))
if not solution:
raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke")
user_id = _user_id(current_user)
restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
_version_solution(restored, user_id, "Løsning gendannet")
return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]}
@router.get("/knowledge/articles")
async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
term = q.strip()
scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
params: list = [customer_id] if customer_id else []
search = ""
if term:
search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
params.extend([term, f"%{term}%"])
count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
item_params = []
rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
if term:
item_params.append(term)
item_params.extend(params)
item_params.extend([limit, offset])
items = execute_query(
f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
WHERE ka.status='published' AND {scope}{search}
ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
tuple(item_params),
) or []
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
@router.get("/knowledge/articles/{article_id}")
async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
article = execute_query_single(
"""SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
LEFT JOIN users u ON u.user_id=ka.published_by_user_id
WHERE ka.id=%s AND ka.status='published'""",
(article_id,),
@router.post(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return article
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case."""
try:
# Check if case exists
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
if not case_check:
raise HTTPException(status_code=404, detail="Case not found")
# Check if solution already exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if check:
raise HTTPException(status_code=400, detail="Solution already exists for this case")
query = """
INSERT INTO sag_solutions
(sag_id, title, description, solution_type, result, created_by_user_id)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING *
"""
params = (
sag_id,
solution.title,
solution.description,
solution.solution_type,
solution.result,
getattr(request.state, "user_id", None)
)
result = execute_query(query, params)
if result:
logger.info("✅ Solution created for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to create solution")
except HTTPException:
raise
except Exception as e:
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,
dependencies=[Depends(case_edit_access)],
)
async def update_solution(sag_id: int, updates: SolutionUpdate):
"""Update a solution."""
try:
# Check if solution exists
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
if not check:
raise HTTPException(status_code=404, detail="Solution not found")
# Build dynamic update query
set_clauses = []
params = []
# Helper to check and add params
if updates.title is not None:
set_clauses.append("title = %s")
params.append(updates.title)
if updates.description is not None:
set_clauses.append("description = %s")
params.append(updates.description)
if updates.solution_type is not None:
set_clauses.append("solution_type = %s")
params.append(updates.solution_type)
if updates.result is not None:
set_clauses.append("result = %s")
params.append(updates.result)
if not set_clauses:
raise HTTPException(status_code=400, detail="No fields to update")
set_clauses.append("updated_at = NOW()")
params.append(sag_id)
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
result = execute_query(query, tuple(params))
if result:
logger.info("✅ Solution updated for case: %s", sag_id)
return result[0]
raise HTTPException(status_code=500, detail="Failed to update solution")
except HTTPException:
raise
except Exception as e:
logger.error("❌ Error updating solution: %s", e)
raise HTTPException(status_code=500, detail="Failed to update solution")

View File

@ -13,48 +13,6 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("/knowledge", response_class=HTMLResponse)
async def knowledge_index(request: Request):
"""Read-only first release of the case knowledge base."""
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
@router.get("/solutions", response_class=HTMLResponse)
async def solutions_management(request: Request):
"""Central management surface for case solutions and their publication flow."""
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
@router.get("/procurement", response_class=HTMLResponse)
async def procurement_overview_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
@router.get("/reminder-rules", response_class=HTMLResponse)
async def reminder_rules_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int):
article = execute_query(
"""
SELECT ka.*, c.name AS customer_name,
COALESCE(u.full_name, u.username) AS published_by
FROM knowledge_articles ka
LEFT JOIN customers c ON c.id = ka.customer_id
LEFT JOIN users u ON u.user_id = ka.published_by_user_id
WHERE ka.id = %s AND ka.status = 'published'
""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return templates.TemplateResponse(
"modules/sag/templates/knowledge_detail.html",
{"request": request, "article": article[0]},
)
def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title)
@ -245,7 +203,6 @@ async def sager_liste(
assigned_group_id: str = Query(None),
unassigned: bool = Query(False),
include_deferred: bool = Query(False),
quick: str = Query(None),
):
"""Display list of all cases."""
try:
@ -258,7 +215,6 @@ async def sager_liste(
query = """
SELECT s.*,
c.name as customer_name,
sk_first.contact_id AS kontakt_id,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name,
@ -322,8 +278,7 @@ async def sager_liste(
query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_quick = str(quick or "").strip().lower()
normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
normalized_status = str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all":
pass
@ -361,7 +316,6 @@ async def sager_liste(
fallback_query = """
SELECT s.*,
c.name as customer_name,
NULL::integer AS kontakt_id,
'' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name,
@ -485,7 +439,6 @@ async def sager_liste(
"relations_map": relations_map,
"child_ids": list(child_ids),
"statuses": status_options,
"status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status,
"current_priority": normalized_priority,
@ -498,19 +451,16 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses,
})
except Exception:
logger.exception("❌ Error displaying case list")
fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request,
"sager": [],
"relations_map": {},
"child_ids": [],
"statuses": fallback_status_options,
"status_options": fallback_status_options,
"statuses": _fetch_case_status_options(),
"all_tags": [],
"current_status": status,
"current_priority": str(priority or "").strip().lower(),
@ -523,7 +473,6 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned,
"current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(),
})
@ -843,31 +792,11 @@ async def sag_detaljer(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
WHERE cc_match.contact_id = c.id
AND cc_match.customer_id IN (
SELECT s_match.customer_id
FROM sag_sager s_match
WHERE s_match.id = sk.sag_id AND s_match.customer_id IS NOT NULL
UNION
SELECT sk_customer.customer_id
FROM sag_kunder sk_customer
WHERE sk_customer.sag_id = sk.sag_id AND sk_customer.deleted_at IS NULL
)
) AS is_external
company.customer_name
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.id AS customer_id, cu.name AS customer_name
SELECT cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
@ -892,7 +821,7 @@ async def sag_detaljer(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
@ -909,13 +838,7 @@ async def sag_detaljer(request: Request, sag_id: int):
t.ekstern_nummer,
t.started_at,
t.ended_at,
CASE
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
ELSE NULL
END AS duration_sec,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
@ -1224,25 +1147,11 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
c.phone,
c.mobile,
c.title,
company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
JOIN sag_sager s_match ON s_match.id = sk.sag_id
WHERE cc_match.contact_id = c.id
AND s_match.customer_id IS NOT NULL
AND cc_match.customer_id = s_match.customer_id
) AS is_external
company.customer_name
FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL (
SELECT cu.id AS customer_id, cu.name AS customer_name
SELECT cu.name AS customer_name
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
@ -1267,7 +1176,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,))
# Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None
@ -1284,13 +1193,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
t.ekstern_nummer,
t.started_at,
t.ended_at,
CASE
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
ELSE NULL
END AS duration_sec,
t.duration_sec,
u.username,
u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name

View File

@ -4,21 +4,6 @@
{% block extra_css %}
<style>
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
.cc-panel-body { padding: 8px 16px 16px; }
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cc-types .btn { min-height: 44px; }
#cc-more > summary { padding: 10px; cursor: pointer; }
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
.cc-workload summary { cursor: pointer; }
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
.cc-work-item small { display: block; }
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
/* Gradient Header for the Card */
.card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
@ -151,144 +136,18 @@
.case-top-alerts .alert-danger {
border-left-color: #e03131;
}
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
.cc-page .form-label { font-size:.82rem; }
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
.cc-page #cc-types { align-items:stretch; gap:10px; }
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
.cc-page #cc-types > button:hover { border-color:#258783; }
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
.cc-page #cc-more .cc-types { padding-top:10px; }
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
.cc-panel-label { min-width:0; font-size:.86rem; }
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
.cc-page .cc-panel-body { padding:8px 18px 18px; }
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
.cc-page .cc-actions .btn { border-radius:10px; }
.cc-page #submitBtn { background:#176967; border-color:#176967; }
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-template-toolbar .form-label { margin:0; }
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
.cc-contact-cases[hidden] { display:none; }
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
.cc-contact-cases__body { padding:6px 13px 10px; }
.cc-contact-cases__group { padding-top:8px; }
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
.cc-contact-cases__item:last-child { border-bottom:0; }
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
@media(min-width:1200px) {
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
.cc-page .cc-panel-body { padding:14px 24px 24px; }
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
.cc-contact-cases__item small { text-align:right; margin:0; }
}
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
/* Calm, task-first layout: the form is the product, not the decoration. */
.cc-page { max-width:1240px; }
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
.cc-page .card-header-custom p { display:none; }
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
.cc-page #caseTypeHelp { display:none; }
.cc-page #cc-types { gap:7px; }
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
.cc-page .cc-type-hint { display:none; }
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
.cc-page #cc-more { flex-basis:auto; }
.cc-page #cc-more > summary { padding:8px 4px; }
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
.cc-page .cc-panel > summary { padding:12px 14px; }
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
.cc-page .cc-panel-label { font-size:.8rem; }
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
.cc-page .cc-panel-body { padding:8px 14px 16px; }
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
.cc-page #cc-metadata { margin-top:16px; }
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
/* The panels have very different heights. Keep them in a single stable flow
instead of pretending they are equal dashboard cards. */
@media(min-width:992px) {
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
}
@media(max-width:700px) {
.cc-page .card-header-custom { padding:16px 18px; }
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
.cc-page #cc-types > .cc-message-type { margin-left:0; }
}
</style>
{% endblock %}
{% block content %}
<div class="container py-4 cc-page">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12">
<div class="col-lg-8">
<!-- Main Card -->
<div class="card card-custom">
<div class="card-header-custom">
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
</div>
<div class="card-body p-4">
@ -303,14 +162,10 @@
</div>
<form id="createForm" novalidate>
<div class="mb-4 cc-type-section">
<div class="mb-4 p-3 rounded-3 border bg-light">
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
<select class="form-select form-select-lg" id="type" required></select>
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
</button>
</div>
<!-- Section: Relations -->
@ -344,7 +199,6 @@
<input type="hidden" id="customer_id" name="customer_id">
</div>
</div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<hr class="my-4 opacity-25">
@ -532,7 +386,7 @@
let customerContactsLoadToken = 0;
let successAlertTimeout;
let orderLineCounter = 0;
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) {
@ -621,13 +475,6 @@
}, duration);
}
function showCreateError(message) {
const errorDiv = document.getElementById('error');
errorDiv.classList.remove('d-none');
document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) {
@ -647,7 +494,7 @@
if (!source) {
descriptionInput?.focus();
showCreateError('Skriv en beskrivelse først.');
alert('Skriv en beskrivelse først.');
return;
}
@ -682,7 +529,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) {
console.error('Case create rewrite failed:', error);
showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
@ -698,13 +545,10 @@
const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) {
titleInput.value = suggestedTitle;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (suggestedTitle) titleInput.value = suggestedTitle;
if (suggestedDescription) {
descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
descriptionInput.dispatchEvent(new Event('input'));
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
});
@ -728,20 +572,13 @@
const contactInput = document.getElementById('contactSearch');
if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
contactInput.addEventListener('keydown', e => {
if (e.key === 'Escape') {
clearTimeout(contactSearchTimeout);
document.getElementById('contactResults').classList.add('d-none');
contactInput.blur();
}
});
contactInput.addEventListener('focus', () => {
if (selectedCustomer && contactInput.value.trim().length >= 2) {
if (selectedCustomer) {
renderCustomerContactResults(contactInput.value);
}
});
document.addEventListener('click', (e) => {
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
if (!e.target.closest('.search-position-relative')) {
const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none');
}
@ -760,12 +597,6 @@
clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
return;
}
if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query);
return;
@ -777,7 +608,6 @@
}
const timeout = setTimeout(async () => {
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
try {
// Show loading state
resultsDiv.classList.remove('d-none');
@ -794,7 +624,6 @@
return;
}
const data = await response.json();
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return;
@ -857,10 +686,6 @@
if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
resultsDiv.classList.add('d-none');
return;
}
const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true;
return [
@ -906,9 +731,9 @@
selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Henter firmaets kontakter...</div>';
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
resultsDiv.classList.remove('d-none');
try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
@ -922,7 +747,7 @@
if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
resultsDiv.classList.remove('d-none');
}
}
@ -1002,7 +827,6 @@
}
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function readTelefoniPrefill() {
@ -1012,7 +836,6 @@
const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description');
const internetConnectionIdRaw = params.get('internet_connection_id');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
@ -1021,8 +844,6 @@
telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
}
async function applyTelefoniPrefill() {
@ -1070,23 +891,6 @@
console.error('Telefoni prefill failed', e);
}
}
if (telefoniPrefill.internetConnectionId) {
const panel = document.getElementById('internetConnectionPrefill');
try {
const res = await fetch(`/api/v1/internet-connections/${telefoniPrefill.internetConnectionId}`, { credentials: 'include' });
if (!res.ok) throw new Error('Forbindelsen findes ikke');
const connection = await res.json();
const reference = connection.circuit_number || connection.provider_reference || `#${telefoniPrefill.internetConnectionId}`;
panel.innerHTML = `<i class="bi bi-router me-2"></i><strong>Internetforbindelse knyttes til sagen:</strong> ${escapeTopAlertHtml(connection.name || 'Internetforbindelse')} · ${escapeTopAlertHtml(reference)}${connection.address ? ` · ${escapeTopAlertHtml(connection.address)}` : ''}. <a href="/economy/internet-connections/${telefoniPrefill.internetConnectionId}" class="alert-link">Åbn forbindelse</a><div class="small mt-1">Kunden vælges stadig manuelt og ændres ikke på forbindelsen.</div>`;
panel.classList.remove('d-none');
const titleInput = document.getElementById('titel');
if (titleInput && !titleInput.value.trim()) titleInput.value = `Vedr. forbindelse ${reference}`;
} catch (error) {
panel.textContent = `Internetforbindelse #${telefoniPrefill.internetConnectionId} kunne ikke hentes.`;
panel.className = 'alert alert-warning mb-4';
}
}
}
function removeContact(id) {
@ -1094,7 +898,6 @@
delete selectedContactsCompanies[id];
renderSelections();
loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
}
function renderSelections() {
@ -1196,7 +999,7 @@
if (!anydeskId) return;
try {
await navigator.clipboard.writeText(anydeskId);
showSuccessAlert('AnyDesk ID kopieret');
alert('AnyDesk ID kopieret');
} catch (err) {
console.error('Copy failed', err);
}
@ -1209,13 +1012,13 @@
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) {
showCreateError('Navn er påkrævet');
alert('Navn er påkrævet');
return;
}
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) {
showCreateError('Vælg et firma før du opretter hardware');
alert('Vælg et firma før du opretter hardware');
return;
}
@ -1241,7 +1044,7 @@
document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts();
} catch (err) {
showCreateError('Fejl: ' + err.message);
alert('Fejl: ' + err.message);
}
}
@ -1336,13 +1139,14 @@
function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket';
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
window.caseCreateUI?.typeChanged(type);
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
const help = document.getElementById('caseTypeHelp');
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
: 'Denne sagstype bruger kun de fælles sagsfelter.';
}
function renderOrderLinesEmptyState() {
@ -1417,7 +1221,7 @@
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]');
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
@ -1430,35 +1234,18 @@
updateCaseTypeSections();
}
async function selectCurrentUserAsResponsible() {
const select = document.getElementById('ansvarlig_bruger_id');
if (!select || select.value) return;
try {
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!response.ok) return;
const user = await response.json();
const userId = user.user_id ?? user.id;
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
select.value = String(userId);
}
} catch (error) {
console.error('Kunne ikke forvælge ansvarlig bruger', error);
}
}
// --- Initialization ---
document.addEventListener('DOMContentLoaded', async () => {
document.addEventListener('DOMContentLoaded', () => {
initializeSearch();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
loadCaseTypesSelect();
loadPipelineStages();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
await applyTelefoniPrefill();
await window.caseCreateUI?.init();
applyTelefoniPrefill();
});
// --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault();
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset
const errorDiv = document.getElementById('error');
@ -1531,15 +1318,10 @@
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,
deadline: document.getElementById('deadline').value || null,
contact_ids: Object.keys(selectedContacts).map(id => parseInt(id)).filter(Number.isFinite),
telefoni_opkald_id: telefoniPrefill.callId ? parseInt(telefoniPrefill.callId) : null
deadline: document.getElementById('deadline').value || null
};
if (telefoniPrefill.internetConnectionId) {
data.internet_connection_ids = [telefoniPrefill.internetConnectionId];
}
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
if (data.type === 'pipeline') {
data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null,
@ -1547,10 +1329,9 @@
description: document.getElementById('pipeline_description').value || null
};
}
if (document.querySelector('#orderLines .order-line')) {
if (data.type === 'ordre') {
data.order_items = collectOrderItems();
}
Object.assign(data, window.caseCreateUI?.relations() || {});
try {
const response = await fetch('/api/v1/sag', {
@ -1562,11 +1343,64 @@
if (response.ok) {
const result = await response.json();
window.caseCreateUI?.created(result);
// Add contacts if any
const contactPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/sag/${result.id}/contacts`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact_id: parseInt(cid),
role: 'Kontakt',
is_primary: false
})
})
);
await Promise.all(contactPromises);
// Link telephony call -> case (best-effort)
if (telefoniPrefill.callId) {
try {
await fetch(`/api/v1/telefoni/calls/${encodeURIComponent(telefoniPrefill.callId)}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sag_id: result.id,
kontakt_id: telefoniPrefill.contactId || null
})
});
} catch (e) {
console.warn('Telefoni link failed', e);
}
}
// Ensure contact-company link exists
if (selectedCustomer) {
const linkPromises = Object.keys(selectedContacts).map(cid =>
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
method: 'POST',
credentials: 'include',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
})
);
const linkResponses = await Promise.all(linkPromises);
const linkFailed = linkResponses.find(res => !res.ok);
if (linkFailed) {
const err = await linkFailed.json();
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
}
}
successDiv.classList.remove('d-none');
document.getElementById('success-text').textContent = "Sag oprettet";
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
setTimeout(() => {
window.location.href = `/sag/${result.id}/v3`;
}, 1000);
} else {
const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag";
@ -1586,5 +1420,4 @@
}
});
</script>
<script src="/static/js/case-create.js?v=5"></script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -299,6 +299,8 @@
deadline: document.getElementById('deadline').value || null
};
console.log('Updating case with data:', data);
try {
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
@ -308,11 +310,17 @@
body: JSON.stringify(data)
});
console.log('Response status:', response.status);
if (response.ok) {
await response.json();
document.getElementById('success').textContent = `✅ Sag opdateret`;
const result = await response.json();
console.log('Updated case:', result);
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
document.getElementById('success').style.display = 'block';
setTimeout(() => {
window.location.href = `/sag/${caseId}/v3`;
}, 1000);
} else {
const errorText = await response.text();
console.error('Error response:', errorText);

View File

@ -5,54 +5,17 @@
{% block extra_css %}
<style>
.search-bar {
position: relative;
margin-bottom: 0;
flex: 1 1 460px;
flex: 1 1 380px;
min-width: 240px;
}
.search-bar input {
min-height: 46px;
border-radius: 12px;
border: 1px solid rgba(15,76,117,.15);
padding: 0.6rem 4.5rem 0.6rem 2.7rem;
background: var(--bg-card);
box-shadow: 0 4px 16px rgba(15, 76, 117, .06);
transition: border-color .16s ease, box-shadow .16s ease;
border-radius: 8px;
border: 1px solid rgba(0,0,0,0.1);
padding: 0.45rem 0.85rem;
}
.search-bar input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15,76,117,.12), 0 5px 18px rgba(15,76,117,.08);
}
.search-bar .search-icon {
position: absolute; left: .95rem; top: 50%; transform: translateY(-50%);
color: var(--accent); pointer-events: none;
}
.search-clear-btn {
position: absolute; right: .45rem; top: 50%; transform: translateY(-50%);
width: 34px; height: 34px; border: 0; border-radius: 9px;
background: transparent; color: var(--text-secondary);
}
.search-clear-btn:hover { background: rgba(15,76,117,.09); color: var(--accent); }
.search-shortcut {
position:absolute; right:2.85rem; top:50%; transform:translateY(-50%);
padding:.1rem .35rem; border:1px solid rgba(0,0,0,.12); border-radius:5px;
color:var(--text-secondary); font-size:.68rem; background:rgba(255,255,255,.7);
}
.sag-toolbar-card { padding: .85rem; margin-bottom: .9rem; background: var(--bg-card); border:1px solid rgba(15,76,117,.09); border-radius:16px; box-shadow:0 5px 20px rgba(15,76,117,.055); }
.sag-quick-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.48rem; padding-top:.7rem; margin-top:.7rem; border-top:1px solid rgba(15,76,117,.08); }
.sag-quick-filter { display:inline-flex; align-items:center; gap:.4rem; padding:.43rem .72rem; border:1px solid rgba(15,76,117,.14); border-radius:999px; background:rgba(15,76,117,.035); color:var(--text-primary); font-size:.8rem; font-weight:650; transition:all .16s ease; }
.sag-quick-filter:hover { border-color:rgba(15,76,117,.32); background:rgba(15,76,117,.09); color:var(--accent); transform:translateY(-1px); }
.sag-quick-filter.active { color:#fff; background:var(--accent); border-color:var(--accent); box-shadow:0 4px 12px rgba(15,76,117,.2); }
.sag-quick-filter[data-quick-filter="overdue"].active { background:#c92a2a; border-color:#c92a2a; }
.sag-advanced-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:1rem; padding:.6rem .7rem; border-radius:12px; background:rgba(15,76,117,.035); border:1px solid rgba(15,76,117,.07); }
.top-controls-row {
display: flex;
align-items: center;
@ -127,21 +90,6 @@
background: var(--accent-light);
}
.sag-table tbody tr.sag-deadline-overdue {
background: rgba(201, 42, 42, 0.045);
box-shadow: inset 5px 0 0 #c92a2a, inset 0 0 18px rgba(201, 42, 42, 0.11);
}
.sag-table tbody tr.sag-deadline-overdue:hover {
background: rgba(201, 42, 42, 0.09);
box-shadow: inset 5px 0 0 #b42318, inset 0 0 22px rgba(201, 42, 42, 0.16);
}
.sag-table tbody tr.sag-deadline-overdue td:last-child {
color: #b42318 !important;
font-weight: 700;
}
.sag-table tbody td {
padding: 0.5rem 0.75rem;
vertical-align: top;
@ -169,75 +117,10 @@
max-width: 360px;
}
.sag-column-list { min-width: 260px; max-height: 420px; overflow-y: auto; }
.sag-column-item { display:flex; align-items:center; gap:.55rem; padding:.45rem .6rem; border-radius:8px; cursor:grab; }
.sag-column-item:hover, .sag-column-item.dragging { background:rgba(15,76,117,.1); }
.sag-column-item .drag-handle { color:var(--text-secondary); cursor:grab; }
.sag-id {
display: inline-flex;
align-items: center;
gap: 0.28rem;
padding: 0.22rem 0.48rem;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 7px;
background: rgba(15, 76, 117, 0.07);
color: var(--accent);
font-weight: 700;
font-size: 0.82rem;
line-height: 1.2;
text-decoration: none;
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
}
.sag-id:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.14);
border-color: rgba(15, 76, 117, 0.3);
transform: translateY(-1px);
text-decoration: none;
}
.sag-entity-link {
display: inline-flex;
align-items: center;
gap: 0.38rem;
max-width: 100%;
padding: 0.25rem 0.5rem;
border-radius: 7px;
color: var(--text-primary);
font-weight: 600;
line-height: 1.25;
text-decoration: none;
transition: color .16s ease, background-color .16s ease, transform .16s ease;
}
.sag-entity-link i {
flex: 0 0 auto;
color: var(--text-secondary);
font-size: 0.78rem;
}
.sag-entity-link span {
overflow: hidden;
text-overflow: ellipsis;
}
.sag-entity-link:hover {
color: var(--accent);
background: rgba(15, 76, 117, 0.09);
text-decoration: none;
transform: translateX(2px);
}
.sag-entity-link:hover i {
color: var(--accent);
}
.sag-id:focus-visible,
.sag-entity-link:focus-visible {
outline: 3px solid rgba(15, 76, 117, 0.22);
outline-offset: 2px;
font-size: 0.95rem;
}
.sag-unread-badge {
@ -405,64 +288,6 @@
color: #065f46;
}
.sag-inline-select {
min-height: 34px;
border: 1px solid rgba(15, 76, 117, 0.16);
border-radius: 9px;
background-color: rgba(15, 76, 117, 0.045);
color: var(--text-primary);
font-size: 0.8rem;
font-weight: 600;
padding: 0.34rem 2rem 0.34rem 0.62rem;
box-shadow: none;
cursor: pointer;
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
}
.sag-inline-select:hover:not(:disabled) {
border-color: rgba(15, 76, 117, 0.38);
background-color: rgba(15, 76, 117, 0.09);
}
.sag-inline-select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.14);
}
.sag-inline-select:disabled {
opacity: .62;
cursor: wait;
}
.sag-owner-select {
background-color: rgba(99, 102, 241, 0.055);
border-color: rgba(99, 102, 241, 0.17);
}
.sag-status-select.status-tone-open {
color: #8a5a00;
background-color: #fff8df;
border-color: #efd991;
}
.sag-status-select.status-tone-progress {
color: #174a89;
background-color: #eaf3ff;
border-color: #b8d5fa;
}
.sag-status-select.status-tone-waiting {
color: #8a4600;
background-color: #fff1df;
border-color: #f0c58e;
}
.sag-status-select.status-tone-done {
color: #087044;
background-color: #e5f8ef;
border-color: #acdcbc;
}
.filter-pills {
display: flex;
gap: 0.5rem;
@ -824,8 +649,7 @@
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<div class="sag-toolbar-card">
<div class="top-controls-row mb-0">
<div class="top-controls-row">
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<i class="bi bi-list-check"></i>
</h1>
@ -842,14 +666,11 @@
</div>
<div class="search-bar">
<i class="bi bi-search search-icon"></i>
<input type="text"
class="form-control"
id="searchInput"
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
autocomplete="off">
<span class="search-shortcut">/</span>
<button class="search-clear-btn d-none" id="clearSearchBtn" type="button" title="Ryd søgning" aria-label="Ryd søgning"><i class="bi bi-x-lg"></i></button>
</div>
<div class="top-controls-actions">
@ -861,17 +682,13 @@
</button>
</div>
</div>
<div class="sag-quick-filters" aria-label="Hurtigfiltre">
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'all' else '' }}" type="button" data-quick-filter="all"><i class="bi bi-grid"></i>Alle aktive</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'mine-open' else '' }}" type="button" data-quick-filter="mine-open"><i class="bi bi-person-check"></i>Mine åbne sager</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'overdue' else '' }}" type="button" data-quick-filter="overdue"><i class="bi bi-alarm"></i>Overskredet deadline</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'my-groups' else '' }}" type="button" data-quick-filter="my-groups"><i class="bi bi-people"></i>Mine grupper</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'unassigned' else '' }}" type="button" data-quick-filter="unassigned"><i class="bi bi-person-dash"></i>Ikke tildelt</button>
<button class="sag-quick-filter {{ 'active' if current_quick_filter == 'closed' else '' }}" type="button" data-quick-filter="closed"><i class="bi bi-check2-circle"></i>Lukkede</button>
</div>
</div>
<div class="sag-advanced-filters">
<div class="d-flex flex-wrap align-items-center gap-2 mb-3">
<div class="filter-pills">
<div class="filter-pill active" data-filter="all">Alle</div>
<div class="filter-pill" data-filter="åben">Åbne</div>
<div class="filter-pill" data-filter="lukket">Lukkede</div>
</div>
<div class="type-filter-wrap">
<div class="dropdown type-filter-dropdown mb-1">
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
@ -929,19 +746,6 @@
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a>
<div class="dropdown ms-auto">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
</button>
<div class="dropdown-menu dropdown-menu-end p-2 shadow">
<div class="small text-muted px-2 pb-2">Træk for at ændre rækkefølge</div>
<div id="sagColumnList" class="sag-column-list"></div>
<div class="d-flex gap-2 border-top pt-2 mt-2">
<button id="resetSagColumnsBtn" class="btn btn-sm btn-outline-secondary" type="button">Nulstil</button>
<button id="saveSagColumnsBtn" class="btn btn-sm btn-primary ms-auto" type="button">Gem for mig</button>
</div>
</div>
</div>
</div>
<!-- Table -->
@ -976,15 +780,14 @@
data-status="{{ sag.status }}"
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}"
data-deadline="{{ sag.deadline.isoformat() if sag.deadline else '' }}">
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
<td class="col-expand" onclick="event.stopPropagation();">
{% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %}
</td>
<td>
<a class="sag-id" href="/sag/{{ sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ sag.id }}</a>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span>
{% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
@ -993,10 +796,10 @@
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if sag.customer_id and sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.customer_name }}"><i class="bi bi-building"></i><span>{{ sag.customer_name }}</span></a>{% else %}-{% endif %}
{{ sag.customer_name if sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if sag.kontakt_id and sag.kontakt_navn and sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
@ -1009,15 +812,13 @@
</td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% set status_raw = sag.status if sag.status else 'åben' %}
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'status', this.value, this)">
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
{% set status_class = status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
<option value="">Ikke tildelt</option>
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
<div class="owner-cell">
{{ initials_bubble(sag.ansvarlig_navn) }}
</div>
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell">
@ -1054,10 +855,10 @@
{% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %}
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" data-deadline="{{ related_sag.deadline.isoformat() if related_sag.deadline else '' }}" style="display: none;">
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" style="display: none;">
<td class="col-expand"><span class="child-branch"></span></td>
<td>
<a class="sag-id" href="/sag/{{ related_sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ related_sag.id }}</a>
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span>
{% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
@ -1066,10 +867,10 @@
{% endif %}
</td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if related_sag.customer_id and related_sag.customer_name %}<a class="sag-entity-link" href="/customers/{{ related_sag.customer_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.customer_name }}"><i class="bi bi-building"></i><span>{{ related_sag.customer_name }}</span></a>{% else %}-{% endif %}
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
</td>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
{% if related_sag.kontakt_id and related_sag.kontakt_navn and related_sag.kontakt_navn.strip() %}<a class="sag-entity-link" href="/contacts/{{ related_sag.kontakt_id }}" onclick="event.stopPropagation()" title="Åbn {{ related_sag.kontakt_navn }}"><i class="bi bi-person"></i><span>{{ related_sag.kontakt_navn }}</span></a>{% else %}-{% endif %}
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
</td>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% for rt in all_rel_types %}
@ -1085,15 +886,13 @@
</td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %}
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ related_status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'status', this.value, this)">
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
</select>
{% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
</td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ related_sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
<option value="">Ikke tildelt</option>
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if related_sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
</select>
<div class="owner-cell">
{{ initials_bubble(related_sag.ansvarlig_navn) }}
</div>
</td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
<div class="owner-cell">
@ -1139,127 +938,9 @@
</div>
</div>
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-danger text-white border-0 py-4">
<div class="d-flex align-items-center gap-3">
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem;line-height:1"></i>
<div>
<div class="text-uppercase fw-bold small opacity-75 mb-1">Vigtig advarsel</div>
<h2 class="modal-title fw-bold mb-0" id="closeCaseWithoutTimeTitle">Der er ikke registreret tid på sagen</h2>
</div>
</div>
</div>
<div class="modal-body p-4 p-md-5 text-center">
<p class="fs-4 fw-semibold mb-3">Du er ved at lukke sag <span id="closeWithoutTimeCaseNumber"></span> uden tidsregistrering.</p>
<p class="fs-5 text-muted mb-0">Kontrollér, at det er korrekt. Sagen bliver kun lukket, hvis du aktivt bekræfter nedenfor.</p>
</div>
<div class="modal-footer border-0 bg-light p-4 justify-content-center gap-2">
<button type="button" class="btn btn-lg btn-outline-secondary px-4" data-bs-dismiss="modal">
<i class="bi bi-arrow-left me-2"></i>Gå tilbage
</button>
<button type="button" class="btn btn-lg btn-danger px-4" id="confirmCloseCaseWithoutTimeBtn">
<i class="bi bi-check-circle-fill me-2"></i>Ja, luk sagen uden tid
</button>
</div>
</div>
</div>
</div>
<script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
function applyStatusSelectTone(control) {
if (!control) return;
const status = String(control.value || '').trim().toLowerCase();
control.classList.remove('status-tone-open', 'status-tone-progress', 'status-tone-waiting', 'status-tone-done');
let tone = 'status-tone-open';
if (['under behandling', 'i gang', 'in progress'].includes(status)) tone = 'status-tone-progress';
else if (['afventer', 'on hold'].includes(status)) tone = 'status-tone-waiting';
else if (['løst', 'lukket', 'afsluttet', 'resolved', 'closed', 'done'].includes(status)) tone = 'status-tone-done';
control.classList.add(tone);
}
document.querySelectorAll('.sag-status-select').forEach(applyStatusSelectTone);
function confirmCloseCaseWithoutTime(caseId, message) {
return new Promise(resolve => {
const modalElement = document.getElementById('closeCaseWithoutTimeModal');
const confirmButton = document.getElementById('confirmCloseCaseWithoutTimeBtn');
const caseNumber = document.getElementById('closeWithoutTimeCaseNumber');
if (!modalElement || !confirmButton || typeof bootstrap === 'undefined') {
resolve(window.confirm(`${message}\n\nVil du lukke sagen uden tidsregistrering?`));
return;
}
if (caseNumber) caseNumber.textContent = `#${caseId}`;
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
let confirmed = false;
const handleConfirm = () => {
confirmed = true;
modal.hide();
};
const handleHidden = () => {
confirmButton.removeEventListener('click', handleConfirm);
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
resolve(confirmed);
};
confirmButton.addEventListener('click', handleConfirm, { once: true });
modalElement.addEventListener('hidden.bs.modal', handleHidden, { once: true });
modal.show();
});
}
async function updateCaseListField(caseId, field, value, control) {
const previous = control?.dataset?.previous ?? '';
if (control) control.disabled = true;
try {
const saveField = async (confirmedWithoutTime = false) => {
const body = { [field]: value === '' ? null : value };
if (confirmedWithoutTime) body.confirm_close_without_time = true;
const response = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
};
let { response, payload } = await saveField();
const detail = payload?.detail;
if (response.status === 409 && detail?.code === 'close_without_time_confirmation_required') {
const confirmed = await confirmCloseCaseWithoutTime(caseId, detail.message);
if (!confirmed) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
return;
}
({ response, payload } = await saveField(true));
}
if (!response.ok) {
const message = typeof payload.detail === 'string' ? payload.detail : payload.detail?.message;
throw new Error(message || 'Ændringen kunne ikke gemmes');
}
if (control) control.dataset.previous = String(value ?? '');
if (control && field === 'status') applyStatusSelectTone(control);
const row = control?.closest('tr');
if (row && field === 'status') row.dataset.status = String(value || '');
if (row && field === 'status') updateOverdueDeadlineMarker(row);
if (row && field === 'ansvarlig_bruger_id') row.dataset.assigneeId = String(value || '');
if (typeof applyFilters === 'function') applyFilters();
} catch (error) {
if (control) control.value = previous;
if (control && field === 'status') applyStatusSelectTone(control);
if (typeof showNotification === 'function') showNotification(error.message, 'error');
else window.alert(error.message);
} finally {
if (control) control.disabled = false;
}
}
function escapeTopAlertHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
@ -1345,35 +1026,11 @@
const allRows = document.querySelectorAll('.tree-row');
let currentSearch = '';
let currentFilter = 'all';
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
let currentEmployeeId = '';
let currentEmployeeGroupIds = new Set();
let currentTypes = new Set();
let currentAssignees = new Set();
let currentGroups = new Set();
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
function isDeadlinePast(deadlineValue) {
if (!deadlineValue) return false;
const deadline = new Date(deadlineValue);
if (Number.isNaN(deadline.getTime())) return false;
deadline.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
return deadline.getTime() < today.getTime();
}
function updateOverdueDeadlineMarker(row) {
if (!row) return;
const status = String(row.dataset.status || '').trim().toLowerCase();
const deadlineValue = String(row.dataset.deadline || '').trim();
const isOverdue = isDeadlinePast(deadlineValue)
&& !closedStatuses.has(status);
row.classList.toggle('sag-deadline-overdue', isOverdue);
}
document.querySelectorAll('.sag-table tbody tr[data-deadline]').forEach(updateOverdueDeadlineMarker);
const assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter');
const assigneeFilterList = document.getElementById('assigneeFilterList');
@ -1386,20 +1043,6 @@
function applyFilters() {
const search = currentSearch;
const matchesQuickFilter = (row, isClosed) => {
const assigneeId = String(row.dataset.assigneeId || '').trim();
const groupId = String(row.dataset.groupId || '').trim();
if (currentQuickFilter === 'mine-open') return !isClosed && !!currentEmployeeId && assigneeId === currentEmployeeId;
if (currentQuickFilter === 'my-groups') return !isClosed && currentEmployeeGroupIds.has(groupId);
if (currentQuickFilter === 'unassigned') return !isClosed && !assigneeId;
if (currentQuickFilter === 'closed') return isClosed;
if (currentQuickFilter === 'overdue') {
const deadline = String(row.dataset.deadline || '');
return !isClosed && isDeadlinePast(deadline);
}
return !isClosed;
};
allRows.forEach(row => {
const text = row.textContent.toLowerCase();
const status = String(row.dataset.status || '').toLowerCase();
@ -1408,7 +1051,7 @@
const groupRaw = String(row.dataset.groupId || '').trim();
const assigneeId = assigneeRaw || '__UNASSIGNED__';
const groupId = groupRaw;
const matchesSearch = search.split(/\s+/).filter(Boolean).every(term => text.includes(term));
const matchesSearch = text.includes(search);
const isClosed = closedStatuses.has(status);
const matchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !isClosed)
@ -1417,7 +1060,7 @@
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup && matchesQuickFilter(row, isClosed);
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
row.style.display = visible ? '' : 'none';
@ -1432,7 +1075,7 @@
const childGroupRaw = String(child.dataset.groupId || '').trim();
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
const childGroupId = childGroupRaw;
const childMatchesSearch = search.split(/\s+/).filter(Boolean).every(term => childText.includes(term));
const childMatchesSearch = childText.includes(search);
const childIsClosed = closedStatuses.has(childStatus);
const childMatchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !childIsClosed)
@ -1441,7 +1084,7 @@
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup && matchesQuickFilter(child, childIsClosed);
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
child.style.display = childVisible ? '' : 'none';
});
}
@ -1552,55 +1195,11 @@
if (searchInput) {
searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.trim().toLowerCase();
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
currentSearch = e.target.value.toLowerCase();
applyFilters();
});
}
document.getElementById('clearSearchBtn')?.addEventListener('click', () => {
searchInput.value = '';
currentSearch = '';
document.getElementById('clearSearchBtn')?.classList.add('d-none');
searchInput.focus();
applyFilters();
});
document.addEventListener('keydown', event => {
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
event.preventDefault();
searchInput?.focus();
}
if (event.key === 'Escape' && document.activeElement === searchInput && searchInput.value) {
document.getElementById('clearSearchBtn')?.click();
}
});
document.querySelectorAll('.sag-quick-filter').forEach(button => {
button.addEventListener('click', () => {
const quick = button.dataset.quickFilter || 'all';
const url = new URL(window.location.href);
if (quick === 'all') url.searchParams.delete('quick');
else url.searchParams.set('quick', quick);
url.searchParams.delete('status');
window.location.assign(url.toString());
});
});
async function loadQuickFilterContext() {
try {
const response = await fetch('/api/v1/sag/me/quick-filter-context', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
currentEmployeeId = String(data.user_id || '');
currentEmployeeGroupIds = new Set((data.group_ids || []).map(String));
applyFilters();
} catch (error) {
console.error('Kunne ikke hente hurtigfilter-kontekst', error);
}
}
loadQuickFilterContext();
// Filter functionality
const filterPills = document.querySelectorAll('.filter-pill');
@ -1729,121 +1328,6 @@
}
}
const SAG_COLUMN_DEFINITIONS = [
['id', 'SagsID'], ['company', 'Virksomhed'], ['contact', 'Kontakt'],
['description', 'Beskrivelse'], ['type', 'Type'], ['priority', 'Prioritet'],
['status', 'Status'], ['owner', 'Ansvarlig'], ['group', 'Gruppe/Level'],
['next_todo', 'Næste todo'], ['created', 'Oprettet'], ['start', 'Arbejdsstart'],
['deferred', 'Start senest'], ['deadline', 'Deadline']
];
const SAG_DEFAULT_COLUMN_ORDER = SAG_COLUMN_DEFINITIONS.map(([key]) => key);
let sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
let sagHiddenColumns = new Set();
function normalizeSagColumnOrder(order) {
const allowed = new Set(SAG_DEFAULT_COLUMN_ORDER);
const normalized = Array.from(new Set((Array.isArray(order) ? order : []).filter(key => allowed.has(key))));
SAG_DEFAULT_COLUMN_ORDER.forEach(key => { if (!normalized.includes(key)) normalized.push(key); });
return normalized;
}
function applySagColumnPreferences() {
document.querySelectorAll('.sag-table tr').forEach(row => {
const cells = Array.from(row.children).filter(cell => cell.matches('th,td'));
if (!cells.length) return;
const expandCell = cells.find(cell => cell.dataset.columnKey === 'expand') || cells[0];
if (!expandCell.dataset.columnKey) {
expandCell.dataset.columnKey = 'expand';
cells.slice(1).forEach((cell, index) => {
if (SAG_DEFAULT_COLUMN_ORDER[index]) cell.dataset.columnKey = SAG_DEFAULT_COLUMN_ORDER[index];
});
}
const byKey = new Map(Array.from(row.children).filter(cell => cell.dataset.columnKey).map(cell => [cell.dataset.columnKey, cell]));
if (byKey.get('expand')) row.appendChild(byKey.get('expand'));
sagColumnOrder.forEach(key => {
const cell = byKey.get(key);
if (!cell) return;
cell.style.display = sagHiddenColumns.has(key) ? 'none' : '';
row.appendChild(cell);
});
});
}
function renderSagColumnChooser() {
const host = document.getElementById('sagColumnList');
if (!host) return;
const labels = Object.fromEntries(SAG_COLUMN_DEFINITIONS);
host.innerHTML = sagColumnOrder.map(key => `
<div class="sag-column-item" draggable="true" data-column-key="${key}">
<i class="bi bi-grip-vertical drag-handle"></i>
<input class="form-check-input sag-column-visible" type="checkbox" ${sagHiddenColumns.has(key) ? '' : 'checked'}>
<span>${labels[key]}</span>
</div>`).join('');
}
function applySagColumnPayload(data) {
sagColumnOrder = normalizeSagColumnOrder(data?.column_order);
sagHiddenColumns = new Set((Array.isArray(data?.hidden_columns) ? data.hidden_columns : []).filter(key => SAG_DEFAULT_COLUMN_ORDER.includes(key)));
renderSagColumnChooser();
applySagColumnPreferences();
}
async function saveSagColumnPreferences() {
const button = document.getElementById('saveSagColumnsBtn');
if (button) button.disabled = true;
try {
const response = await fetch('/api/v1/sag/me/list-preferences', {
method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type_filters: getSelectedTypesFromUi(),
column_order: sagColumnOrder,
hidden_columns: Array.from(sagHiddenColumns)
})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || 'Kolonnerne kunne ikke gemmes');
applySagColumnPayload(payload);
if (typeof showNotification === 'function') showNotification('Kolonner gemt for din bruger', 'success');
} catch (error) {
if (typeof showNotification === 'function') showNotification(error.message, 'error');
} finally {
if (button) button.disabled = false;
}
}
const sagColumnList = document.getElementById('sagColumnList');
let draggedSagColumn = null;
sagColumnList?.addEventListener('change', event => {
const item = event.target.closest('.sag-column-item');
if (!item || !event.target.matches('.sag-column-visible')) return;
if (event.target.checked) sagHiddenColumns.delete(item.dataset.columnKey);
else sagHiddenColumns.add(item.dataset.columnKey);
applySagColumnPreferences();
});
sagColumnList?.addEventListener('dragstart', event => {
draggedSagColumn = event.target.closest('.sag-column-item')?.dataset.columnKey || null;
event.target.closest('.sag-column-item')?.classList.add('dragging');
});
sagColumnList?.addEventListener('dragend', event => event.target.closest('.sag-column-item')?.classList.remove('dragging'));
sagColumnList?.addEventListener('dragover', event => event.preventDefault());
sagColumnList?.addEventListener('drop', event => {
event.preventDefault();
const targetKey = event.target.closest('.sag-column-item')?.dataset.columnKey;
if (!draggedSagColumn || !targetKey || draggedSagColumn === targetKey) return;
sagColumnOrder = sagColumnOrder.filter(key => key !== draggedSagColumn);
sagColumnOrder.splice(sagColumnOrder.indexOf(targetKey), 0, draggedSagColumn);
draggedSagColumn = null;
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('resetSagColumnsBtn')?.addEventListener('click', () => {
sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
sagHiddenColumns = new Set();
renderSagColumnChooser();
applySagColumnPreferences();
});
document.getElementById('saveSagColumnsBtn')?.addEventListener('click', saveSagColumnPreferences);
async function loadTypeFilterPreferences() {
try {
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
@ -1851,7 +1335,6 @@
const data = await res.json();
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
applySagColumnPayload(data);
applySelectedTypesToUi();
applyFilters();
} catch (err) {
@ -1868,7 +1351,7 @@
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type_filters: selected, column_order: sagColumnOrder, hidden_columns: Array.from(sagHiddenColumns) }),
body: JSON.stringify({ type_filters: selected }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (typeof showNotification === 'function') {

View File

@ -1,18 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}{{ article.title }} - Viden{% endblock %}
{% block content %}
<div class="container py-4" style="max-width:980px">
<div class="d-flex justify-content-between align-items-center mb-4"><a href="/knowledge" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Vidensdatabase</a><a href="/sag/{{ article.sag_id }}/v3#solution" class="btn btn-outline-primary"><i class="bi bi-folder2-open me-1"></i>Kildesag</a></div>
<article class="card border-0 shadow-sm rounded-4"><div class="card-body p-4 p-lg-5">
<div class="d-flex flex-wrap gap-2 mb-3"><span class="badge text-bg-success">Godkendt</span><span class="badge text-bg-light border">Version {{ article.version_number }}</span><span class="badge text-bg-light border">{{ 'Generel' if article.visibility == 'general' else ('Kundespecifik' if article.visibility == 'customer' else 'Intern') }}</span></div>
<h1 class="display-6 fw-bold">{{ article.title }}</h1><p class="lead text-muted">{{ article.summary or '' }}</p><hr class="my-4">
{% if article.problem %}<section class="mb-4"><h2 class="h5"><i class="bi bi-exclamation-circle text-danger me-2"></i>Problem og symptomer</h2><div style="white-space:pre-wrap">{{ article.problem }}</div></section>{% endif %}
{% if article.root_cause %}<section class="mb-4"><h2 class="h5"><i class="bi bi-diagram-3 text-warning me-2"></i>Årsag</h2><div style="white-space:pre-wrap">{{ article.root_cause }}</div></section>{% endif %}
{% if article.investigation %}<section class="mb-4"><h2 class="h5"><i class="bi bi-search text-info me-2"></i>Undersøgelse</h2><div style="white-space:pre-wrap">{{ article.investigation }}</div></section>{% endif %}
<section class="p-4 rounded-4 bg-success-subtle mb-4"><h2 class="h5 text-success-emphasis"><i class="bi bi-check-circle me-2"></i>Endelig løsning</h2><div style="white-space:pre-wrap">{{ article.solution }}</div></section>
{% if article.workaround %}<section class="mb-4"><h2 class="h5"><i class="bi bi-cone-striped text-primary me-2"></i>Workaround</h2><div style="white-space:pre-wrap">{{ article.workaround }}</div></section>{% endif %}
<div class="d-flex flex-wrap gap-2 mt-4">{% for tag in article.tags or [] %}<span class="badge rounded-pill text-bg-light border">{{ tag }}</span>{% endfor %}{% for product in article.products or [] %}<span class="badge rounded-pill text-bg-primary">{{ product }}</span>{% endfor %}</div>
<hr class="my-4"><div class="small text-muted">Udgivet af {{ article.published_by or 'ukendt' }}{% if article.customer_name %} · Kun {{ article.customer_name }}{% endif %} · Kilde: sag {{ article.sag_id }}</div>
</div></article>
</div>
{% endblock %}

View File

@ -1,36 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Vidensdatabase - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.kb-hero{background:linear-gradient(135deg,#0f4c75,#2563eb);color:#fff;border-radius:22px;padding:2rem;box-shadow:0 18px 45px rgba(15,76,117,.2)}
.kb-search{border:0;border-radius:14px;min-height:56px;padding-left:3.1rem;box-shadow:0 8px 24px rgba(15,23,42,.14)}
.kb-card{border:1px solid rgba(15,76,117,.12);border-radius:16px;transition:.18s ease;background:var(--bg-card,#fff)}
.kb-card:hover{transform:translateY(-2px);box-shadow:0 12px 28px rgba(15,76,117,.12);border-color:rgba(37,99,235,.3)}
.kb-card a{text-decoration:none;color:inherit}.kb-tag{background:rgba(37,99,235,.09);color:#1d4ed8;border-radius:999px;padding:.25rem .6rem;font-size:.75rem}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4">
<section class="kb-hero mb-4">
<div class="d-flex flex-wrap justify-content-between gap-3 align-items-start">
<div><div class="small text-uppercase opacity-75 fw-semibold">BMC Viden</div><h1 class="h2 fw-bold mb-2">Find en gennemprøvet løsning</h1><p class="mb-0 opacity-75">Kun godkendte og udgivne løsninger vises her.</p></div>
<div class="d-flex gap-2"><a href="/solutions" class="btn btn-light"><i class="bi bi-sliders me-1"></i>Administrer løsninger</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-arrow-left me-1"></i>Sager</a></div>
</div>
<div class="position-relative mt-4"><i class="bi bi-search position-absolute top-50 translate-middle-y ms-3 fs-5 text-secondary"></i><input id="kbSearch" class="form-control kb-search" type="search" placeholder="Søg efter problem, fejlbesked, produkt eller løsning…" autocomplete="off"></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0">Vidensartikler</h2><span id="kbCount" class="badge rounded-pill text-bg-light border">Henter…</span></div>
<div id="kbState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter viden…</div>
<div id="kbResults" class="row g-3"></div>
</div>
<script>
(() => {
const input=document.getElementById('kbSearch'), results=document.getElementById('kbResults'), state=document.getElementById('kbState'), count=document.getElementById('kbCount'); let timer, controller;
const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
async function load(){ controller?.abort(); controller=new AbortController(); const q=input.value.trim(); state.classList.remove('d-none'); state.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Søger…'; results.innerHTML='';
try{const r=await fetch(`/api/v1/knowledge/articles?q=${encodeURIComponent(q)}`,{signal:controller.signal,credentials:'include'});if(!r.ok)throw new Error('Kunne ikke hente vidensartikler');const d=await r.json();count.textContent=`${d.total} artikler`;state.classList.toggle('d-none',d.items.length>0);if(!d.items.length)state.innerHTML='<i class="bi bi-journal-x fs-1 d-block mb-2"></i>Ingen godkendte artikler matcher søgningen.';
results.innerHTML=d.items.map(a=>`<div class="col-xl-4 col-md-6"><article class="kb-card h-100 p-4"><a href="/knowledge/${a.id}"><div class="d-flex justify-content-between gap-2 mb-2"><span class="small text-uppercase text-primary fw-semibold">${a.visibility==='general'?'Generel':a.visibility==='customer'?'Kundespecifik':'Intern'}</span><span class="small text-muted">v${a.version_number}</span></div><h3 class="h5 fw-bold">${esc(a.title)}</h3><p class="text-muted mb-3">${esc(a.summary||'Ingen kort beskrivelse')}</p><div class="d-flex flex-wrap gap-1">${(a.tags||[]).slice(0,5).map(t=>`<span class="kb-tag">${esc(t)}</span>`).join('')}</div><div class="small text-muted mt-3"><i class="bi bi-folder2-open me-1"></i>Sag ${a.sag_id}${a.customer_name?' · '+esc(a.customer_name):''}</div></a></article></div>`).join('');
}catch(e){if(e.name==='AbortError')return;state.classList.remove('d-none');state.innerHTML=`<i class="bi bi-exclamation-triangle text-danger fs-2 d-block"></i>${esc(e.message)}`;count.textContent='Fejl';}}
input.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -1,15 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Indkøbsoversigt{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-4"><div><div class="text-uppercase small text-primary fw-bold">Drift · indkøb</div><h1 class="h3 mb-1">Varekø og levering</h1><p class="text-muted mb-0">Alt, der mangler at blive bestilt, modtaget eller sendt til kunden.</p></div><button class="btn btn-outline-primary" onclick="loadProcurement()"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
<div class="row g-3 mb-4" id="procurementKpis"></div>
<div class="card border-0 shadow-sm rounded-4"><div class="card-body p-0"><div class="table-responsive"><table class="table table-hover align-middle mb-0"><thead class="table-light"><tr><th class="ps-4">Status</th><th>Vare</th><th>Sag / kunde</th><th>Antal</th><th>Indkøb</th><th>Salgsordre</th><th class="pe-4"></th></tr></thead><tbody id="procurementRows"><tr><td colspan="7" class="text-center py-5 text-muted">Henter indkøbskø…</td></tr></tbody></table></div></div></div>
</div>
<script>
const stateLabel={to_order:['Skal bestilles','danger'],ordered:['Bestilt','warning'],received:['Modtaget','success']};
const money=v=>new Intl.NumberFormat('da-DK',{style:'currency',currency:'DKK'}).format(Number(v||0));
async function loadProcurement(){const res=await fetch('/api/v1/procurement/overview');const data=await res.json();const c=data.counts||{};document.getElementById('procurementKpis').innerHTML=[['Skal bestilles',c.to_order,'danger','cart-plus'],['Bestilt',c.ordered,'warning','truck'],['Modtaget',c.received,'success','box-seam'],['Mangler salgsordre',c.missing_sales_order,'secondary','receipt']].map(([l,n,col,i])=>`<div class="col-sm-6 col-xl-3"><div class="card border-0 shadow-sm rounded-4"><div class="card-body d-flex justify-content-between"><div><div class="small text-muted">${l}</div><div class="display-6 fw-bold text-${col}">${n||0}</div></div><i class="bi bi-${i} fs-3 text-${col} opacity-75"></i></div></div></div>`).join('');document.getElementById('procurementRows').innerHTML=(data.items||[]).map(x=>{const s=stateLabel[x.fulfilment_state]||stateLabel.to_order;return `<tr><td class="ps-4"><span class="badge text-bg-${s[1]}">${s[0]}</span></td><td><strong>${x.description||'Uden beskrivelse'}</strong><div class="small text-muted">${x.external_ref||'Ingen reference'}</div></td><td><a href="/sag/${x.sag_id}/v3">#${x.sag_id} · ${x.case_title||''}</a><div class="small text-muted">${x.customer_name||'Ingen kunde'}</div></td><td>${x.quantity||'—'} ${x.unit||''}</td><td>${money(x.amount)}</td><td>${x.has_sales_line?'<span class="text-success"><i class="bi bi-check-circle me-1"></i>Oprettet</span>':'<span class="text-danger"><i class="bi bi-exclamation-circle me-1"></i>Mangler</span>'}</td><td class="pe-4"><a class="btn btn-sm btn-outline-primary" href="/sag/${x.sag_id}/v3?tab=sales">Åbn sag</a></td></tr>`}).join('')||'<tr><td colspan="7" class="text-center py-5 text-success">Ingen åbne indkøbslinjer.</td></tr>'}
loadProcurement();
</script>
{% endblock %}

View File

@ -1,19 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Automatiske opgavelister{% endblock %}
{% block content %}
<div class="container py-4 py-lg-5 reminder-rules-page">
<section class="rule-hero mb-4"><div><div class="eyebrow hero-eyebrow">SUPPORT · AUTOMATISERING</div><h1>Automatiske opgavelister</h1><p>Få dine åbne sager og valgfrit gruppens samlet i en kort, klikbar besked på de tidspunkter, der passer din arbejdsdag.</p></div><div class="hero-icon"></div></section>
<div class="card rule-card border-0 shadow-sm"><div class="card-body p-4 p-lg-5"><div class="d-flex align-items-start gap-3 mb-4"><div class="section-icon"></div><div><h2 class="h4 mb-1">Ny regel</h2><p class="text-muted mb-0">Reglen kører automatisk; brug <strong>Send nu</strong> for at teste den med det samme.</p></div></div><div class="row g-4"><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTitle">Navn på listen</label><input id="ruleTitle" class="form-control form-control-lg" value="Min opgaveliste"><div class="form-text">Kun til din egen oversigt.</div></div><div class="col-lg-6"><label class="form-label fw-semibold" for="ruleTimes">Tidspunkter</label><input id="ruleTimes" class="form-control form-control-lg" value="09:00, 12:00, 14:00" placeholder="09:00, 12:00"><div class="form-text">Skriv flere tider adskilt med komma, fx 09:00, 12:00, 14:00.</div></div><div class="col-12"><div class="rule-options"><label class="form-check option"><input id="ruleGroups" class="form-check-input" type="checkbox" checked><span><strong>Medtag mine gruppers sager</strong><small>Viser også åbne sager, som er tildelt dine grupper.</small></span></label><label class="form-check option"><input id="ruleMattermost" class="form-check-input" type="checkbox" checked><span><strong>Send til Mattermost</strong><small>Hver sag i beskeden får et direkte link til Hub.</small></span></label></div></div><div class="col-12 d-flex justify-content-end"><button id="saveButton" class="btn btn-primary px-4 py-2" onclick="saveRule()">Gem regel</button></div></div></div></div>
<section class="mt-5"><div class="mb-3"><div class="eyebrow">DINE REGLER</div><h2 class="h4 mb-0">Planlagte opgavelister</h2></div><div id="rules"></div></section>
</div>
<style>.reminder-rules-page{max-width:1180px}.rule-hero{background:linear-gradient(120deg,#0b3e68,#126d91 65%,#18a6a6);color:#fff;border-radius:24px;padding:34px 40px;display:flex;justify-content:space-between;align-items:center}.rule-hero h1{font-size:clamp(1.7rem,3vw,2.4rem);margin:.35rem 0 .6rem;font-weight:750}.rule-hero p{margin:0;max-width:680px;color:#d8edf5;font-size:1.05rem}.eyebrow{font-size:.73rem;letter-spacing:.11em;font-weight:800;color:#5a7890}.hero-eyebrow{color:#a9edf0}.hero-icon{width:74px;height:74px;border-radius:50%;display:grid;place-items:center;font-size:2.4rem;font-weight:700;background:#ffffff22;border:1px solid #ffffff40}.rule-card{border-radius:22px}.section-icon{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;background:#e6f4fa;color:#0c6895;font-size:1.35rem}.rule-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.option{margin:0;border:1px solid #dce8ef;border-radius:14px;padding:16px 17px 16px 40px;background:#fbfdff}.option .form-check-input{margin-left:-24px;margin-top:.35rem}.option small{display:block;color:#6a7c89;margin-top:3px}.rule-entry{border:1px solid #dde8ef;border-radius:17px;background:#fff;padding:18px 20px;display:flex;align-items:center;justify-content:space-between;gap:16px}.rule-time{display:inline-flex;border-radius:999px;padding:5px 10px;background:#edf6fa;color:#155c82;font-size:.86rem;font-weight:650}.empty-rules{border:1px dashed #bfd2df;border-radius:16px;padding:28px;color:#647684;text-align:center;background:#fcfeff}@media(max-width:700px){.rule-hero{padding:27px}.hero-icon{display:none}.rule-options{grid-template-columns:1fr}.rule-entry{align-items:flex-start;flex-direction:column}}</style>
<script>
const rulesEl=document.getElementById('rules');
const escapeHtml=value=>String(value||'').replace(/[&<>'"]/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
async function responseMessage(response,fallback){const body=await response.json().catch(()=>({}));return body.detail||body.message||fallback}
async function load(){const response=await fetch('/api/v1/reminder-task-rules');if(!response.ok){rulesEl.innerHTML=`<div class="alert alert-danger">${escapeHtml(await responseMessage(response,'Kunne ikke hente regler'))}</div>`;return}const list=await response.json();rulesEl.innerHTML=list.map(rule=>`<article class="rule-entry"><div><div class="fw-bold fs-5">${escapeHtml(rule.title)}</div><div class="mt-2">${(rule.times_json||[]).map(time=>`<span class="rule-time me-1">${escapeHtml(time)}</span>`).join('')} ${rule.include_groups?'<span class="text-muted small ms-1">· Mine grupper er med</span>':''}</div></div><button class="btn btn-outline-primary" onclick="sendRule(${rule.id},this)">Send nu ↗</button></article>`).join('')||'<div class="empty-rules">Ingen regler endnu. Opret den første ovenfor, og test den derefter med <strong>Send nu</strong>.</div>'}
async function saveRule(){const button=document.getElementById('saveButton');const times=document.getElementById('ruleTimes').value.split(',').map(value=>value.trim()).filter(Boolean);if(!times.length)return alert('Vælg mindst ét tidspunkt');button.disabled=true;button.textContent='Gemmer…';try{const response=await fetch('/api/v1/reminder-task-rules',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('ruleTitle').value,times,include_groups:document.getElementById('ruleGroups').checked,notify_mattermost:document.getElementById('ruleMattermost').checked})});if(!response.ok)throw new Error(await responseMessage(response,'Kunne ikke gemme'));await load()}catch(error){alert(error.message)}finally{button.disabled=false;button.textContent='Gem regel'}}
async function sendRule(id,button){const original=button.innerHTML;button.disabled=true;button.textContent='Sender…';try{const response=await fetch(`/api/v1/reminder-task-rules/${id}/send-now`,{method:'POST'});const result=await response.json().catch(()=>({}));if(!response.ok||!result.sent)throw new Error(result.detail||result.message||'Kunne ikke sende');alert(`Opgavelisten er sendt med ${result.count} sager.`)}catch(error){alert(error.message)}finally{button.disabled=false;button.innerHTML=original}}
load();
</script>
{% endblock %}

View File

@ -1,58 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Løsninger - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.sol-hero{background:linear-gradient(125deg,#12263f,#0f4c75 55%,#2563eb);color:#fff;border-radius:22px;padding:1.6rem 1.8rem;box-shadow:0 16px 40px rgba(15,76,117,.2)}
.sol-toolbar,.sol-card{background:var(--bg-card,#fff);border:1px solid rgba(15,76,117,.12);border-radius:16px}
.sol-card{transition:.16s ease}.sol-card:hover{border-color:rgba(37,99,235,.3);box-shadow:0 10px 24px rgba(15,76,117,.09)}
.sol-summary{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
.sol-filter.active{background:#0f4c75;color:#fff;border-color:#0f4c75}.sol-chip{border-radius:999px;padding:.25rem .65rem;font-size:.75rem;font-weight:600}
.offcanvas.sol-editor{width:min(760px,100vw)}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid px-4 py-4">
<section class="sol-hero mb-4 d-flex flex-wrap justify-content-between align-items-center gap-3">
<div><div class="small text-uppercase fw-semibold opacity-75">Sager · Viden</div><h1 class="h2 fw-bold mb-1">Løsninger</h1><p class="mb-0 opacity-75">Rediger, kvalitetssikr og udgiv sagernes løsninger.</p></div>
<div class="d-flex gap-2"><a href="/knowledge" class="btn btn-light"><i class="bi bi-journal-richtext me-1"></i>Vidensdatabase</a><a href="/sag" class="btn btn-outline-light"><i class="bi bi-folder2 me-1"></i>Sager</a></div>
</section>
<section class="sol-toolbar p-3 mb-4">
<div class="row g-3 align-items-center"><div class="col-xl-5"><div class="input-group"><span class="input-group-text bg-transparent"><i class="bi bi-search"></i></span><input id="solSearch" class="form-control" type="search" placeholder="Søg i titel, problem, løsning, sag eller kunde…"></div></div>
<div class="col-xl-7 d-flex flex-wrap gap-2 justify-content-xl-end"><button class="btn btn-sm btn-outline-secondary sol-filter active" data-status="">Alle</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="draft">Kladder</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="pending">Afventer</button><button class="btn btn-sm btn-outline-secondary sol-filter" data-status="approved">Godkendte</button><button id="archivedToggle" class="btn btn-sm btn-outline-danger"><i class="bi bi-archive me-1"></i>Arkiverede</button></div></div>
</section>
<div class="d-flex justify-content-between align-items-center mb-3"><h2 class="h5 mb-0" id="solHeading">Aktive løsninger</h2><span class="badge rounded-pill text-bg-light border" id="solCount">Henter…</span></div>
<div id="solState" class="text-center text-muted py-5"><div class="spinner-border spinner-border-sm me-2"></div>Henter løsninger…</div><div id="solGrid" class="row g-3"></div>
</div>
<div class="offcanvas offcanvas-end sol-editor" tabindex="-1" id="solutionEditor"><div class="offcanvas-header border-bottom"><div><div class="small text-uppercase text-primary fw-semibold">Løsning</div><h2 class="offcanvas-title h4 mb-0" id="editorHeading">Rediger løsning</h2></div><button class="btn-close" data-bs-dismiss="offcanvas"></button></div><div class="offcanvas-body">
<input type="hidden" id="editSagId"><div class="mb-3"><label class="form-label">Titel *</label><input class="form-control" id="editTitle"></div>
<div class="row g-3 mb-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select" id="editType"><option>Support</option><option>Drift</option><option>Konsulent</option><option>Infrastruktur</option><option>Ekstern</option></select></div><div class="col-md-4"><label class="form-label">Resultat</label><select class="form-select" id="editResult"><option>Løst</option><option>Delvist</option><option>Workaround</option><option>Ej løst</option></select></div><div class="col-md-4"><label class="form-label">Synlighed</label><select class="form-select" id="editVisibility"><option value="internal">Kun internt</option><option value="general">Generel viden</option><option value="customer">Kun denne kunde</option></select></div></div>
<div class="mb-3"><label class="form-label">Problem og symptomer</label><textarea class="form-control" id="editProblem" rows="3"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Årsag</label><textarea class="form-control" id="editCause" rows="3"></textarea></div><div class="col-md-6"><label class="form-label">Undersøgelse</label><textarea class="form-control" id="editInvestigation" rows="3"></textarea></div></div>
<div class="mb-3"><label class="form-label">Endelig løsning *</label><textarea class="form-control" id="editDescription" rows="5"></textarea></div><div class="mb-3"><label class="form-label">Workaround</label><textarea class="form-control" id="editWorkaround" rows="2"></textarea></div>
<div class="row g-3 mb-3"><div class="col-md-6"><label class="form-label">Tags</label><input class="form-control" id="editTags"></div><div class="col-md-6"><label class="form-label">Produkter/systemer</label><input class="form-control" id="editProducts"></div></div><div class="mb-4"><label class="form-label">Ændringsnote</label><input class="form-control" id="editNote" placeholder="Hvad er ændret?"></div>
<div class="d-flex justify-content-between gap-2 border-top pt-3"><a id="editorCaseLink" class="btn btn-outline-secondary"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag</a><button class="btn btn-primary" onclick="saveManagedSolution()"><i class="bi bi-check2 me-1"></i>Gem ændringer</button></div>
</div></div>
<div class="modal fade" id="archiveSolutionModal" tabindex="-1" data-bs-backdrop="static"><div class="modal-dialog modal-dialog-centered"><div class="modal-content border-0 shadow"><div class="modal-body p-4"><div class="d-flex gap-3"><div class="fs-2 text-danger"><i class="bi bi-archive"></i></div><div><h2 class="h4">Arkivér løsningen?</h2><p class="text-muted mb-2">Løsningen fjernes fra sagen og en udgivet vidensartikel skjules. Historik og data bevares.</p><div class="fw-semibold" id="archiveSolutionName"></div></div></div></div><div class="modal-footer border-0 pt-0"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-danger" id="archiveConfirmBtn">Ja, arkivér</button></div></div></div></div>
<script>
(() => {
const state={items:new Map(),status:'',archived:false,timer:null,archiveId:null}; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const labels={draft:'Kladde',pending:'Afventer godkendelse',approved:'Godkendt',outdated:'Forældet',rejected:'Afvist'};
async function api(url,options={}){const r=await fetch(url,{credentials:'include',...options});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(typeof e.detail==='string'?e.detail:'Handlingen mislykkedes');}return r.json();}
async function load(){const box=document.getElementById('solState'),grid=document.getElementById('solGrid'),q=document.getElementById('solSearch').value.trim();box.classList.remove('d-none');box.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Henter…';grid.innerHTML='';
try{const d=await api(`/api/v1/solutions?q=${encodeURIComponent(q)}&approval_status=${encodeURIComponent(state.status)}&include_archived=${state.archived}`);state.items=new Map(d.items.map(x=>[x.id,x]));document.getElementById('solCount').textContent=`${d.total} løsninger`;document.getElementById('solHeading').textContent=state.archived?'Arkiverede løsninger':'Aktive løsninger';box.classList.toggle('d-none',d.items.length>0);if(!d.items.length)box.innerHTML='<i class="bi bi-lightbulb fs-1 d-block mb-2"></i>Ingen løsninger matcher.';
grid.innerHTML=d.items.map(s=>`<div class="col-xxl-4 col-lg-6"><article class="sol-card h-100 p-4"><div class="d-flex justify-content-between gap-2 mb-2"><div class="d-flex gap-1"><span class="sol-chip ${s.approval_status==='approved'?'bg-success-subtle text-success-emphasis':s.approval_status==='pending'?'bg-warning-subtle text-warning-emphasis':'bg-secondary-subtle text-secondary-emphasis'}">${labels[s.approval_status]||esc(s.approval_status)}</span><span class="sol-chip bg-light border">${s.visibility==='general'?'Generel':s.visibility==='customer'?'Kunde':'Intern'}</span></div><span class="small text-muted">#${s.sag_id}</span></div><h3 class="h5 fw-bold">${esc(s.title)}</h3><p class="sol-summary text-muted">${esc(s.problem||s.description||'Ingen beskrivelse')}</p><div class="small text-muted mb-3">${esc(s.customer_name||'Ingen kunde')} · ${esc(s.updated_by||s.created_by||'Ukendt')}</div><div class="d-flex flex-wrap gap-2"><a class="btn btn-sm btn-outline-secondary" href="/sag/${s.sag_id}/v3#solution">Sag</a>${state.archived?`<button class="btn btn-sm btn-success" onclick="restoreManagedSolution(${s.id})"><i class="bi bi-arrow-counterclockwise me-1"></i>Gendan</button>`:`<button class="btn btn-sm btn-outline-primary" onclick="openManagedSolution(${s.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>${s.approval_status==='pending'?`<button class="btn btn-sm btn-success" onclick="solutionAction(${s.sag_id},'approve')">Godkend</button>`:''}${s.approval_status==='approved'?`<button class="btn btn-sm btn-primary" onclick="publishManagedSolution(${s.sag_id})">Udgiv</button>`:''}<button class="btn btn-sm btn-outline-danger ms-auto" onclick="askArchiveSolution(${s.id})" title="Arkivér"><i class="bi bi-archive"></i></button>`}</div></article></div>`).join('');
}catch(e){box.classList.remove('d-none');box.innerHTML=`<i class="bi bi-exclamation-triangle text-danger d-block fs-2"></i>${esc(e.message)}`;}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
window.solutionAction=async(sag,action)=>{try{await api(`/api/v1/sag/${sag}/solution/workflow`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});load();}catch(e){alert(e.message);}};
window.publishManagedSolution=async sag=>{try{const a=await api(`/api/v1/sag/${sag}/solution/publish`,{method:'POST'});location.href=`/knowledge/${a.id}`;}catch(e){alert(e.message);}};
window.askArchiveSolution=id=>{state.archiveId=id;document.getElementById('archiveSolutionName').textContent=state.items.get(id)?.title||'';bootstrap.Modal.getOrCreateInstance(document.getElementById('archiveSolutionModal')).show();};
document.getElementById('archiveConfirmBtn').addEventListener('click',async()=>{try{await api(`/api/v1/solutions/${state.archiveId}`,{method:'DELETE'});bootstrap.Modal.getInstance(document.getElementById('archiveSolutionModal'))?.hide();load();}catch(e){alert(e.message);}});
window.restoreManagedSolution=async id=>{try{await api(`/api/v1/solutions/${id}/restore`,{method:'POST'});load();}catch(e){alert(e.message);}};
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -145,7 +145,7 @@
<input type="date" class="form-control" id="ordersDateTo">
</div>
<div class="col-md-2">
<button class="btn btn-outline-secondary w-100" onclick="resetOrderFilters()"><i class="bi bi-x-lg me-1"></i>Ryd filtre</button>
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
</div>
<div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
@ -358,26 +358,7 @@
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
}
let ordersFilterTimer = null;
function resetOrderFilters() {
['ordersSearch', 'ordersStatus', 'ordersCaseId', 'ordersCustomerId', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
const element = document.getElementById(id);
if (element) element.value = '';
});
loadOrders();
}
document.addEventListener('DOMContentLoaded', () => {
['ordersStatus', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
document.getElementById(id)?.addEventListener('change', loadOrders);
});
['ordersSearch', 'ordersCaseId', 'ordersCustomerId'].forEach(id => {
document.getElementById(id)?.addEventListener('input', () => {
window.clearTimeout(ordersFilterTimer);
ordersFilterTimer = window.setTimeout(loadOrders, 300);
});
});
loadOrders();
});
</script>

View File

@ -7,10 +7,10 @@ router = APIRouter()
async def search_customers(q: str = Query(..., min_length=2)):
"""
Autocomplete search for customers.
Returns customer identity and registered address for address-aware workflows.
Returns list of {id, name, cvr_nummer, email}
"""
sql = """
SELECT id, name, cvr_number as cvr_nummer, email, address, city, postal_code
SELECT id, name, cvr_number as cvr_nummer, email
FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL
@ -45,19 +45,6 @@ async def search_contacts(q: str = Query(..., min_length=2)):
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company,
(
SELECT cu.id
FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS user_company_id,
ARRAY(
SELECT cc.customer_id
FROM contact_companies cc
WHERE cc.contact_id = c.id
) AS customer_ids,
(
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
FROM contact_companies cc
@ -74,17 +61,11 @@ async def search_contacts(q: str = Query(..., min_length=2)):
OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc_search
JOIN customers cu_search ON cu_search.id = cc_search.customer_id
WHERE cc_search.contact_id = c.id AND cu_search.name ILIKE %s
)
ORDER BY c.first_name ASC, c.last_name ASC
LIMIT 20
"""
term = f"%{q}%"
results = execute_query(sql, (term, term, term, term, term, term, term))
results = execute_query(sql, (term, term, term, term, term, term))
return results
@router.get("/search/hardware")

View File

@ -3,8 +3,6 @@ import logging
import base64
import ipaddress
import re
import uuid
import math
from datetime import datetime
from typing import Optional
from urllib.error import URLError, HTTPError
@ -18,7 +16,6 @@ from app.core.auth_dependencies import require_permission
from app.core.config import settings
from app.core.database import execute_query, execute_query_single
from app.services.sms_service import SmsService
from app.services.system_event_logger import log_system_event
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
from .service import TelefoniService
@ -381,13 +378,6 @@ async def yealink_established(
raw_payload=json.dumps(payload),
started_at=datetime.utcnow(),
)
log_system_event(
source="voip", event_type="voip.established", status="success",
summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}",
correlation_id=resolved_callid, user_id=primary_user_id,
details={"call_row_id": row.get("id"), "extension": local_extension,
"external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload},
)
# Send websocket notification to ALL users with this extension
if user_ids:
@ -448,20 +438,10 @@ async def yealink_terminated(
if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,))
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
log_system_event(
source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup",
status="success" if existed else "warning",
summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event",
correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"),
details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id,
"client_ip": _get_client_ip(request)},
)
return {"status": "ok", "time_entry_id": time_entry_id}
return {"status": "ok"}
@router.websocket("/telefoni/ws")
@ -637,76 +617,6 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
return str(value)
def _register_completed_call_time(callid: str) -> Optional[int]:
"""Create one completed worklog for a case-started call after PBX termination."""
call = execute_query_single(
"""
SELECT t.id, t.sag_id, t.bruger_id, t.started_at, t.ended_at, t.duration_sec,
t.raw_payload,
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Hub User') AS user_name
FROM telefoni_opkald t
JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
LEFT JOIN users u ON u.user_id = t.bruger_id
WHERE t.callid = %s
""",
(callid,),
)
if not call or not call.get("sag_id"):
return None
raw_payload = call.get("raw_payload") or {}
if isinstance(raw_payload, str):
try:
raw_payload = json.loads(raw_payload)
except (TypeError, ValueError):
raw_payload = {}
if not raw_payload.get("case_click_to_call"):
return None
marker = f"[telefoni:{call['id']}]"
existing = execute_query_single(
"SELECT id FROM tmodule_times WHERE sag_id = %s AND description LIKE %s LIMIT 1",
(call["sag_id"], f"%{marker}%"),
)
if existing:
return int(existing["id"])
from app.timetracking.backend.router import _resolve_case_customer_id
time_customer_id = _resolve_case_customer_id(call["sag_id"])
if not time_customer_id:
logger.warning("⚠️ Could not resolve time customer for call %s / case %s", callid, call["sag_id"])
return None
seconds = int(call.get("duration_sec") or 0)
if seconds <= 0 and call.get("started_at") and call.get("ended_at"):
seconds = max(0, int((call["ended_at"] - call["started_at"]).total_seconds()))
actual_minutes = max(1, math.ceil(seconds / 60))
billable_minutes = max(30, math.ceil(actual_minutes / 30) * 30)
row = execute_query_single(
"""
INSERT INTO tmodule_times (
sag_id, customer_id, description, original_hours, worked_date,
user_name, status, billable, start_tid, slut_tid,
faktisk_tid_min, fakturerbar_tid_min, entry_type, kilde,
entry_status, medarbejder_id, aktiv_timer, round_block_min,
ikke_placeret, work_type
) VALUES (
%s, %s, %s, %s, COALESCE(%s::timestamp, NOW())::date,
%s, 'pending', TRUE, %s, %s,
%s, %s, 'opkald', 'auto',
'afventer', %s, FALSE, 30,
FALSE, 'support'
) RETURNING id
""",
(
call["sag_id"], time_customer_id, f"Telefonopkald {marker}",
max(actual_minutes / 60.0, 0.01), call.get("started_at"), call.get("user_name"),
call.get("started_at"), call.get("ended_at"), actual_minutes, billable_minutes,
call.get("bruger_id"),
),
)
return int(row["id"]) if row else None
@router.post("/telefoni/click-to-call")
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
client_ip = _get_client_ip(request)
@ -796,13 +706,6 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
effective_user_id = int(authenticated_user_id or payload.user_id) if (authenticated_user_id or payload.user_id) else None
if payload.sag_id:
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (payload.sag_id,)):
raise HTTPException(status_code=404, detail="Case not found")
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
try:
@ -825,29 +728,11 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
if auth_header:
display_url = "[basic-auth] " + resolved_url
call_record = None
if payload.sag_id:
pending_callid = f"click-to-call:{uuid.uuid4()}"
call_record = execute_query_single(
"""
INSERT INTO telefoni_opkald
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, sag_id, started_at, raw_payload)
VALUES (%s, %s, 'outbound', %s, %s, %s, %s, NOW(), %s::jsonb)
RETURNING id, callid, sag_id, kontakt_id
""",
(
pending_callid, effective_user_id, number_normalized, extension_value or None,
payload.contact_id, payload.sag_id,
json.dumps({"case_click_to_call": True, "requested_number": number_normalized}),
),
)
return {
"status": "ok",
"action_url": display_url,
"http_status": status,
"number": number_normalized,
"call_record": call_record,
}
@ -860,7 +745,6 @@ async def list_calls(
limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0),
):
TelefoniService.reconcile_stale_calls()
where = []
params = []
@ -891,13 +775,13 @@ async def list_calls(
t.sag_id,
t.started_at,
t.ended_at,
COALESCE(
t.duration_sec,
CASE
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
ELSE NULL
END AS duration_sec,
END
) AS duration_sec,
t.created_at,
u.username,
u.full_name,
@ -910,8 +794,7 @@ async def list_calls(
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1
) AS contact_company,
s.titel AS sag_titel,
'telefoni'::VARCHAR AS source
s.titel AS sag_titel
FROM telefoni_opkald t
LEFT JOIN users u ON u.user_id = t.bruger_id
LEFT JOIN contacts c ON c.id = t.kontakt_id
@ -1020,38 +903,6 @@ async def list_calls(
@router.patch("/telefoni/calls/{call_id}")
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
if not existing and data.source == "legacy_mission" and data.callid:
promoted = execute_query(
"""
INSERT INTO telefoni_opkald
(callid, direction, ekstern_nummer, started_at, ended_at, duration_sec, raw_payload)
SELECT
m.call_id,
CASE WHEN LOWER(COALESCE(m.state, '')) IN ('outbound', 'udgaaende') THEN 'outbound' ELSE 'inbound' END,
m.caller_number,
m.started_at,
m.ended_at,
CASE
WHEN m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
AND m.ended_at - m.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
THEN EXTRACT(EPOCH FROM (m.ended_at - m.started_at))::int
ELSE NULL
END,
COALESCE(m.last_payload, '{}'::jsonb)
FROM mission_call_state m
WHERE m.call_id = %s
ON CONFLICT (callid) DO NOTHING
RETURNING id
""",
(data.callid.strip(),),
) or []
if promoted:
call_id = int(promoted[0]["id"])
existing = {"id": call_id}
else:
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE callid = %s", (data.callid.strip(),))
if existing:
call_id = int(existing["id"])
if not existing:
raise HTTPException(status_code=404, detail="Call not found")

View File

@ -5,8 +5,6 @@ from typing import Optional
class TelefoniCallLinkUpdate(BaseModel):
sag_id: Optional[int] = None
kontakt_id: Optional[int] = None
callid: Optional[str] = None
source: Optional[str] = None
class TelefoniUserMappingUpdate(BaseModel):
@ -21,8 +19,6 @@ class TelefoniClickToCallRequest(BaseModel):
number: str
extension: Optional[str] = None
user_id: Optional[int] = None
sag_id: Optional[int] = None
contact_id: Optional[int] = None
class SmsSendRequest(BaseModel):

View File

@ -1,5 +1,4 @@
import logging
import re
from datetime import datetime
from typing import Any, Optional
@ -9,24 +8,6 @@ logger = logging.getLogger(__name__)
class TelefoniService:
@staticmethod
def reconcile_stale_calls(max_age_hours: int = 12) -> int:
"""Close the lifecycle state for calls whose phone never sent hangup."""
rows = execute_query(
"""WITH stale AS (
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW()
WHERE ended_at IS NULL AND lifecycle_status='active'
AND started_at < NOW()-(%s * INTERVAL '1 hour')
RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at
), logged AS (
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW()
FROM stale RETURNING id
) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,),
) or []
return int(rows[0].get("count") or 0) if rows else 0
@staticmethod
def find_user_by_extension(extension: Optional[str]) -> list[int]:
"""Find all users with the given extension - returns list of user_ids."""
@ -141,52 +122,6 @@ class TelefoniService:
raw_payload: Any,
started_at: datetime,
) -> dict:
# Reuse the temporary row created by a case click-to-call when the
# phone reports the real PBX call id. This preserves sag_id and avoids
# duplicate rows in the case call history.
if direction == "outbound" and user_id:
pending_rows = execute_query(
"""
SELECT * FROM telefoni_opkald
WHERE callid LIKE 'click-to-call:%'
AND direction = 'outbound'
AND bruger_id = %s
AND ended_at IS NULL
AND started_at >= NOW() - INTERVAL '3 minutes'
ORDER BY started_at DESC
LIMIT 10
""",
(user_id,),
) or []
target_digits = re.sub(r"\D", "", str(ekstern_nummer or ""))[-8:]
pending = next(
(
row for row in pending_rows
if (kontakt_id and row.get("kontakt_id") == kontakt_id)
or (
target_digits
and re.sub(r"\D", "", str(row.get("ekstern_nummer") or "")).endswith(target_digits)
)
),
None,
)
if pending:
rows = execute_query(
"""
UPDATE telefoni_opkald
SET callid = %s,
raw_payload = COALESCE(raw_payload, '{}'::jsonb) || %s::jsonb,
intern_extension = COALESCE(intern_extension, %s),
ekstern_nummer = COALESCE(%s, ekstern_nummer),
kontakt_id = COALESCE(kontakt_id, %s),
started_at = LEAST(started_at, %s)
WHERE id = %s
RETURNING *
""",
(callid, raw_payload, intern_extension, ekstern_nummer, kontakt_id, started_at, pending["id"]),
)
return rows[0] if rows else {}
query = """
INSERT INTO telefoni_opkald
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, started_at, raw_payload)
@ -201,7 +136,6 @@ class TelefoniService:
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
, lifecycle_status = 'active', last_event_at = NOW()
RETURNING *
"""
rows = execute_query(
@ -227,21 +161,19 @@ class TelefoniService:
rows = execute_query(
"""
INSERT INTO telefoni_opkald
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
VALUES
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW())
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
ON CONFLICT (callid)
DO UPDATE SET
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
duration_sec = COALESCE(
EXCLUDED.duration_sec,
CASE
WHEN telefoni_opkald.started_at IS NOT NULL
AND NOW() - telefoni_opkald.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
WHEN telefoni_opkald.started_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int, 0)
ELSE NULL
END
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW()
)
RETURNING id
""",
(callid, duration_sec),

View File

@ -63,8 +63,6 @@
{% for call in initial_calls %}
<tr
data-call-id="{{ call.id }}"
data-callid="{{ call.callid or '' }}"
data-source="{{ call.source or 'telefoni' }}"
data-direction="{{ call.direction or '' }}"
data-display-number="{{ call.display_number or '' }}"
data-ekstern-nummer="{{ call.display_number or '' }}"
@ -448,12 +446,11 @@ async function searchContacts(query) {
}
async function patchCallContact(callId, contactId) {
const call = telefoniCallMap.get(Number(callId));
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ kontakt_id: contactId, callid: call?.callid || null, source: call?.source || null })
body: JSON.stringify({ kontakt_id: contactId })
});
if (!res.ok) {
const t = await res.text();
@ -853,12 +850,11 @@ function initLinkSagModalEvents() {
confirmBtn.addEventListener('click', async () => {
if (!linkSagState.callId || !linkSagState.selectedSagId) return;
try {
const call = telefoniCallMap.get(Number(linkSagState.callId));
const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: linkSagState.selectedSagId, callid: call?.callid || null, source: call?.source || null })
body: JSON.stringify({ sag_id: linkSagState.selectedSagId })
});
if (!res.ok) {
const t = await res.text();
@ -958,8 +954,6 @@ function hydrateCallMapFromSsrRows() {
telefoniCallMap.set(callId, {
id: callId,
callid: String(row.dataset.callid || '').trim() || null,
source: String(row.dataset.source || '').trim() || 'telefoni',
direction: String(row.dataset.direction || '').trim() || null,
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,

View File

@ -1 +0,0 @@
"""Administration of content published on bmcnetworks.dk."""

View File

@ -1 +0,0 @@
"""Website content backend."""

View File

@ -1,152 +0,0 @@
from __future__ import annotations
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from PIL import Image, UnidentifiedImageError
from app.core.auth_dependencies import require_permission
from app.modules.website_content.backend.schemas import (
CompleteOperation,
CustomerReferenceCreate,
CustomerReferenceUpdate,
IncidentCreate,
IncidentUpdate,
OperationCreate,
OperationUpdate,
)
from app.modules.website_content.backend.service import NotFoundError, WebsiteContentAPIError, website_content_service
router = APIRouter()
ALLOWED_LOGOS = {"image/png", "image/jpeg", "image/webp", "image/gif"}
MAX_LOGO_BYTES = 5 * 1024 * 1024
def call_service(method, *args, **kwargs):
try:
return method(*args, **kwargs)
except NotFoundError:
raise HTTPException(status_code=404, detail="Indholdet blev ikke fundet")
except WebsiteContentAPIError as exc:
raise HTTPException(status_code=503, detail=str(exc))
@router.get("/customers")
async def list_customers(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "customers", include_hidden)
@router.post("/customers", status_code=201)
async def create_customer(
payload: CustomerReferenceCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "customers", payload.model_dump())
@router.patch("/customers/{item_id}")
async def update_customer(
item_id: int,
payload: CustomerReferenceUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "customers", item_id, payload.model_dump(exclude_unset=True)
)
@router.post("/customers/{item_id}/logo")
async def upload_customer_logo(
item_id: int,
logo: UploadFile = File(...),
_user: dict = Depends(require_permission("website_content.edit")),
):
if logo.content_type not in ALLOWED_LOGOS:
raise HTTPException(status_code=415, detail="Logo skal være PNG, JPEG, WebP eller GIF")
content = await logo.read(MAX_LOGO_BYTES + 1)
if not content or len(content) > MAX_LOGO_BYTES:
raise HTTPException(status_code=413, detail="Logo må højst fylde 5 MB")
try:
image = Image.open(BytesIO(content))
image.verify()
except (UnidentifiedImageError, OSError):
raise HTTPException(status_code=422, detail="Filen er ikke et gyldigt billede")
return call_service(website_content_service.upload_logo, item_id, content, logo.content_type)
@router.get("/customers/{item_id}/logo")
async def preview_customer_logo(
item_id: int,
_user: dict = Depends(require_permission("website_content.view")),
):
content, mime_type = call_service(website_content_service.logo, item_id)
return Response(content=content, media_type=mime_type, headers={"Cache-Control": "private, max-age=60"})
@router.get("/operations")
async def list_operations(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "operations", include_hidden)
@router.post("/operations", status_code=201)
async def create_operation(
payload: OperationCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "operations", payload.model_dump())
@router.patch("/operations/{item_id}")
async def update_operation(
item_id: int,
payload: OperationUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "operations", item_id, payload.model_dump(exclude_unset=True)
)
@router.post("/operations/{item_id}/complete", status_code=201)
async def complete_operation(
item_id: int,
payload: CompleteOperation,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.complete_operation, item_id, payload.ends_at, payload.is_public
)
@router.get("/incidents")
async def list_incidents(
include_hidden: bool = Query(True),
_user: dict = Depends(require_permission("website_content.view")),
):
return call_service(website_content_service.list, "incidents", include_hidden)
@router.post("/incidents", status_code=201)
async def create_incident(
payload: IncidentCreate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(website_content_service.create, "incidents", payload.model_dump())
@router.patch("/incidents/{item_id}")
async def update_incident(
item_id: int,
payload: IncidentUpdate,
_user: dict = Depends(require_permission("website_content.edit")),
):
return call_service(
website_content_service.update, "incidents", item_id, payload.model_dump(exclude_unset=True)
)

View File

@ -1,69 +0,0 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, Field, HttpUrl, model_validator
Severity = Literal["ok", "info", "warning", "critical"]
class CustomerReferenceCreate(BaseModel):
customer_name: str = Field(min_length=1, max_length=180)
logo_url: str | None = Field(default=None, max_length=500)
website_url: HttpUrl | None = None
sort_order: int = Field(default=100, ge=0, le=100000)
is_active: bool = True
class CustomerReferenceUpdate(BaseModel):
customer_name: str | None = Field(default=None, min_length=1, max_length=180)
logo_url: str | None = Field(default=None, max_length=500)
website_url: HttpUrl | None = None
sort_order: int | None = Field(default=None, ge=0, le=100000)
is_active: bool | None = None
class OperationCreate(BaseModel):
title: str = Field(min_length=1, max_length=180)
severity: Severity = "info"
message: str = Field(min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_active: bool = True
@model_validator(mode="after")
def validate_dates(self):
if self.starts_at and self.ends_at and self.ends_at < self.starts_at:
raise ValueError("Sluttid skal ligge efter starttid")
return self
class OperationUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=180)
severity: Severity | None = None
message: str | None = Field(default=None, min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_active: bool | None = None
class IncidentCreate(BaseModel):
title: str = Field(min_length=1, max_length=180)
severity: Severity = "info"
message: str = Field(min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_public: bool = True
class IncidentUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=180)
severity: Severity | None = None
message: str | None = Field(default=None, min_length=1, max_length=20000)
starts_at: datetime | None = None
ends_at: datetime | None = None
is_public: bool | None = None
class CompleteOperation(BaseModel):
ends_at: datetime | None = None
is_public: bool = True

View File

@ -1,99 +0,0 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import httpx
from app.core.config import settings
class NotFoundError(RuntimeError):
pass
class WebsiteContentAPIError(RuntimeError):
pass
class WebsiteContentService:
"""Authenticated HTTPS client for the website administration API."""
def __init__(self, api_url=None, api_token=None, client: httpx.Client | None = None):
self.api_url = (api_url or settings.WEBSITE_CONTENT_API_URL).rstrip("/")
explicit_token = api_token is not None
self.api_token = api_token if explicit_token else settings.WEBSITE_CONTENT_API_TOKEN
if not explicit_token and not self.api_token and settings.WEBSITE_CONTENT_MYSQL_PASSWORD:
self.api_token = settings.WEBSITE_CONTENT_MYSQL_PASSWORD
self.client = client or httpx.Client(timeout=settings.WEBSITE_CONTENT_API_TIMEOUT_SECONDS)
def _request(self, method: str, resource: str, *, item_id=None, action=None,
data=None, files=None, expect_bytes=False, extra_params=None):
if not self.api_url or not self.api_token:
raise WebsiteContentAPIError("Website admin-API er ikke konfigureret")
params = {"resource": resource, **(extra_params or {})}
if item_id is not None:
params["id"] = item_id
if action:
params["action"] = action
headers = {"X-Website-Admin-Token": self.api_token, "Accept": "application/json"}
if settings.WEBSITE_CONTENT_API_HOST_HEADER:
headers["Host"] = settings.WEBSITE_CONTENT_API_HOST_HEADER
try:
response = self.client.request(
method, self.api_url, params=params, headers=headers,
json=self._json_values(data) if data is not None and files is None else None,
files=files,
)
except httpx.HTTPError as exc:
raise WebsiteContentAPIError("Kunne ikke kontakte website admin-API") from exc
if response.status_code == 404:
raise NotFoundError()
if not response.is_success:
try:
payload = response.json()
detail = payload.get("message") or payload.get("error")
except (ValueError, AttributeError):
detail = None
raise WebsiteContentAPIError(detail or f"Website admin-API svarede {response.status_code}")
if expect_bytes:
return response.content, response.headers.get("content-type", "application/octet-stream")
return response.json()
def list(self, kind: str, include_hidden: bool = True) -> list[dict[str, Any]]:
result = self._request("GET", kind, extra_params={"include_hidden": int(include_hidden)})
return result.get("items", result) if isinstance(result, dict) else result
def get(self, kind: str, item_id: int) -> dict[str, Any]:
return self._request("GET", kind, item_id=item_id)
def create(self, kind: str, data: dict[str, Any]) -> dict[str, Any]:
return self._request("POST", kind, data=data)
def update(self, kind: str, item_id: int, data: dict[str, Any]) -> dict[str, Any]:
return self._request("PATCH", kind, item_id=item_id, data=data)
def upload_logo(self, item_id: int, content: bytes, mime_type: str) -> dict[str, Any]:
return self._request("POST", "customers", item_id=item_id, action="logo",
files={"logo": ("logo", content, mime_type)})
def logo(self, item_id: int) -> tuple[bytes, str]:
return self._request("GET", "customers", item_id=item_id, action="logo", expect_bytes=True)
def complete_operation(self, item_id: int, ends_at: datetime | None, is_public: bool):
return self._request("POST", "operations", item_id=item_id, action="complete",
data={"ends_at": ends_at, "is_public": is_public})
@classmethod
def _json_values(cls, data):
if data is None:
return None
return {key: value.isoformat() if isinstance(value, datetime)
else str(value) if cls._is_url(value) else value for key, value in data.items()}
@staticmethod
def _is_url(value: Any) -> bool:
return value is not None and value.__class__.__module__.startswith("pydantic.networks")
website_content_service = WebsiteContentService()

View File

@ -1 +0,0 @@
"""Website content frontend."""

View File

@ -1,19 +0,0 @@
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("/website-content", response_class=HTMLResponse)
async def website_content_page(
request: Request,
current_user: dict = Depends(require_permission("website_content.view")),
):
return templates.TemplateResponse(
"modules/website_content/templates/index.html",
{"request": request, "current_user": current_user},
)

View File

@ -1,11 +0,0 @@
{
"name": "website_content",
"version": "1.0.0",
"description": "Administration af kundereferencer og offentlig driftsstatus på bmcnetworks.dk.",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "",
"api_prefix": "/api/v1/website-content",
"tags": ["Website", "Indhold", "Driftsstatus"]
}

View File

@ -1,127 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Website-indhold · BMC Hub{% endblock %}
{% block extra_css %}
<style>
.wc-shell { max-width: 1500px; margin: 0 auto; }
.wc-hero { background: linear-gradient(135deg, #06364b, #087894); color: #fff; border-radius: 22px; padding: 1.5rem; box-shadow: 0 14px 35px rgba(5,54,75,.2); }
.wc-kicker { text-transform: uppercase; letter-spacing: .13em; font-size: .72rem; opacity: .74; font-weight: 700; }
.wc-panel { background: var(--card-bg, #fff); color: var(--text-color, #17202a); border: 1px solid var(--border-color, #dde4e8); border-radius: 18px; overflow: hidden; }
.wc-panel-head { padding: 1rem 1.15rem; border-bottom: 1px solid var(--border-color, #dde4e8); display:flex; align-items:center; justify-content:space-between; gap:1rem; }
.wc-list { display:grid; gap:.75rem; padding:1rem; }
.wc-row { border:1px solid var(--border-color, #dde4e8); border-radius:14px; padding:1rem; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:1rem; align-items:center; background:var(--body-bg, #fff); }
.wc-logo { width:92px; height:54px; object-fit:contain; border-radius:8px; padding:5px; background:#fff; border:1px solid #e5e7eb; }
.wc-meta { color:var(--text-muted, #68737d); font-size:.84rem; }
.wc-severity { display:inline-flex; align-items:center; gap:.35rem; border-radius:999px; padding:.25rem .6rem; font-size:.75rem; font-weight:700; }
.wc-severity.ok { color:#087b4d; background:#d9f8ea; } .wc-severity.info { color:#1261a0; background:#ddebfa; }
.wc-severity.warning { color:#926300; background:#fff0c2; } .wc-severity.critical { color:#b42318; background:#fee4e2; }
.wc-hidden { opacity:.55; }
.wc-empty { text-align:center; color:var(--text-muted, #68737d); padding:2.5rem 1rem; }
.wc-tabs { display:flex; gap:.5rem; overflow:auto; padding-bottom:.25rem; }
.wc-tabs button { white-space:nowrap; border-radius:999px; }
.form-control, .form-select { background-color:var(--card-bg, #fff); color:var(--text-color, #17202a); border-color:var(--border-color, #ced4da); }
@media(max-width:700px) { .wc-row { grid-template-columns:1fr; } .wc-actions { width:100%; display:flex; } .wc-actions .btn { flex:1; } }
</style>
{% endblock %}
{% block content %}
{% set can_edit = current_user.is_superadmin or 'website_content.edit' in (current_user.permissions or []) %}
<main class="container-fluid py-4 px-lg-4 wc-shell" data-can-edit="{{ 'true' if can_edit else 'false' }}">
<section class="wc-hero mb-4 d-flex flex-wrap justify-content-between align-items-end gap-3">
<div><div class="wc-kicker mb-2">Nordic Top · website administration</div><h1 class="h3 mb-2">Website-indhold</h1><p class="mb-0 opacity-75">Administrér kundelogoer, aktuel driftsstatus og offentlig historik.</p></div>
<div id="connectionState" class="small opacity-75"><i class="bi bi-circle-fill me-1"></i> Forbinder…</div>
</section>
<div class="wc-tabs mb-3" role="tablist">
<button class="btn btn-primary" data-tab="customers"><i class="bi bi-buildings me-1"></i>Kundereferencer</button>
<button class="btn btn-outline-secondary" data-tab="operations"><i class="bi bi-broadcast me-1"></i>Aktuel drift</button>
<button class="btn btn-outline-secondary" data-tab="incidents"><i class="bi bi-clock-history me-1"></i>Historik</button>
</div>
<section class="wc-panel" id="contentPanel">
<div class="wc-panel-head">
<div><h2 class="h5 mb-1" id="panelTitle">Kundereferencer</h2><div class="wc-meta" id="panelSubtitle">Rækkefølge og logoer på forsiden</div></div>
{% if can_edit %}<button class="btn btn-primary" id="createBtn"><i class="bi bi-plus-lg me-1"></i>Opret</button>{% endif %}
</div>
<div class="wc-list" id="contentList"><div class="wc-empty"><div class="spinner-border spinner-border-sm me-2"></div>Indlæser…</div></div>
</section>
</main>
<div class="modal fade" id="editorModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"><form class="modal-content" id="editorForm">
<div class="modal-header"><h2 class="modal-title fs-5" id="editorTitle">Redigér</h2><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body" id="editorFields"></div>
<div class="modal-footer"><button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" type="submit">Gem</button></div>
</form></div>
</div>
<div class="toast-container position-fixed bottom-0 end-0 p-3"><div id="wcToast" class="toast"><div class="toast-body" id="wcToastText"></div></div></div>
{% endblock %}
{% block extra_js %}
<script>
(() => {
const api = '/api/v1/website-content';
const canEdit = document.querySelector('.wc-shell').dataset.canEdit === 'true';
const labels = {
customers: ['Kundereferencer', 'Rækkefølge og logoer på forsiden'],
operations: ['Aktuel driftsstatus', 'Aktive, kommende og skjulte meddelelser'],
incidents: ['Driftshistorik', 'Offentlige og skjulte afsluttede hændelser']
};
let tab = 'customers', items = [], editing = null;
const modal = new bootstrap.Modal(document.getElementById('editorModal'));
const esc = value => String(value ?? '').replace(/[&<>'"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const localInput = value => value ? String(value).replace(' ', 'T').slice(0,16) : '';
const formatDate = value => value ? new Intl.DateTimeFormat('da-DK', {dateStyle:'medium', timeStyle:'short'}).format(new Date(String(value).replace(' ', 'T'))) : 'Ikke angivet';
const toast = (message, error=false) => { const el=document.getElementById('wcToast'); el.classList.toggle('text-bg-danger', error); el.classList.toggle('text-bg-success', !error); document.getElementById('wcToastText').textContent=message; bootstrap.Toast.getOrCreateInstance(el).show(); };
async function request(path, options={}) {
const response = await fetch(api + path, {credentials:'include', ...options});
if (!response.ok) { let detail='Handlingen mislykkedes'; try { detail=(await response.json()).detail || detail; } catch {} throw new Error(detail); }
return response.status === 204 ? null : response.json();
}
async function load() {
const list=document.getElementById('contentList'); list.innerHTML='<div class="wc-empty"><div class="spinner-border spinner-border-sm me-2"></div>Indlæser…</div>';
try { items=await request(`/${tab}`); render(); document.getElementById('connectionState').innerHTML='<i class="bi bi-check-circle-fill me-1"></i> Website-database forbundet'; }
catch(error) { list.innerHTML=`<div class="wc-empty text-danger"><i class="bi bi-exclamation-triangle fs-3 d-block mb-2"></i>${esc(error.message)}</div>`; document.getElementById('connectionState').innerHTML='<i class="bi bi-x-circle-fill me-1"></i> Ikke forbundet'; }
}
function render() {
const list=document.getElementById('contentList');
if (!items.length) { list.innerHTML='<div class="wc-empty">Der er endnu ikke oprettet indhold her.</div>'; return; }
list.innerHTML=items.map(item => tab==='customers' ? customerRow(item) : operationRow(item)).join('');
list.querySelectorAll('[data-edit]').forEach(button => button.onclick=()=>openEditor(Number(button.dataset.edit)));
list.querySelectorAll('[data-toggle]').forEach(button => button.onclick=()=>toggleVisibility(Number(button.dataset.toggle)));
list.querySelectorAll('[data-complete]').forEach(button => button.onclick=()=>complete(Number(button.dataset.complete)));
}
function customerRow(item) {
const active=!!item.is_active, preview=`${api}/customers/${item.id}/logo?v=${encodeURIComponent(item.updated_at || '')}`;
return `<article class="wc-row ${active?'':'wc-hidden'}"><div class="d-flex align-items-center gap-3"><img class="wc-logo" src="${preview}" onerror="this.onerror=null;this.src='${esc(item.logo_url || '')}'" alt=""><div><div class="fw-semibold">${esc(item.customer_name)}</div><div class="wc-meta">Placering ${item.sort_order} · ${active?'Synlig':'Skjult'}</div>${item.website_url?`<a class="small" href="${esc(item.website_url)}" target="_blank" rel="noopener">${esc(item.website_url)}</a>`:''}</div></div>${actions(item, active)}`;
}
function operationRow(item) {
const visibility=tab==='operations' ? !!item.is_active : !!item.is_public;
return `<article class="wc-row ${visibility?'':'wc-hidden'}"><div><div class="d-flex flex-wrap align-items-center gap-2 mb-1"><span class="wc-severity ${item.severity}">${esc(item.severity)}</span><strong>${esc(item.title)}</strong></div><div class="mb-2">${esc(item.message)}</div><div class="wc-meta">${formatDate(item.starts_at)} → ${formatDate(item.ends_at)} · ${visibility?(tab==='operations'?'Aktiv':'Offentlig'):'Skjult'}</div></div>${actions(item, visibility)}`;
}
function actions(item, visible) {
if (!canEdit) return '';
return `<div class="wc-actions d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" data-edit="${item.id}"><i class="bi bi-pencil"></i><span class="visually-hidden">Redigér</span></button>${tab==='operations'&&item.is_active?`<button class="btn btn-sm btn-success" data-complete="${item.id}"><i class="bi bi-check2-circle me-1"></i>Afslut</button>`:''}<button class="btn btn-sm btn-outline-secondary" data-toggle="${item.id}">${visible?'Skjul':'Vis'}</button></div>`;
}
function openEditor(id=null) {
editing=id ? items.find(item=>item.id===id) : null;
document.getElementById('editorTitle').textContent=(editing?'Redigér ':'Opret ') + labels[tab][0].toLowerCase();
document.getElementById('editorFields').innerHTML=tab==='customers' ? customerFields(editing||{}) : operationFields(editing||{});
modal.show();
}
function customerFields(v) { return `<div class="row g-3"><div class="col-md-8"><label class="form-label">Kundenavn</label><input class="form-control" name="customer_name" maxlength="180" required value="${esc(v.customer_name)}"></div><div class="col-md-4"><label class="form-label">Rækkefølge</label><input class="form-control" type="number" min="0" name="sort_order" value="${v.sort_order ?? 100}"></div><div class="col-12"><label class="form-label">Website-link</label><input class="form-control" type="url" name="website_url" value="${esc(v.website_url)}" placeholder="https://…"></div><div class="col-12"><label class="form-label">Logo</label><input class="form-control" type="file" name="logo" accept="image/png,image/jpeg,image/webp,image/gif"><div class="form-text">PNG, JPEG, WebP eller GIF · maks. 5 MB. Tomt felt beholder eksisterende logo.</div><img id="logoPreview" class="wc-logo mt-2 d-none" alt="Logo-preview"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="is_active" id="activeCheck" ${v.is_active===0?'':'checked'}><label class="form-check-label" for="activeCheck">Synlig på websitet</label></div></div>`; }
function operationFields(v) { const visibility=tab==='operations'?'is_active':'is_public'; return `<div class="row g-3"><div class="col-md-8"><label class="form-label">Titel</label><input class="form-control" name="title" maxlength="180" required value="${esc(v.title)}"></div><div class="col-md-4"><label class="form-label">Severity</label><select class="form-select" name="severity">${['ok','info','warning','critical'].map(x=>`<option ${v.severity===x?'selected':''}>${x}</option>`).join('')}</select></div><div class="col-12"><label class="form-label">Meddelelse</label><textarea class="form-control" name="message" rows="5" required>${esc(v.message)}</textarea></div><div class="col-md-6"><label class="form-label">Planlagt start</label><input class="form-control" type="datetime-local" name="starts_at" value="${localInput(v.starts_at)}"></div><div class="col-md-6"><label class="form-label">Planlagt slut</label><input class="form-control" type="datetime-local" name="ends_at" value="${localInput(v.ends_at)}"></div><div class="col-12 form-check ms-2"><input class="form-check-input" type="checkbox" name="${visibility}" id="visibleCheck" ${v[visibility]===0?'':'checked'}><label class="form-check-label" for="visibleCheck">${tab==='operations'?'Aktiv':'Offentlig'}</label></div></div>`; }
document.getElementById('editorFields').addEventListener('change', event => { if(event.target.name==='logo'&&event.target.files[0]) { const p=document.getElementById('logoPreview'); p.src=URL.createObjectURL(event.target.files[0]); p.classList.remove('d-none'); } });
document.getElementById('editorForm').onsubmit=async event => {
event.preventDefault(); const form=new FormData(event.target), path=`/${tab}${editing?'/'+editing.id:''}`;
const payload=tab==='customers' ? {customer_name:form.get('customer_name'), website_url:form.get('website_url')||null, sort_order:Number(form.get('sort_order')), is_active:form.has('is_active')} : {title:form.get('title'), severity:form.get('severity'), message:form.get('message'), starts_at:form.get('starts_at')||null, ends_at:form.get('ends_at')||null, [tab==='operations'?'is_active':'is_public']:form.has(tab==='operations'?'is_active':'is_public')};
try { const saved=await request(path,{method:editing?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); const logo=form.get('logo'); if(tab==='customers'&&logo&&logo.size){const data=new FormData();data.append('logo',logo);await request(`/customers/${saved.id}/logo`,{method:'POST',body:data});} modal.hide(); toast('Indholdet er gemt'); await load(); } catch(error){toast(error.message,true);}
};
async function toggleVisibility(id) { const item=items.find(x=>x.id===id), field=tab==='incidents'?'is_public':'is_active'; try{await request(`/${tab}/${id}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({[field]:!item[field]})});await load();}catch(error){toast(error.message,true);} }
async function complete(id) { if(!confirm('Afslut hændelsen og flyt den til historikken?'))return; try{await request(`/operations/${id}/complete`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_public:true})});toast('Hændelsen er flyttet til historikken');await load();}catch(error){toast(error.message,true);} }
document.querySelectorAll('[data-tab]').forEach(button=>button.onclick=()=>{tab=button.dataset.tab;document.querySelectorAll('[data-tab]').forEach(x=>x.className='btn btn-outline-secondary');button.className='btn btn-primary';document.getElementById('panelTitle').textContent=labels[tab][0];document.getElementById('panelSubtitle').textContent=labels[tab][1];load();});
document.getElementById('createBtn')?.addEventListener('click',()=>openEditor()); load();
})();
</script>
{% endblock %}

View File

@ -1,58 +0,0 @@
"""Server-side product cost filtering, including nested snapshots and audit records."""
import json
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from fastapi.routing import APIRoute
from app.core.auth_dependencies import get_current_user, security
from app.core.auth_service import AuthService
COST_KEYS = {'cost_price', 'supplier_price', 'purchase_price', 'costPrice', 'unitCostPrice',
'marginInBaseCurrency', 'marginPercentage', 'economic_snapshot', 'cost_currency',
'purchasePrice', 'buy_price', 'cost', 'supplier_cost'}
def without_cost(value):
if isinstance(value, list):
return [without_cost(v) for v in value if not (isinstance(v, dict) and
(v.get('price_type') in COST_KEYS or any(word in str(v.get('event_type', '')) for word in ('supplier', 'cost'))))]
if isinstance(value, dict):
return {key: without_cost(val) for key, val in value.items() if key not in COST_KEYS}
return value
class ProductAccessRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def secured(request):
actor = await get_current_user(request, await security(request))
def permitted(code):
return bool(actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], code))
can_cost = permitted('economic.cost.view')
# Supplier offers and gateway payloads may contain provider-specific price aliases.
if not can_cost and any(segment in request.url.path for segment in ('supplier', 'apigateway', 'gateway')):
raise HTTPException(403, 'Du mangler rettighed til leverandørpriser')
if request.method not in ('GET', 'HEAD') and not permitted('products.update'):
raise HTTPException(403, 'Du mangler rettighed til produktredigering')
if request.url.path.endswith('/apigateway-sync') and request.query_params.get('auto_create', 'true').lower() not in ('false','0') and not permitted('products.update'):
raise HTTPException(403, 'Automatisk vareoprettelse kræver produktredigering')
if request.method == 'POST' and request.url.path.endswith('/price') and not permitted('economic.pricing.manage'):
raise HTTPException(403, 'Du mangler rettighed til prisændringer')
if request.method == 'PATCH' and request.path_params.get('product_id') and 'name' in await request.json():
from app.core.database import execute_query_single
linked = execute_query_single('SELECT economic_product_number FROM products WHERE id=%s', (request.path_params['product_id'],))
if linked and linked.get('economic_product_number') and not permitted('economic.catalog.manage'):
raise HTTPException(403, 'Du mangler rettighed til navneændring af en e-conomic-vare')
response = await handler(request)
if 'application/json' in response.headers.get('content-type', ''):
body = json.loads(response.body)
if not can_cost:
body = without_cost(body)
if request.method == 'GET' and isinstance(body, dict) and 'id' in body and 'name' in body:
body['_permissions'] = {'cost_view':can_cost,'update':permitted('products.update'),
'pricing':permitted('economic.pricing.manage')}
headers = {k:v for k,v in response.headers.items() if k not in ('content-length', 'content-type')}
return JSONResponse(body, status_code=response.status_code, headers=headers, background=response.background)
return response
return secured

Some files were not shown because too many files have changed in this diff Show More