Compare commits

..

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

184 changed files with 1456 additions and 18903 deletions

View File

@ -3,7 +3,6 @@
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk HUB_BASE_URL=https://hub.bmcnetworks.dk
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose) # Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub POSTGRES_USER=bmc_hub

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.7.2

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> </div>
<script> <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) => { document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@ -136,7 +131,8 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
return; return;
} }
window.location.href = loginRedirectTarget; // Redirect to dashboard
window.location.href = '/';
} else { } else {
// Show error // Show error
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.'; errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
@ -173,7 +169,8 @@ if (token) {
d.setTime(d.getTime() + (24*60*60*1000)); d.setTime(d.getTime() + (24*60*60*1000));
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`; document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
window.location.href = loginRedirectTarget; // Redirect to dashboard
window.location.href = '/';
} else { } else {
// Token invalid, clear storage // Token invalid, clear storage
localStorage.removeItem('access_token'); 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.ollama_service import ollama_service
from app.services.template_service import template_service from app.services.template_service import template_service
from app.services.invoice2data_service import get_invoice2data_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 logging
import os import os
import re import re
@ -27,11 +26,15 @@ router = APIRouter()
_PURCHASE_CASE_TYPE = "indkøb" _PURCHASE_CASE_TYPE = "indkøb"
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = { _INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
"address", "service_address", "monthly_cost", "sales_price", "technology", "address",
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id", "service_address",
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id", "monthly_cost",
"sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed", "technology",
"range_monthly_cost", "range_sales_price", "connection_type",
"circuit_number",
"speed_mbps",
"download_mbps",
"upload_mbps",
} }
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist") SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
@ -264,19 +267,90 @@ def _ensure_internet_change_case(
owner_customer_id: Optional[int], owner_customer_id: Optional[int],
changes: Dict[str, Dict[str, object]], changes: Dict[str, Dict[str, object]],
) -> Optional[int]: ) -> Optional[int]:
outcome = ensure_external_change_case( # Customer ownership, initial activation and internal classification are
connection_id=connection_id, # bookkeeping outcomes of a successful import, not operational incidents.
source_type="globalconnect_invoice", # Only create cases for changes that can affect delivery or billing.
source_key=str(invoice_number), relevant_changes = {
source_label=f"GlobalConnect faktura {invoice_number}", field: change
source_url="/billing/supplier-invoices", for field, change in (changes or {}).items()
reference=reference, if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
connection_name=connection_name, }
provider="GlobalConnect", if not relevant_changes:
owner_customer_id=owner_customer_id, return None
changes=changes,
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( 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) 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: def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
parts = [f"Afventer mapping for {reference}."] parts = [f"Afventer mapping for {reference}."]
if end_customer_name: if end_customer_name:
@ -759,9 +757,11 @@ def _should_assign_internal_bmc_owner(
return False return False
if any(str(line.get("end_customer_name") or "").strip() for line in lines): if any(str(line.get("end_customer_name") or "").strip() for line in lines):
return False return False
description = " ".join(str(line.get("description") or "").lower() for line in lines) if any(_looks_like_ip_range_line(line) for line in lines):
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit") return True
return any(marker in description for marker in explicit_shared_markers) if len(lines) > 1:
return True
return bool(service_address)
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str: 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]: def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
canonical_cidr = _canonical_ip_network(cidr) matches = execute_query(
candidates = execute_query(
""" """
SELECT id, cidr SELECT id
FROM internet_connections_ip_ranges FROM internet_connections_ip_ranges
WHERE connection_id = %s WHERE connection_id = %s
AND cidr = %s
AND deleted_at IS NULL AND deleted_at IS NULL
ORDER BY id ORDER BY id
""", """,
(connection_id,), (connection_id, cidr),
) or [] )
matches = [
row for row in candidates
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
]
if not matches: if not matches:
return None 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]: def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
if not reference: if not reference:
return [] return []
match_keys = sorted(_provider_reference_match_keys(reference))
rows = execute_query( rows = execute_query(
""" """
SELECT id, customer_id, address, monthly_cost, technology, connection_type, SELECT id, customer_id, address, monthly_cost, technology, connection_type,
circuit_number, speed_mbps, download_mbps, upload_mbps, status, 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 FROM internet_connections_connections
WHERE deleted_at IS NULL WHERE deleted_at IS NULL
AND provider ILIKE 'GlobalConnect%%' 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 ORDER BY id
""", """,
(match_keys,), (reference,),
) or [] ) or []
return [dict(row) for row in rows] 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: if existing and merge_ids:
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"])) _merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
# Supplier data may suggest a customer name, but customer ownership is matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
# always a manual CRM decision. Existing manually selected owners survive if not matched_customer and existing and existing.get("customer_id"):
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL. # Preserve a previously reviewed owner when the new invoice has no
matched_customer = None # unambiguous customer name/address instead of replacing it with BMC.
matched_customer = next(
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
None,
)
description = str(primary_line.get("description") or reference) description = str(primary_line.get("description") or reference)
end_customer_name = str(primary_line.get("end_customer_name") or "").strip() end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
# Internal BMC ownership is only a default for a newly discovered # Internal BMC ownership is only a default for a newly discovered
# connection. An existing connection with no customer may deliberately be # connection. An existing connection with no customer may deliberately be
# unassigned and must not gain an owner merely because a later invoice is # unassigned and must not gain an owner merely because a later invoice is
# ambiguous. # ambiguous.
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address) internal_owner = (
internal_owner = None _resolve_internal_bmc_customer()
owner_customer = None if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address)
is_confident = False else None
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}" )
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")) 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"))) 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}" base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference) 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) download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
target_status = "pending" target_status = "active" if (is_confident or internal_owner) else "pending"
shared_value_type = "delefiber" if is_shared_candidate else "other" shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
payload = ( payload = (
connection_name, connection_name,
"GlobalConnect A/S", "GlobalConnect A/S",
@ -1258,27 +1267,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
upload_mbps, upload_mbps,
download_mbps, download_mbps,
note_text, note_text,
"shared" if is_shared_candidate else "dedicated", "shared" if internal_owner and not matched_customer else "dedicated",
shared_value_type, shared_value_type,
None, None,
) )
if existing: 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 = { updated_snapshot = {
"customer_id": existing.get("customer_id"), "customer_id": owner_customer["id"] if owner_customer else None,
"address": service_address, "address": service_address,
"monthly_cost": monthly_cost, "monthly_cost": monthly_cost,
"technology": _infer_technology(description), "technology": _infer_technology(description),
@ -1288,9 +1284,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
"download_mbps": download_mbps, "download_mbps": download_mbps,
"upload_mbps": upload_mbps, "upload_mbps": upload_mbps,
"status": target_status, "status": target_status,
"allocation_model": resolved_allocation_model, "allocation_model": "shared" if internal_owner and not matched_customer else "dedicated",
"value_type": resolved_value_type, "value_type": shared_value_type,
"value_label": resolved_value_label, "value_label": None,
} }
update_payload = ( update_payload = (
connection_name, connection_name,
@ -1305,9 +1301,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
download_mbps, download_mbps,
upload_mbps, upload_mbps,
note_text, note_text,
resolved_allocation_model, "shared" if internal_owner and not matched_customer else "dedicated",
resolved_value_type, shared_value_type,
resolved_value_label, None,
existing["id"], existing["id"],
) )
execute_update( execute_update(
@ -1337,7 +1333,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
update_payload[1], update_payload[1],
update_payload[2], update_payload[2],
update_payload[3], update_payload[3],
target_status, "active" if (is_confident or internal_owner) else "pending",
update_payload[4], update_payload[4],
update_payload[5], update_payload[5],
update_payload[6], update_payload[6],
@ -1402,7 +1398,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
payload[1], payload[1],
payload[2], payload[2],
payload[3], payload[3],
target_status, "active" if (is_confident or internal_owner) else "pending",
payload[4], payload[4],
payload[5], payload[5],
payload[6], 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): 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: if not connection_id or not cidr:
return None return None
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip() display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
service_address = _build_service_address(line) service_address = _build_service_address(line)
# Never infer range ownership from invoice text. A user must select it. matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching())
matched_customer = None
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr) canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
existing = execute_query_single( existing = execute_query_single(
""" """
@ -1512,16 +1507,12 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
{"cidr": cidr, "changes": changes}, {"cidr": cidr, "changes": changes},
) )
reference = display_reference or cidr 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( _ensure_internet_change_case(
connection_id=connection_id, connection_id=connection_id,
invoice_number=invoice_number, invoice_number=invoice_number,
reference=reference, reference=reference,
connection_name=f"IP-range {cidr}", 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, changes=changes,
) )
return range_id 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}", f"IP-range {cidr} oprettet fra faktura {invoice_number}",
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]}, {"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 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]: def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
"""Use an existing CIDR as the strongest key, but never cross service addresses.""" """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: if not cidr:
return {"connection_id": None, "conflict_reason": None} return {"connection_id": None, "conflict_reason": None}
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line) service_address = _build_service_address(line)
rows = execute_query( 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.address AS connection_address,
connection.circuit_number AS connection_reference connection.circuit_number AS connection_reference
FROM internet_connections_ip_ranges range FROM internet_connections_ip_ranges range
JOIN internet_connections_connections connection ON connection.id = range.connection_id 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.deleted_at IS NULL
AND connection.provider ILIKE 'GlobalConnect%%'
ORDER BY range.id ORDER BY range.id
""", """,
(), (cidr,),
) or [] ) or []
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
if reference: if reference:
matching_reference = [ matching_reference = [
row for row in rows 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: for audit_index, line in ip_range_lines:
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id")) reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
service_address = _build_service_address(line) service_address = _build_service_address(line)
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
existing_range_resolution = _resolve_existing_ip_range_connection(line) 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 skipped_orphan_ip_ranges += 1
line_audit[audit_index]["status"] = "skipped" line_audit[audit_index]["status"] = "skipped"
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"] line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
continue continue
mapped_reference_connection_id = connection_map.get(reference) connection_id = existing_range_resolution.get("connection_id") or 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
resolved_from_existing = False resolved_from_existing = False
matched_by_reference = bool( if existing_range_resolution.get("connection_id"):
not address_range_connection_id
and (reference_connection_id or mapped_reference_connection_id)
)
if reference_connection_id or address_range_connection_id:
resolved_from_existing = True resolved_from_existing = True
corrected_service_address = None
if connection_id and not _connection_can_host_ip_range(connection_id, service_address): if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
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 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: if not connection_id and reference:
connection_id, connection_conflict_reason = (None, None) if not service_address:
if 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) connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
if connection_id: if connection_id:
connection_map[reference] = 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]["status"] = "skipped"
line_audit[audit_index]["reason"] = connection_conflict_reason line_audit[audit_index]["reason"] = connection_conflict_reason
continue 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) 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"): if existing_range_resolution.get("canonical_reference"):
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"] sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
sync_line["circuit_id"] = 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), connection_groups=len(grouped_connections),
ip_range_candidates=len(ip_range_lines), 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 { return {
"skipped": False, "skipped": False,
@ -1993,7 +1897,6 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
"line_audit": line_audit, "line_audit": line_audit,
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"], "skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
"verification": verification, "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): while i < len(lines_arr):
line = lines_arr[i].strip() 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 # Skip empty or header lines
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE): if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
i += 1 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" # 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 # Whitespace-separated columns: position item_number description quantity unit_price total_price
# Most specific pattern - try first! # 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(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)
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,
)
if layout_match: if layout_match:
unit_price = _parse_invoice_number(layout_match.group(5))
line_total = _parse_invoice_number(layout_match.group(6))
items.append({ items.append({
'line_number': len(items) + 1, 'line_number': len(items) + 1,
'position': layout_match.group(1), 'position': layout_match.group(1),
'item_number': layout_match.group(2), 'item_number': layout_match.group(2),
'sku': layout_match.group(2),
'description': layout_match.group(3).strip(), 'description': layout_match.group(3).strip(),
'quantity': _parse_invoice_number(layout_match.group(4)), 'quantity': layout_match.group(4),
'unit_price': unit_price, 'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
'line_total': line_total, 'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
'total_price': line_total,
'raw_text': line 'raw_text': line
}) })
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...") 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 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 ========== # ========== CRUD OPERATIONS ==========
@router.get("/supplier-invoices") @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'), "_validation_warning": llm_json_data.get('_validation_warning'),
"_vat_warning": llm_json_data.get('_vat_warning'), "_vat_warning": llm_json_data.get('_vat_warning'),
"_validation_details": llm_json_data.get('_validation_details'), "_validation_details": llm_json_data.get('_validation_details'),
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
} }
elif extraction: elif extraction:
# Fallback to extraction table columns if no LLM JSON # 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)) 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") @router.post("/supplier-invoices/ai/analyze")
async def ai_analyze_invoice(request: Dict): async def ai_analyze_invoice(request: Dict):
"""Brug AI til at analysere faktura og foreslå template felter""" """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 # Build enhanced PDF text with instruction
from app.core.config import settings 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. 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! 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_number": "5082481",
"invoice_date": "24/10-25", "invoice_date": "24/10-25",
"total_amount": "1471.20", "total_amount": "1471.20",
"cvr": "26686091", "cvr": "29522790",
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"], "detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
"lines_start": "Nr.VarenrTekst", "lines_start": "Nr.VarenrTekst",
"lines_end": "Subtotal" "lines_end": "Subtotal"
@ -3935,7 +3593,7 @@ VIGTIGT:
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk data - LAD VÆRE med at lave patterns eller line_item regex - kun udtræk data
PDF TEKST: PDF TEKST:
{excerpt} {pdf_text[:2000]}
RETURNER KUN JSON - intet andet!""" RETURNER KUN JSON - intet andet!"""
@ -3945,21 +3603,10 @@ RETURNER KUN JSON - intet andet!"""
if not result: if not result:
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen") 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 logger.info(f"✅ AI analyse gennemført: {result}")
if vendor_id: return result
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
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ AI analyse fejlede: {e}") logger.error(f"❌ AI analyse fejlede: {e}")
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(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: if template_id and confidence >= 0.5:
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})") logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
extracted_fields = template_service.extract_fields(text, template_id) 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) # Check if this is an invoice2data template (ID -1)
is_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'), extracted_fields.get('currency', 'DKK'),
1.0, # invoice2data always 100% confidence 1.0, # invoice2data always 100% confidence
json.dumps(extracted_fields), # llm_response_json 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 # 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}") raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
extracted_fields = _hybrid_validate_invoice(llm_result, text) extracted_fields = llm_result
llm_result = extracted_fields
confidence = llm_result.get('confidence', 0.75) confidence = llm_result.get('confidence', 0.75)
# Post-process: clear own CVR(s) if AI mistakenly returned them # 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'), llm_result.get('document_type', 'invoice'),
confidence, confidence,
json.dumps(llm_result), json.dumps(llm_result),
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated') 'extracted')
) )
# Insert line items if extracted # Insert line items if extracted

View File

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

View File

@ -207,8 +207,6 @@
3. Systemet laver automatisk patterns! 3. Systemet laver automatisk patterns!
</div> </div>
<div id="aiLineItemsResult" class="d-none mb-3"></div>
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn"> <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 <i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
</button> </button>
@ -1372,20 +1370,10 @@ async function autoGenerateTemplate() {
}); });
if (!response.ok) { if (!response.ok) {
let detail = `AI analyse fejlede (HTTP ${response.status})`; throw new Error('AI analyse fejlede');
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);
} }
const result = await response.json(); 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) // Helper to extract value (handles both nested {value, pattern} and flat formats)
const getValue = (field) => field?.value || field; const getValue = (field) => field?.value || field;
@ -1468,7 +1456,6 @@ async function autoGenerateTemplate() {
const linesEnd = result.lines_end?.pattern || result.lines_end; const linesEnd = result.lines_end?.pattern || result.lines_end;
document.getElementById('linesEndPattern').value = linesEnd; document.getElementById('linesEndPattern').value = linesEnd;
} }
renderAiLineItems(result.line_items || [], result.line_count || 0);
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it // 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!'; 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); console.error('AI generation failed:', error);
btn.innerHTML = originalText; btn.innerHTML = originalText;
btn.disabled = false; 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() { async function saveTemplate() {
const vendorId = document.getElementById('vendorSelect').value; const vendorId = document.getElementById('vendorSelect').value;
const templateName = document.getElementById('templateName').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: def _resolve_customer_id() -> int:
# Resolve our own company by identity, never by an arbitrary/old numeric ID. configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
# Customer IDs differ between environments and ID 1 may belong to a supplier. 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( named_row = execute_query_single(
""" """
SELECT c.id SELECT id
FROM customers c FROM customers
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL WHERE LOWER(name) = LOWER(%s)
WHERE LOWER(TRIM(c.name)) = LOWER(%s) ORDER BY id ASC
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
LIMIT 1 LIMIT 1
""", """,
("BMC Networks",), ("BMC Networks",),
@ -113,23 +112,11 @@ def _resolve_customer_id() -> int:
if named_row: if named_row:
return int(named_row["id"]) return int(named_row["id"])
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID) fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
configured_row = execute_query_single( if fallback:
""" return int(fallback["id"])
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"])
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) @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: if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large") raise HTTPException(status_code=400, detail="Screenshot too large")
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png" stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
filename = f"screenshot.{extension}" _create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.{extension}")
_create_sag_file_record(sag_id, filename, content_type, size, stored_name)
# Attach logs as json file # Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8") logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")

View File

@ -37,10 +37,6 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" 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 supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12 ELNET_TIMEOUT_SECONDS: int = 12
@ -123,9 +119,6 @@ class Settings(BaseSettings):
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434" OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2" 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 # Email System Configuration
# IMAP Settings # IMAP Settings
@ -232,8 +225,6 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30 ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000 ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False 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 System Configuration
BACKUP_ENABLED: bool = True 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

@ -891,7 +891,7 @@
</li> </li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt"> <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> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -1241,14 +1241,11 @@
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div> <div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
</div> </div>
<!-- Kommunikation Tab --> <!-- Kontakt Tab -->
<div class="tab-pane fade" id="kontakt"> <div class="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<div> <h5 class="fw-bold mb-0">Kontakt historik</h5>
<h5 class="fw-bold mb-0">Kommunikationshistorik</h5> <div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
<small class="text-muted">Opkald og SMSer med kunden</small>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kommunikationsfilter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button> <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="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button> <button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>

View File

@ -488,11 +488,6 @@
const fetchJson = async (url, options = {}) => { const fetchJson = async (url, options = {}) => {
const res = await fetch(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) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
let message = text || `HTTP ${res.status}`; let message = text || `HTTP ${res.status}`;
@ -530,7 +525,7 @@
}; };
const loadConfig = async () => { 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 const label = !config.enabled
? 'ALSO deaktiveret' ? 'ALSO deaktiveret'
: config.read_only : config.read_only
@ -672,27 +667,10 @@
renderJobLines(); 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 renderMapEditor = (line) => {
const lineId = Number(line.id); const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId]; const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || ''; const query = state.lineCustomerSearchQueries[lineId] || '';
return ` 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="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}"> <div class="map-results mb-2">
${renderCustomerSearchResults(lineId)} ${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>
<div class="d-flex gap-2"> <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'}> <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 renderProductMapEditor = (line) => {
const lineId = Number(line.id); const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId]; const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || ''; const query = state.lineProductSearchQueries[lineId] || '';
return ` 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="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}"> <div class="map-results mb-2">
${renderProductSearchResults(lineId)} ${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>
<div class="d-flex gap-2"> <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'}> <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; state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) { if ((query || '').trim().length < 2) {
state.lineCustomerSearchResults[lineId] = []; state.lineCustomerSearchResults[lineId] = [];
updateCustomerSearchResults(lineId); renderJobLines();
return; return;
} }
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`); const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
if (state.lineCustomerSearchQueries[lineId] !== query) return;
state.lineCustomerSearchResults[lineId] = rows || []; state.lineCustomerSearchResults[lineId] = rows || [];
updateCustomerSearchResults(lineId); renderJobLines();
}; };
const saveCompanyMapping = async (lineId) => { const saveCompanyMapping = async (lineId) => {
@ -892,14 +868,13 @@
state.lineProductSearchQueries[lineId] = query; state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) { if ((query || '').trim().length < 2) {
state.lineProductSearchResults[lineId] = []; state.lineProductSearchResults[lineId] = [];
updateProductSearchResults(lineId); renderJobLines();
return; return;
} }
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`); const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
if (state.lineProductSearchQueries[lineId] !== query) return;
state.lineProductSearchResults[lineId] = rows || []; state.lineProductSearchResults[lineId] = rows || [];
updateProductSearchResults(lineId); renderJobLines();
}; };
const saveProductMapping = async (lineId) => { 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.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier 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)) 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") @router.post("/emails/process")
async def process_emails( async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500), 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}") logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity # Log activity
await activity_logger.log_fetched( activity_logger.log_fetched(
email_id=email_id, email_id=email_id,
source="manual_upload", source="manual_upload",
message_id=email_data.get("message_id", file.filename) metadata={"filename": file.filename}
) )
# Auto-classify # Auto-classify

View File

@ -77,79 +77,6 @@
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card)); 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 { .email-ai-decision {
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color)); border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
border-radius: 12px; border-radius: 12px;
@ -1147,19 +1074,7 @@
? 'Høj sikkerhed' ? 'Høj sikkerhed'
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt'; : confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed kontrollér manuelt';
const email = state.selectedEmail || {};
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
const effects = []; 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) => { (preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
if (row.effect) effects.push(row.effect); if (row.effect) effects.push(row.effect);
}); });
@ -1169,36 +1084,42 @@
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”` WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
)); ));
}); });
}
const uniqueEffects = [...new Set(effects)]; const uniqueEffects = [...new Set(effects)];
const hasActions = uniqueEffects.length > 0; const hasActions = uniqueEffects.length > 0;
const automaticExecution = Boolean(preview.automatic_execution); const automaticExecution = Boolean(preview.automatic_execution);
host.innerHTML = ` host.innerHTML = `
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div> <div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
<div class="d-flex justify-content-between align-items-center gap-2">
<div class="email-ai-type">${escapeHtml(typeLabel)}</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>
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div> <div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
<div class="mt-3">
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
<div class="mt-3">
${renderIdentityAssessment()} ${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 ? ` ${hasActions ? `
<ol class="email-ai-actions mb-2"> <ol class="email-ai-actions mb-3">
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')} ${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
</ol> </ol>
${automaticExecution ? ` ${automaticExecution ? `
<div class="alert alert-danger py-2 small mb-0"> <div class="alert alert-danger py-2 small mb-0">
<i class="bi bi-lightning-charge-fill me-1"></i> <i class="bi bi-lightning-charge-fill me-1"></i>
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match. Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
</div>` : ''} </div>` : `
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</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>
`}
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
</div> </div>
`; `;
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
bindIdentityDecisionActions(); bindIdentityDecisionActions();
} }
@ -1208,12 +1129,7 @@
button.disabled = true; button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…'; button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
try { 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(); await executeWorkflowsCurrent();
}
} finally { } finally {
if (document.body.contains(button)) { if (document.body.contains(button)) {
button.disabled = false; button.disabled = false;
@ -1242,8 +1158,6 @@
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : []; const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
const system = Array.isArray(preview.system_matches) ? preview.system_matches : []; const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
const emailMeta = preview.email || {}; const emailMeta = preview.email || {};
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
&& Boolean(state.selectedEmail?.supplier_id);
const systemHtml = system.map((row) => { const systemHtml = system.map((row) => {
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>'; const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
@ -1255,15 +1169,7 @@
`; `;
}).join(''); }).join('');
const matchingHtml = usesSupplierApprovalFlow const matchingHtml = matching.length
? `<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
? matching.map((wf) => ` ? matching.map((wf) => `
<div class="emails-v2-kv"> <div class="emails-v2-kv">
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div> <div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
@ -1698,48 +1604,26 @@
const hasCustomer = Boolean(email.customer_id); const hasCustomer = Boolean(email.customer_id);
const hasSupplier = Boolean(email.supplier_id); const hasSupplier = Boolean(email.supplier_id);
const hasIdentity = hasCustomer || hasSupplier; 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.className = 'emails-v2-quick-toolbar';
quickActions.innerHTML = email.linked_case_id ? ` quickActions.innerHTML = email.linked_case_id ? `
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3"> <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)} <i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
</a> </a>
` : ` ` : `
<div class="emails-v2-flow-picker"> <div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div> <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'}">
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow"> <i class="bi bi-person-workspace me-1"></i>Kundesag
<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>
</button> </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'}"> <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'}">
${recommendationBadge('create_supplier_invoice')} <i class="bi bi-receipt me-1"></i>Leverandørfaktura
<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> </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'}"> <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'}">
${recommendationBadge('create_accounting_case')} <i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
<span class="flow-description">Opret kundesag hos Bogholderi</span>
</button> </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'}"> <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'}">
${recommendationBadge('archive_entity_mail')} <i class="bi bi-archive me-1"></i>Arkivér 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> </button>
</div> </div>
</div>
`; `;
mailBody.className = 'emails-v2-mail-body'; mailBody.className = 'emails-v2-mail-body';
@ -1777,14 +1661,8 @@
</div> </div>
</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 ? ` ${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> <h6>Allerede knyttet til sag</h6>
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3"> <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)} <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 <i class="bi bi-check2 me-1"></i>Markér mailen behandlet
</button> </button>
</div>` : ` </div>` : `
<details class="border rounded p-3 mb-3"> <div class="emails-v2-card emails-primary-action">
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary> <h6>Opret sag fra denne email</h6>
<div class="mt-3">
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel"> <input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<select id="v2CaseType" class="form-select form-select-sm"> <select id="v2CaseType" class="form-select form-select-sm">
@ -1809,16 +1686,15 @@
<i class="bi bi-plus-lg me-1"></i>Opret <i class="bi bi-plus-lg me-1"></i>Opret
</button> </button>
</div> </div>
</div> </div>`}
</details>`}
<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> <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…"> <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 id="v2SagResults" class="emails-v2-sag-results"></div>
</div> </div>
<div class="border rounded p-3 mb-3"> <div class="emails-v2-card">
<h6>Hurtige mailhandlinger</h6> <h6>Hurtige mailhandlinger</h6>
<div class="emails-v2-actions mb-0"> <div class="emails-v2-actions mb-0">
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary"> <button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
@ -1834,7 +1710,7 @@
</div> </div>
</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> <summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
<div class="emails-v2-actions mt-3 mb-2"> <div class="emails-v2-actions mt-3 mb-2">
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button> <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> <div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
</details> </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> <summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
<h6 class="mt-3">Leverandør faktura</h6> <h6 class="mt-3">Leverandør faktura</h6>
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div> <div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
@ -1867,7 +1743,7 @@
<div id="v2DomainStatus" class="emails-v2-inline-status"></div> <div id="v2DomainStatus" class="emails-v2-inline-status"></div>
</details> </details>
<details class="border rounded p-3"> <details class="emails-v2-card">
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary> <summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({ <pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
email_id: email.id, email_id: email.id,
@ -1879,8 +1755,6 @@
linked_case_id: email.linked_case_id, linked_case_id: email.linked_case_id,
}, null, 2))}</pre> }, null, 2))}</pre>
</details> </details>
</div>
</details>
`; `;
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read))); document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));

View File

@ -29,8 +29,6 @@ async def check_reminders():
try: try:
logger.info("🔔 Checking for pending reminders...") 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) # Step 1: Process queued trigger events (status changes)
queue_count = await _process_reminder_queue() queue_count = await _process_reminder_queue()

View File

@ -8,7 +8,6 @@ Runs daily at 04:00
import logging import logging
from datetime import datetime, date from datetime import datetime, date
import json import json
from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection from app.core.database import execute_query, get_db_connection
@ -21,7 +20,7 @@ from app.services.subscription_billing_calendar import (
logger = logging.getLogger(__name__) 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. Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today - Find active subscriptions where next_invoice_date <= today
@ -109,19 +108,10 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
SELECT 1 FROM subscription_billing_runs br SELECT 1 FROM subscription_billing_runs br
WHERE br.subscription_id = s.id AND br.period_start = s.period_start 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 ORDER BY s.next_invoice_date, s.id
""" """
subscriptions = execute_query(query, tuple(params)) subscriptions = execute_query(query)
if not subscriptions: if not subscriptions:
logger.info("✅ No subscriptions due for invoicing") logger.info("✅ No subscriptions due for invoicing")

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 enum import Enum
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime, date from datetime import datetime, date
@ -89,7 +89,6 @@ class VendorBase(BaseModel):
priority: Optional[int] = 100 priority: Optional[int] = 100
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True is_active: bool = True
is_internet_provider: bool = False
class VendorCreate(VendorBase): class VendorCreate(VendorBase):
@ -104,16 +103,10 @@ class VendorUpdate(BaseModel):
domain: Optional[str] = None domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: 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 contact_person: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_internet_provider: Optional[bool] = None
class Vendor(VendorBase): class Vendor(VendorBase):
@ -166,15 +159,6 @@ class SolutionBase(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc. solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst 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): class SolutionCreate(SolutionBase):
"""Schema for creating a solution""" """Schema for creating a solution"""
@ -187,16 +171,6 @@ class SolutionUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None solution_type: Optional[str] = None
result: 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): class Solution(SolutionBase):
"""Full solution schema""" """Full solution schema"""
@ -205,9 +179,6 @@ class Solution(SolutionBase):
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None 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) model_config = ConfigDict(from_attributes=True)

View File

@ -1,6 +1,6 @@
from typing import Optional 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.backend.service import also_service
from app.modules.also.models.schemas import ( from app.modules.also.models.schemas import (
@ -34,8 +34,7 @@ def _user_id_from_request(request: Request) -> Optional[int]:
@router.get("/also/config") @router.get("/also/config")
async def also_config(response: Response) -> dict: async def also_config() -> dict:
response.headers["Cache-Control"] = "no-store"
return also_service.get_config() 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)) 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: def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
product_name = _normalize_match_key(line.get("product_name")) product_name = _normalize_match_key(line.get("product_name"))
if "microsoftorganizationtenant" not in 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"], "total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
"currency": ["currency", "valuta"], "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"], "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": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"], "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"], "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"], "source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
@ -2024,10 +2008,6 @@ class AlsoService:
quantity = Decimal("1") quantity = Decimal("1")
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0"))) 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)) 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( draft_lines.append(
{ {
@ -2035,7 +2015,7 @@ class AlsoService:
"source_type": "also_cloud", "source_type": "also_cloud",
"source_id": int(line["id"]), "source_id": int(line["id"]),
"reference_id": int(line["import_job_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), "quantity": float(quantity),
"unit": "stk", "unit": "stk",
"unit_price": float(unit_price), "unit_price": float(unit_price),
@ -2052,7 +2032,6 @@ class AlsoService:
"also_material_number": line.get("material_number"), "also_material_number": line.get("material_number"),
"also_vendor": line.get("vendor"), "also_vendor": line.get("vendor"),
"also_import_job_id": int(line.get("import_job_id")), "also_import_job_id": int(line.get("import_job_id")),
"billing_period": charge_period,
}, },
} }
) )
@ -2088,7 +2067,7 @@ class AlsoService:
] ]
execute_query( execute_query(
"""UPDATE ordre_drafts """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""", WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])), (_json_dumps(merged_lines), int(draft["id"])),
) )
@ -2111,10 +2090,10 @@ class AlsoService:
RETURNING id RETURNING id
""", """,
( (
"Abonnementer", f"ALSO Cloud {customer_name} - {period_key}",
customer_id, customer_id,
_json_dumps(draft_lines), _json_dumps(draft_lines),
None, "Genereret fra ALSO Cloud Billing approval",
1, 1,
approved_by_user_id, approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}), _json_dumps({"source": "also_cloud_billing"}),

View File

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

View File

@ -1,8 +1,8 @@
from typing import Optional, Literal from typing import Optional
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request 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_service import AuthService
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
@ -69,10 +69,6 @@ class BottomBarMessageCreatePayload(BaseModel):
message: str message: str
recipient_user_id: Optional[int] = None recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False 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): class BottomBarMessageReadPayload(BaseModel):
@ -570,17 +566,13 @@ async def send_bottom_bar_message(
if recipient_user_id == int(current_user_id): if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv") 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( 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) INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at 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 {} ) or {}
return { return {
@ -590,10 +582,6 @@ async def send_bottom_bar_message(
"from": _resolve_current_user_display_name(current_user), "from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}", "to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text, "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")), "requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
"is_own": True, "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, 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, recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL, 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, requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL 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") logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else: else:
columns = set(_table_columns("bottom_bar_messages")) 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: if "requires_manual_ack" not in columns:
execute_query( 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.sender_user_id,
m.recipient_user_id, m.recipient_user_id,
m.message_text, 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.requires_manual_ack,
m.created_at, m.created_at,
receipt.read_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 FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id 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 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 LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id 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 WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS ( 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 ORDER BY m.created_at DESC, m.id DESC
LIMIT %s 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 [] ) or []
unread_row = execute_query_single( 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", "from": row.get("sender_name") or "Ukendt",
"to": recipient_name, "to": recipient_name,
"text": row.get("message_text") or "", "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")), "requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None, "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_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_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, "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) unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10) recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(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( urgent_cases = execute_query(
""" """
@ -1178,10 +1155,6 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [], "list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {}, "filter_meta": unassigned_open_cases.get("filter_meta") or {},
}, },
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
},
"timer": { "timer": {
"active_count": 1 if timer.get("active") else 0, "active_count": 1 if timer.get("active") else 0,
"list": timer_list, "list": timer_list,

View File

@ -8,164 +8,11 @@ from psycopg2.extras import Json
from datetime import datetime, date from datetime import datetime, date
import os import os
import uuid import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() 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]: def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None: if payload is None:
return 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; border-radius: 24px;
padding: 1.5rem; padding: 1.5rem;
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08); 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 { .detail-panel {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid rgba(15, 76, 117, 0.12); border: 1px solid rgba(15, 76, 117, 0.12);
border-radius: 20px; border-radius: 20px;
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06); 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 { .detail-metric {
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01)); background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
@ -43,10 +27,7 @@
border-radius: 16px; border-radius: 16px;
padding: 1rem; padding: 1rem;
height: 100%; 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 { .detail-metric-label {
color: var(--text-secondary); color: var(--text-secondary);
@ -74,31 +55,6 @@
padding: 0.85rem; 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 { .detail-grid-card.editing {
border-color: rgba(15, 76, 117, 0.28); border-color: rgba(15, 76, 117, 0.28);
background: rgba(15, 76, 117, 0.06); background: rgba(15, 76, 117, 0.06);
@ -279,7 +235,6 @@
} }
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
.detail-read-grid { .detail-read-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -290,8 +245,6 @@
gap: 0.2rem; gap: 0.2rem;
} }
} }
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
</style> </style>
{% endblock %} {% endblock %}
@ -299,16 +252,12 @@
<div class="container-fluid py-4"> <div class="container-fluid py-4">
<div class="detail-hero mb-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="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>
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div> <div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2> <h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
<div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div> <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 class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
</div> </div>
</div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<a href="/economy/internet-connections" class="btn btn-outline-secondary"> <a href="/economy/internet-connections" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Tilbage <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()"> <button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()">
<i class="bi bi-save me-1"></i>Gem ændringer <i class="bi bi-save me-1"></i>Gem ændringer
</button> </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>
</div> </div>
<div class="alert mb-4" id="slaBanner" role="status"> <div class="row g-4 mb-4">
<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="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="detail-metric"> <div class="detail-metric">
<div class="detail-metric-label">Salgspris</div> <div class="detail-metric-label">Salgspris</div>
@ -405,7 +314,7 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-xl-8"> <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"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Grunddata</h5> <h5 class="mb-0">Grunddata</h5>
<span id="detailStatusBadge" class="status-pill inactive">-</span> <span id="detailStatusBadge" class="status-pill inactive">-</span>
@ -418,7 +327,7 @@
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Leverandør</label> <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>
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label">Kredsløb</label> <label class="form-label">Kredsløb</label>
@ -474,13 +383,6 @@
<label class="form-label">Overvågning</label> <label class="form-label">Overvågning</label>
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://..."> <input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
</div> </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"> <div class="col-md-3">
<label class="form-label">Allokering</label> <label class="form-label">Allokering</label>
<select class="form-select" id="fieldAllocationModel"> <select class="form-select" id="fieldAllocationModel">
@ -522,7 +424,7 @@
</div> </div>
</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 class="d-flex justify-content-between align-items-center mb-3">
<div> <div>
<h5 class="mb-0">IP-ranges</h5> <h5 class="mb-0">IP-ranges</h5>
@ -636,16 +538,16 @@
</div> </div>
</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"> <div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Pris og kontrakt</h5> <h5 class="mb-0">Pris og kontrakt</h5>
<div class="small text-muted" id="pricingFeedback"></div> <div class="small text-muted" id="pricingFeedback"></div>
</div> </div>
<div class="row g-2 mb-3"> <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"><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"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></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"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></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="text" class="form-control" id="pricingNotesInput" placeholder="Note"></div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button> <button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
@ -654,12 +556,6 @@
<div id="pricingHistoryList"></div> <div id="pricingHistoryList"></div>
</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="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 class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
@ -684,15 +580,7 @@
</div> </div>
</div> </div>
<div class="detail-panel p-4 mb-4" id="connection-cases"> <div class="detail-panel p-4">
<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">
<h5 class="mb-3">Historik</h5> <h5 class="mb-3">Historik</h5>
<div id="historyList"></div> <div id="historyList"></div>
</div> </div>
@ -821,7 +709,6 @@
<label class="form-label">Noter</label> <label class="form-label">Noter</label>
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea> <textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
</div> </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> </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) { async function extractErrorMessage(response, fallback) {
try { try {
const payload = await response.clone().json(); const payload = await response.clone().json();
@ -1120,12 +1000,11 @@
} }
async function loadLookups() { 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/customers?limit=1000&is_active=true'),
fetch('/api/v1/internet-connections'), fetch('/api/v1/internet-connections'),
fetch('/api/v1/internet-connections/subscription-options?status=active'), fetch('/api/v1/internet-connections/subscription-options?status=active'),
fetch('/api/v1/products'), 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: [] }; const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
@ -1133,9 +1012,6 @@
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : []; connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : []; subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.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('customerLookupList', customerOptions);
populateDatalist( populateDatalist(
@ -1181,46 +1057,8 @@
)).join(''); )).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() { function getSharedHeadOptions() {
return (connectionOptions || []).filter((item) => !item?.parent_id); return (connectionOptions || []).filter((item) => item?.is_shared_head);
} }
async function loadRangesForSharedHead(sharedHeadId) { async function loadRangesForSharedHead(sharedHeadId) {
@ -1321,7 +1159,7 @@
: await loadRangesForSharedHead(headId); : await loadRangesForSharedHead(headId);
const availableRanges = filterAvailableBmcnetRanges(ranges); const availableRanges = filterAvailableBmcnetRanges(ranges);
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => ` 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('')}`; `).join('')}`;
hint.textContent = availableRanges.length hint.textContent = availableRanges.length
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.` ? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
@ -1331,10 +1169,8 @@
} }
function populateBmcnetWizard(connection) { function populateBmcnetWizard(connection) {
const canCreateBmcnet = Boolean(connection && !connection.parent_id); document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet); if (!connection?.is_shared_head) return;
if (!canCreateBmcnet) return;
const today = new Date().toISOString().slice(0, 10);
document.getElementById('bmcnetWizardCustomerLookup').value = ''; document.getElementById('bmcnetWizardCustomerLookup').value = '';
document.getElementById('bmcnetWizardCustomerId').value = ''; document.getElementById('bmcnetWizardCustomerId').value = '';
@ -1343,15 +1179,7 @@
document.getElementById('bmcnetWizardAddress').value = connection.address || ''; document.getElementById('bmcnetWizardAddress').value = connection.address || '';
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly'; document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
document.getElementById('bmcnetWizardBillingDay').value = '1'; document.getElementById('bmcnetWizardBillingDay').value = '1';
document.getElementById('bmcnetWizardPeriodStart').value = today; document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
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('bmcnetWizardInternetProduct').value = ''; document.getElementById('bmcnetWizardInternetProduct').value = '';
document.getElementById('bmcnetWizardIpProduct').value = ''; document.getElementById('bmcnetWizardIpProduct').value = '';
document.getElementById('bmcnetWizardInternetPrice').value = ''; document.getElementById('bmcnetWizardInternetPrice').value = '';
@ -1369,7 +1197,7 @@
} }
function openBmcnetWizard() { function openBmcnetWizard() {
if (!currentConnection || currentConnection.parent_id) return; if (!currentConnection?.is_shared_head) return;
populateBmcnetWizard(currentConnection); populateBmcnetWizard(currentConnection);
if (!bmcnetWizardModal) { if (!bmcnetWizardModal) {
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal')); bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
@ -1386,21 +1214,7 @@
input.value = ''; input.value = '';
return; return;
} }
const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value)); input.value = option.dataset.price || '';
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();
} }
async function createBmcnetConnection() { async function createBmcnetConnection() {
@ -1425,15 +1239,7 @@
address: document.getElementById('bmcnetWizardAddress').value.trim() || null, address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly', billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1), 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, 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_product_id: internetProductId,
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== '' internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
? Number(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() { async function loadConnection() {
await loadLookups(); await loadLookups();
@ -1619,7 +1320,6 @@
pricingHistoryResponse, pricingHistoryResponse,
contractsResponse, contractsResponse,
historyResponse, historyResponse,
casesResponse,
crossFieldPortsResponse, crossFieldPortsResponse,
] = await Promise.all([ ] = await Promise.all([
fetch(`/api/v1/internet-connections/${connectionId}`), fetch(`/api/v1/internet-connections/${connectionId}`),
@ -1630,7 +1330,6 @@
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`), fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
fetch('/api/v1/internet-connections/contracts'), fetch('/api/v1/internet-connections/contracts'),
fetch(`/api/v1/internet-connections/${connectionId}/history`), fetch(`/api/v1/internet-connections/${connectionId}/history`),
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`), fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
]); ]);
@ -1641,28 +1340,15 @@
} }
const connection = await connectionResponse.json(); const connection = await connectionResponse.json();
const ranges = await safeJson(rangesResponse, []); const ranges = await rangesResponse.json();
const addresses = await safeJson(addressesResponse, []); const addresses = await addressesResponse.json();
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 }); const summary = await summaryResponse.json();
const pricing = await safeJson(pricingResponse, {}); const pricing = await pricingResponse.json();
const pricingHistory = await safeJson(pricingHistoryResponse, []); const pricingHistory = await pricingHistoryResponse.json();
const contracts = await safeJson(contractsResponse, []); const contracts = await contractsResponse.json();
const history = await safeJson(historyResponse, []); const history = await historyResponse.json();
const cases = await safeJson(casesResponse, []);
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} }); 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; currentConnection = connection;
currentAddresses = Array.isArray(addresses) ? addresses : []; currentAddresses = Array.isArray(addresses) ? addresses : [];
currentRanges = Array.isArray(ranges) ? ranges : []; currentRanges = Array.isArray(ranges) ? ranges : [];
@ -1670,35 +1356,15 @@
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), []) ? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
: []; : [];
await renderAllocationBanner(connection);
renderCore(connection, pricing, summary); renderCore(connection, pricing, summary);
renderRanges(currentRanges); renderRanges(currentRanges);
renderAddresses(currentAddresses); renderAddresses(currentAddresses);
renderPricing(pricing, pricingHistory); renderPricing(pricing, pricingHistory);
renderHistory(history); renderHistory(history);
renderConnectionCases(cases);
renderRelationGrid(connection); renderRelationGrid(connection);
renderBmcnetChildren(connection, currentBmcnetChildren); renderBmcnetChildren(connection, currentBmcnetChildren);
await loadDelefiberProductPrices();
renderContractsOverview(contracts); renderContractsOverview(contracts);
renderCrossFieldPorts(crossFieldPorts); 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) { function renderCrossFieldPorts(payload) {
@ -1740,7 +1406,6 @@
function renderCore(connection, pricing, summary) { function renderCore(connection, pricing, summary) {
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse'; 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 ownerText = connection.customer_name || 'Ingen kunde koblet på';
const businessText = connection.allocation_model === 'shared' const businessText = connection.allocation_model === 'shared'
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er` ? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
@ -1755,23 +1420,19 @@
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : []; const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
const totalDown = Number(connection.download_mbps || 0); const totalDown = Number(connection.download_mbps || 0);
const totalUp = Number(connection.upload_mbps || 0); const totalUp = Number(connection.upload_mbps || 0);
// Only explicit child allocations count as consumed capacity. A nominal const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0);
// connection speed is not proof that bandwidth was allocated to a customer. const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0);
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 downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0; const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0; const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`; document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
document.getElementById('metricSales').textContent = formatDKK(effectiveSales); document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase); document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin); document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0)); document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0));
document.getElementById('metricBandwidth').textContent = childConnections.length document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
? `${usedDown} Mbps allokeret` document.getElementById('metricBandwidthSub').textContent = totalUp
: '0 Mbps allokeret'; ? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp : `${downPct}%`;
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
: 'Kapacitet ikke registreret';
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>`; 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 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>` ? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>`
: '<span class="detail-read-muted">-</span>'; : '<span class="detail-read-muted">-</span>';
renderSla(connection);
document.getElementById('coreReadView').innerHTML = ` document.getElementById('coreReadView').innerHTML = `
<div class="detail-read-strip"> <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">Leverandør</span><span class="value">${connection.provider || '-'}</span></div>
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${escapeHtml(connection.circuit_number || '-')}</span></div> <div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${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">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">${escapeHtml(connection.connection_type || '-')}</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">${escapeHtml(connection.technology || '-')}</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 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>
<div class="detail-read-grid"> <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">Navn</span><div class="value">${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 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">${escapeHtml(connection.customer_name || '-')}</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">${escapeHtml(connection.parent_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">${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}</span><div class="value">${escapeHtml(subscriptionText)}</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">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">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 || '<span class="detail-read-muted">-</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> </div>
`; `;
setStatusBadge(connection.status); setStatusBadge(connection.status);
document.getElementById('fieldName').value = connection.name || ''; 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('fieldCircuit').value = connection.circuit_number || '';
document.getElementById('fieldAddress').value = connection.address || ''; document.getElementById('fieldAddress').value = connection.address || '';
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id); setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
@ -1826,7 +1484,6 @@
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || ''; document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated'; document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
document.getElementById('fieldValueType').value = connection.value_type || 'other'; document.getElementById('fieldValueType').value = connection.value_type || 'other';
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
document.getElementById('fieldValueLabel').value = connection.value_label || ''; document.getElementById('fieldValueLabel').value = connection.value_label || '';
setSubscriptionValue(connection.subscription_id); setSubscriptionValue(connection.subscription_id);
document.getElementById('fieldContractStart').value = connection.contract_start || ''; document.getElementById('fieldContractStart').value = connection.contract_start || '';
@ -1836,66 +1493,6 @@
toggleCoreEdit(false, true); 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) { function toggleCoreEdit(force, silent = false) {
coreEditMode = Boolean(force); coreEditMode = Boolean(force);
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode); document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
@ -1915,38 +1512,27 @@
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false); const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
if (!visibleRanges.length && !mismatchedRanges.length) { 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>'; rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
return; return;
} }
const visibleMarkup = visibleRanges.map((range) => ` const visibleMarkup = visibleRanges.map((range) => `
<div class="detail-grid-card ip-range-card" id="rangeCard-${range.id}"> <div class="detail-grid-card" id="rangeCard-${range.id}">
<div class="ip-range-main">
<span class="label">${range.name || 'Range'}</span> <span class="label">${range.name || 'Range'}</span>
<div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div> <div class="value">${range.cidr || '-'}</div>
<div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div> <div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div>
</div> ${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
<div class="ip-range-meta"> ${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
<span class="label">Adresser</span> ${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
<div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div> ${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''}
<div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</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>` : ''}
</div> ${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
<div class="ip-range-meta"> <div class="mt-3 d-flex gap-2">
<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">
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button> <button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
</div> </div>
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div> <div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div>
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}"> <div class="d-none" id="rangeEdit-${range.id}">
<div class="range-edit-grid"> <div class="range-edit-grid">
<div> <div>
<label class="form-label small text-muted mb-1">Navn</label> <label class="form-label small text-muted mb-1">Navn</label>
@ -1995,12 +1581,15 @@
`).join(''); `).join('');
const mismatchMarkup = mismatchedRanges.length ? ` const mismatchMarkup = mismatchedRanges.length ? `
<div class="ip-range-warning"> <div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
<i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i> <span class="label text-danger">Afvigelser</span>
<div> <div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
<div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div> ${mismatchedRanges.map((range) => `
${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')} <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> </div>
`).join('')}
</div> </div>
` : ''; ` : '';
@ -2072,7 +1661,7 @@
}); });
if (!filtered.length) { 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; return;
} }
@ -2324,7 +1913,7 @@
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || ''; document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
const payload = { const payload = {
name: document.getElementById('fieldName').value.trim(), 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, circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
address: document.getElementById('fieldAddress').value.trim() || null, address: document.getElementById('fieldAddress').value.trim() || null,
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null, customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
@ -2340,7 +1929,6 @@
value_type: document.getElementById('fieldValueType').value || 'other', value_type: document.getElementById('fieldValueType').value || 'other',
value_label: document.getElementById('fieldValueLabel').value.trim() || null, value_label: document.getElementById('fieldValueLabel').value.trim() || null,
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || 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_start: document.getElementById('fieldContractStart').value || null,
contract_end: document.getElementById('fieldContractEnd').value || null, contract_end: document.getElementById('fieldContractEnd').value || null,
notes: document.getElementById('fieldNotes').value.trim() || null, notes: document.getElementById('fieldNotes').value.trim() || null,
@ -2388,19 +1976,6 @@
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription'); 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() { async function createRange() {
const feedback = document.getElementById('rangeCreateFeedback'); const feedback = document.getElementById('rangeCreateFeedback');
const button = document.getElementById('createRangeButton'); const button = document.getElementById('createRangeButton');
@ -2464,7 +2039,7 @@
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null, assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
}; };
if (!payload.range_id || !payload.ip_address) { 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; return;
} }
@ -2474,7 +2049,7 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true); alert('Kunne ikke oprette IP-adresse');
return; return;
} }
document.getElementById('addressIpInput').value = ''; document.getElementById('addressIpInput').value = '';
@ -2515,7 +2090,7 @@
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (!response.ok) { if (!response.ok) {
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true); alert('Kunne ikke opdatere IP-adressen');
return; return;
} }
editingAddressId = null; editingAddressId = null;
@ -2575,14 +2150,6 @@
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice'); syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
await refreshBmcnetWizardAllocationMode(); 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 () => { document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
await refreshBmcnetWizardAllocationMode(); await refreshBmcnetWizardAllocationMode();
}); });

View File

@ -137,33 +137,6 @@
padding: 1rem; 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 { .invoice-sync-status {
display:inline-flex; display:inline-flex;
align-items:center; align-items:center;
@ -200,14 +173,14 @@
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div> <div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
</div> </div>
<div class="d-flex gap-2 flex-wrap"> <div class="d-flex gap-2 flex-wrap">
<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 <i class="bi bi-arrow-repeat me-1"></i>Opdater
</button> </button>
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal"> <button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic <i class="bi bi-plus-lg me-1"></i>Ny forbindelse
</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> </button>
</div> </div>
</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 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="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="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> </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="col-6 col-xl-3">
<div class="internet-kpi"> <div class="internet-kpi">
<div class="internet-kpi-label">Forbindelser</div> <div class="internet-kpi-label">Forbindelser</div>
@ -230,7 +200,7 @@
</div> </div>
<div class="col-6 col-xl-3"> <div class="col-6 col-xl-3">
<div class="internet-kpi"> <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 class="internet-kpi-value" id="metricActive">0</div>
</div> </div>
</div> </div>
@ -262,7 +232,7 @@
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" /> <input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
</div> </div>
<div class="col-lg-2"> <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>
<div class="col-lg-2"> <div class="col-lg-2">
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" /> <input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
@ -326,14 +296,13 @@
</div> </div>
</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"> <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="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" /> <input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
<select class="form-select" id="statusFilter"> <select class="form-select" id="statusFilter">
<option value="">Alle statusser</option> <option value="">Alle statusser</option>
<option value="active">Aktive</option> <option value="active">Aktive</option>
<option value="pending">Afventer kontrol</option>
<option value="planned">Planlagte</option> <option value="planned">Planlagte</option>
<option value="inactive">Inaktive</option> <option value="inactive">Inaktive</option>
<option value="terminated">Opsagte</option> <option value="terminated">Opsagte</option>
@ -380,14 +349,13 @@
</div> </div>
</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 class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
<div> <div>
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3> <h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div> <div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
</div> </div>
<div class="d-flex gap-2"> <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()"> <select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
<option value="">Alle resultater</option> <option value="">Alle resultater</option>
<option value="success">Gennemført</option> <option value="success">Gennemført</option>
@ -396,7 +364,6 @@
<option value="skipped">Sprunget over</option> <option value="skipped">Sprunget over</option>
<option value="not_logged">Ældre uden detaljer</option> <option value="not_logged">Ældre uden detaljer</option>
</select> </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> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
</div> </div>
</div> </div>
@ -413,82 +380,6 @@
</table> </table>
</div> </div>
</div> </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>
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
@ -500,10 +391,6 @@
</div> </div>
<div class="modal-body"> <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="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 id="invoiceReviewLines"></div>
</div> </div>
</div> </div>
@ -515,70 +402,6 @@
let invoiceSyncItems = []; let invoiceSyncItems = [];
let activeTab = 'all'; let activeTab = 'all';
let subscriptionOptions = []; 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) { function formatDKK(value) {
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 }); return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
@ -608,19 +431,17 @@
function allocationBadge(item) { function allocationBadge(item) {
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret'); 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) { function valueBadge(item) {
const label = item.value_type_label || 'Anden'; const label = item.value_type_label || 'Anden';
return `<span class="internet-tag">${escapeHtml(label)}</span>`; return `<span class="internet-tag">${label}</span>`;
} }
function currentTabDescription() { function currentTabDescription() {
if (activeTab === 'shared') return ' i delte hovedforbindelser'; if (activeTab === 'shared') return ' i delte hovedforbindelser';
if (activeTab === 'bmcnet') return ' i BMCnet'; if (activeTab === 'bmcnet') return ' i BMCnet';
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
if (activeTab === 'unallocated') return ' uden allokeret kunde';
return ''; 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 = '') { function reviewConnectionOptions(selectedId = '') {
return '<option value="">Vælg forbindelse…</option>' + allConnections return '<option value="">Vælg forbindelse…</option>' + allConnections
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect')) .filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
@ -758,33 +558,14 @@
.join(''); .join('');
} }
function toggleResolvedInvoiceLines() { function openInvoiceReview(runId) {
const show = document.getElementById('showResolvedInvoiceLines').checked; const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
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 };
if (!item) return; if (!item) return;
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : []; 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 decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision])); const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-'; 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('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 groups = skippedItems.reduce((result, line) => {
const reason = line.reason || 'Anden kontrol'; const reason = line.reason || 'Anden kontrol';
(result[reason] ||= []).push(line); (result[reason] ||= []).push(line);
@ -796,7 +577,7 @@
<div class="vstack gap-2"> <div class="vstack gap-2">
${lines.map(line => { ${lines.map(line => {
const decision = decisionsByLine.get(Number(line.line_number)); 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="d-flex flex-column flex-lg-row justify-content-between gap-3">
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div> <div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
@ -819,6 +600,7 @@
</div> </div>
</section> </section>
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>'; `).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
} }
async function submitInvoiceReview(runId, lineNumber, action) { async function submitInvoiceReview(runId, lineNumber, action) {
@ -863,43 +645,22 @@
if (valueType) params.set('value_type', valueType); if (valueType) params.set('value_type', valueType);
if (activeTab === 'shared') params.set('shared_only', 'true'); if (activeTab === 'shared') params.set('shared_only', 'true');
if (activeTab === 'bmcnet') params.set('bmcnet_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 { try {
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`); const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
const connections = await safeJson(connectionsResponse, []); const connections = await safeJson(connectionsResponse, []);
allConnections = Array.isArray(connections) ? connections : []; 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 total = allConnections.length;
const active = allConnections.filter((item) => item.status === 'active').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 sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length; const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0); const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
document.getElementById('metricTotal').textContent = String(total); document.getElementById('metricTotal').textContent = String(total);
document.getElementById('metricActive').textContent = `${active} / ${pending}`; document.getElementById('metricActive').textContent = String(active);
const focusMetric = activeTab === 'bmcnet' document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
? ['BMCnet', bmcnetConnections] document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
: 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('metricMargin').textContent = formatDKK(margin); document.getElementById('metricMargin').textContent = formatDKK(margin);
renderConnections(allConnections); renderConnections(allConnections);
@ -918,33 +679,23 @@
return; return;
} }
body.innerHTML = connections.map((item) => { 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 `
<tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'"> <tr class="internet-row" onclick="window.location.href='/economy/internet-connections/${item.id}'">
<td> <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="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
<div class="internet-mini">${escapeHtml(item.address || '-')}</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 ${escapeHtml(item.parent_name)}</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>` : ''} ${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>
<td> <td>
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div> <div>${item.customer_name || '-'}</div>
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div> <div class="internet-mini">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>`}
</td> </td>
<td> <td>
<div>${escapeHtml(item.provider || '-')}</div> <div>${item.provider || '-'}</div>
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div> <div class="internet-mini">${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>` : '')} ${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>
<td> <td>
<div>${formatSpeed(item)}</div> <div>${formatSpeed(item)}</div>
@ -962,19 +713,7 @@
</a> </a>
</td> </td>
</tr> </tr>
`}).join(''); `).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();
} }
async function submitConnectionForm() { async function submitConnectionForm() {
@ -982,7 +721,7 @@
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary'); const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
const payload = { const payload = {
name: document.getElementById('connectionNameInput').value.trim(), 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, customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null, circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
address: document.getElementById('connectionAddressInput').value.trim() || null, address: document.getElementById('connectionAddressInput').value.trim() || null,
@ -998,8 +737,6 @@
value_type: document.getElementById('connectionValueTypeInput').value || 'other', value_type: document.getElementById('connectionValueTypeInput').value || 'other',
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null, value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || 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) { if (!payload.name) {
@ -1032,7 +769,7 @@
feedback.textContent = 'Forbindelse oprettet.'; feedback.textContent = 'Forbindelse oprettet.';
[ [
'connectionNameInput', 'connectionNameInput',
'connectionVendorInput', 'connectionProviderInput',
'connectionCustomerIdInput', 'connectionCustomerIdInput',
'connectionCircuitInput', 'connectionCircuitInput',
'connectionAddressInput', 'connectionAddressInput',
@ -1074,23 +811,7 @@
document.getElementById('tabAll').classList.toggle('active', tab === 'all'); document.getElementById('tabAll').classList.toggle('active', tab === 'all');
document.getElementById('tabShared').classList.toggle('active', tab === 'shared'); document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet'); document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated'); loadInternetPage();
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();
} }
function toggleCreateValueFields() { function toggleCreateValueFields() {
@ -1124,315 +845,6 @@
.join(''); .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.addEventListener('DOMContentLoaded', async () => {
document.getElementById('searchInput').addEventListener('keydown', (event) => { document.getElementById('searchInput').addEventListener('keydown', (event) => {
if (event.key === 'Enter') loadInternetPage(); if (event.key === 'Enter') loadInternetPage();
@ -1440,36 +852,8 @@
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => { document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || ''; 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(); toggleCreateValueFields();
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]); await loadSubscriptionOptions();
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]); await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
}); });
</script> </script>

View File

@ -2,77 +2,16 @@ import json
import logging import logging
from datetime import date from datetime import date
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import quote
import aiohttp import aiohttp
from fastapi import HTTPException from fastapi import HTTPException
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_query_single 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__) 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: class OrdreEconomicExportService:
"""e-conomic export service for global ordre page.""" """e-conomic export service for global ordre page."""
@ -81,17 +20,8 @@ class OrdreEconomicExportService:
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_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
self.read_only = ( self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
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.default_layout = settings.ORDRE_ECONOMIC_LAYOUT self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
@ -127,21 +57,7 @@ class OrdreEconomicExportService:
notes: Optional[str] = None, notes: Optional[str] = None,
layout_number: Optional[int] = None, layout_number: Optional[int] = None,
user_id: 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]: ) -> 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( customer = execute_query_single(
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s", "SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
(customer_id,), (customer_id,),
@ -165,9 +81,7 @@ class OrdreEconomicExportService:
product_map: Dict[int, str] = {} product_map: Dict[int, str] = {}
if product_ids: if product_ids:
product_rows = execute_query( product_rows = execute_query(
"""SELECT id, name, sku_internal, ean, short_description, long_description, "SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
sales_price, economic_product_group_number
FROM products WHERE id = ANY(%s)""",
(product_ids,), (product_ids,),
) or [] ) or []
product_map = { product_map = {
@ -175,13 +89,8 @@ class OrdreEconomicExportService:
for row in product_rows for row in product_rows
if row.get("sku_internal") 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]] = [] economic_lines: List[Dict[str, Any]] = []
creation_candidates: Dict[str, Dict[str, Any]] = {}
created_product_numbers: List[str] = []
for line in selected_lines: for line in selected_lines:
try: try:
quantity = float(line.get("quantity") or 0) quantity = float(line.get("quantity") or 0)
@ -214,150 +123,29 @@ class OrdreEconomicExportService:
if product_number: if product_number:
line_payload["product"] = {"productNumber": str(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: if discount > 0:
line_payload["discountPercentage"] = discount line_payload["discountPercentage"] = discount
economic_lines.append(line_payload) 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] = { payload: Dict[str, Any] = {
"date": date.today().isoformat(), "date": date.today().isoformat(),
"currency": str(economic_customer.get("currency") or "DKK"), "currency": "DKK",
"customer": {"customerNumber": customer_number}, "customer": {
"paymentTerms": payment_terms, "customerNumber": int(customer["economic_customer_number"]),
"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,
}, },
"layout": { "layout": {
"layoutNumber": int(resolved_layout_number), "layoutNumber": int(layout_number or self.default_layout),
}, },
"lines": economic_lines, "lines": economic_lines,
} }
if notes: if notes:
payload["notes"] = {"textLine1": str(notes)[:1000]} payload["notes"] = {"textLine1": str(notes)[:250]}
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]}
if not write_allowed: operation = f"Export ordre for customer {customer_id} to e-conomic"
if not self._check_write_permission(operation):
return { return {
"success": True, "success": True,
"dry_run": True, "dry_run": True,
@ -369,15 +157,13 @@ class OrdreEconomicExportService:
"read_only": self.read_only, "read_only": self.read_only,
"dry_run": self.dry_run, "dry_run": self.dry_run,
"user_id": user_id, "user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"payload": payload, "payload": payload,
}, },
} }
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str)) 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( async with session.post(
f"{self.api_url}/orders/drafts", f"{self.api_url}/orders/drafts",
headers=self._headers(), headers=self._headers(),
@ -389,7 +175,7 @@ class OrdreEconomicExportService:
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text) logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
raise HTTPException( raise HTTPException(
status_code=502, 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) export_result = await response.json(content_type=None)
@ -406,15 +192,7 @@ class OrdreEconomicExportService:
"customer_name": customer.get("name"), "customer_name": customer.get("name"),
"selected_line_count": len(selected_lines), "selected_line_count": len(selected_lines),
"user_id": user_id, "user_id": user_id,
"skipped_product_numbers": missing_product_numbers,
"missing_product_numbers": missing_product_numbers,
"economic_response": export_result, "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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"} ALLOWED_SYNC_STATUSES = {"pending", "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)
class OrdreLineInput(BaseModel): class OrdreLineInput(BaseModel):
@ -60,9 +25,7 @@ class OrdreLineInput(BaseModel):
discount_percentage: float = Field(default=0, ge=0, le=100) discount_percentage: float = Field(default=0, ge=0, le=100)
unit: Optional[str] = None unit: Optional[str] = None
product_id: Optional[int] = None product_id: Optional[int] = None
ean: Optional[str] = None
selected: bool = True selected: bool = True
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
class OrdreExportRequest(BaseModel): class OrdreExportRequest(BaseModel):
@ -72,8 +35,6 @@ class OrdreExportRequest(BaseModel):
layout_number: Optional[int] = None layout_number: Optional[int] = None
draft_id: Optional[int] = None draft_id: Optional[int] = None
force_export: bool = False 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): class OrdreDraftUpsertRequest(BaseModel):
@ -88,15 +49,6 @@ class OrdreDraftConsolidateRequest(BaseModel):
draft_ids: List[int] = Field(..., min_length=2) 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: def _safe_json_field(value: Any) -> Any:
if value is None: if value is None:
return 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.""" """Export selected ordre lines to e-conomic draft order."""
try: try:
user_id = _get_user_id_from_request(http_request) 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 previous_status = None
export_idempotency_key = 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] 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( export_result = await ordre_economic_export_service.export_order(
customer_id=request.customer_id, customer_id=request.customer_id,
lines=line_payload, lines=line_payload,
notes=request.notes, notes=request.notes,
layout_number=request.layout_number, layout_number=request.layout_number,
user_id=user_id, 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")] 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(), "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 = ( economic_invoice_number = (
export_result.get("economic_invoice_number") export_result.get("economic_invoice_number")
or export_result.get("invoice_number") or export_result.get("invoice_number")
or export_result.get("invoiceNumber") 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" target_sync_status = "pending" if export_result.get("dry_run") else "exported"
execute_query( execute_query(
@ -289,17 +219,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key = %s, export_idempotency_key = %s,
economic_order_number = COALESCE(%s, economic_order_number), economic_order_number = COALESCE(%s, economic_order_number),
economic_invoice_number = COALESCE(%s, economic_invoice_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_sync_at = CURRENT_TIMESTAMP,
last_exported_at = CURRENT_TIMESTAMP, last_exported_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
@ -311,16 +230,6 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
export_idempotency_key, export_idempotency_key,
str(economic_order_number) if economic_order_number is not None else None, 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, 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, request.draft_id,
), ),
) )
@ -335,33 +244,12 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
"idempotency_key": export_idempotency_key, "idempotency_key": export_idempotency_key,
"economic_order_number": economic_order_number, "economic_order_number": economic_order_number,
"economic_invoice_number": economic_invoice_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, user_id,
) )
return export_result return export_result
except HTTPException as exc: except HTTPException:
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)
raise raise
except Exception as e: except Exception as e:
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True) 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): async def get_ordre_draft(draft_id: int, http_request: Request):
"""Get single ordre draft with lines payload (no user filtering).""" """Get single ordre draft with lines payload (no user filtering)."""
try: try:
query = """SELECT d.*, c.name AS customer_name, query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
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"""
params = (draft_id,) params = (draft_id,)
from app.core.database import execute_query_single 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") 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") @router.post("/ordre/drafts")
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request): async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
"""Create a new ordre draft.""" """Create a new ordre draft."""
try: try:
await _check_draft_prices(http_request, request.customer_id, request.lines)
user_id = _get_user_id_from_request(http_request) user_id = _get_user_id_from_request(http_request)
from app.core.database import execute_query from app.core.database import execute_query
@ -491,7 +342,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
params = ( params = (
request.title, request.title,
request.customer_id, request.customer_id,
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False), json.dumps(request.lines, ensure_ascii=False),
request.notes, request.notes,
request.layout_number, request.layout_number,
user_id, user_id,
@ -499,8 +350,6 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
) )
result = execute_query(query, params) result = execute_query(query, params)
return result[0] return result[0]
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True) logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
raise HTTPException(status_code=500, detail="Failed to create ordre draft") 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 = """ query = """
SELECT SELECT
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count, 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 = 'exported') AS exported_count,
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count, COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_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 { return execute_query_single(query, ()) or {
"pending_count": 0, "pending_count": 0,
"uncertain_count": 0,
"exported_count": 0, "exported_count": 0,
"failed_count": 0, "failed_count": 0,
"posted_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 from app.core.database import execute_query_single
previous = 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,) (draft_id,)
) )
if not previous: if not previous:
raise HTTPException(status_code=404, detail="Draft not found") raise HTTPException(status_code=404, detail="Draft not found")
from_status = (previous.get("sync_status") or "pending").strip().lower() 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) values.append(draft_id)
from app.core.database import execute_query 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): async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
"""Update existing ordre draft.""" """Update existing ordre draft."""
try: 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 from app.core.database import execute_query
query = """ query = """
@ -709,7 +541,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht
params = ( params = (
request.title, request.title,
request.customer_id, request.customer_id,
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False), json.dumps(request.lines, ensure_ascii=False),
request.notes, request.notes,
request.layout_number, request.layout_number,
draft_id, draft_id,

View File

@ -344,9 +344,9 @@
const index = line.originalIndex; const index = line.originalIndex;
const isManual = line.source_type === 'manual'; const isManual = line.source_type === 'manual';
const descriptionField = isManual const descriptionField = isManual
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;" ? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>` onchange="ordreLines[${index}].description = this.value;">`
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`; : escapeHtml(line.description || '-');
const manualActions = isManual 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-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-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-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>
</div> </div>
@ -97,9 +95,8 @@
<i class="bi bi-shield-exclamation me-1"></i> <i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run. <strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div> </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"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle"> <input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label> <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-item">
<div class="info-label">Kunde ID</div> <div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID"> <input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <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 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>
<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="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5> <h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div> <div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
</div> </div>
<div class="d-flex gap-2"> <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)"> <button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events <i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button> </button>
@ -194,7 +166,6 @@
<div class="sync-label">Sync status</div> <div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm"> <select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option> <option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option> <option value="exported">exported</option>
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="posted">posted</option> <option value="posted">posted</option>
@ -205,7 +176,7 @@
<div class="sync-label">e-conomic ordre nr.</div> <div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345"> <input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div> </div>
<div class="col-md-3" id="invoiceNumberEditWrap"> <div class="col-md-3">
<div class="sync-label">e-conomic faktura nr.</div> <div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877"> <input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div> </div>
@ -229,19 +200,15 @@
<div class="sync-label">Ordrenummer</div> <div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div> <div id="economicOrderNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3" id="invoiceNumberViewWrap"> <div class="col-md-3">
<div class="sync-label">Fakturanummer</div> <div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div> <div id="economicInvoiceNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div> </div>
<div class="row g-2 align-items-end mb-3"> <div class="row g-2 align-items-end mb-3">
<div class="col-md-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"> <input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
@ -325,37 +292,6 @@
</div> </div>
</div> </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> </div>
{% endblock %} {% endblock %}
@ -368,8 +304,6 @@
let syncEventsOffset = 0; let syncEventsOffset = 0;
let syncEventsTotal = 0; let syncEventsTotal = 0;
let detailToast = null; let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') { function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast'); const toastEl = document.getElementById('detailToast');
@ -388,8 +322,8 @@
detailToast.show(); detailToast.show();
} }
function formatCurrency(value, currency = 'DKK') { function formatCurrency(value) {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0)); return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
} }
function formatDate(dateStr) { function formatDate(dateStr) {
@ -420,7 +354,6 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>'; 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 === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</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>'; 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('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-'; document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_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() { function renderLines() {
@ -471,11 +378,9 @@
tbody.innerHTML = orderLines.map((line, index) => { tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported'; const isExportedLine = line.export_status === 'exported';
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number; const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
const lockLine = isExportedLine && !mayEditExportedDraft; ${isExportedLine ? 'disabled' : ''}
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;" onchange="orderLines[${index}].description = this.value;">`;
${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
const exportStatus = line.export_status || '-'; const exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported' const statusBadge = exportStatus === 'exported'
@ -501,17 +406,17 @@
<td>${descriptionField}</td> <td>${descriptionField}</td>
<td style="min-width:100px;"> <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)}" <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});"> onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:120px;"> <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)}" <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});"> onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:110px;"> <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)}" <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});"> onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td> <td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -597,7 +502,7 @@
const product = data.product; const product = data.product;
line.product_id = product.id || line.product_id || null; line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || ''; 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); line.unit_price = Number(product.sales_price || 0);
} }
if (product.ean) { if (product.ean) {
@ -637,8 +542,7 @@
discount_percentage: Number(line.discountPercentage || 0), discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0), amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk', unit: line.unit || 'stk',
product_id: null, product_id: line.product.productNumber || null,
economic_product_number: line.product.productNumber || null,
selected: line.selected !== false, selected: line.selected !== false,
export_status: line.export_status || null, export_status: line.export_status || null,
}; };
@ -665,25 +569,13 @@
document.getElementById('orderTitle').value = orderData.title || ''; document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || ''; 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('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || ''; document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()); const hasExported = orderData.last_exported_at ? true : false;
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>' ? '<span class="badge bg-success">Eksporteret</span>'
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>' : '<span class="badge bg-warning text-dark">Ikke eksporteret</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' : '-');
document.getElementById('createdAt').textContent = formatDate(orderData.created_at); document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -774,18 +666,9 @@
} }
body.innerHTML = items.map((event) => { body.innerHTML = items.map((event) => {
const data = typeof event.event_payload === 'object' ? event.event_payload : {}; const payload = typeof event.event_payload === 'object'
let payload = String(event.event_payload || ''); ? JSON.stringify(event.event_payload, null, 2)
if (event.event_type === 'export_success') { : String(event.event_payload || '');
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(' · ');
}
return ` return `
<tr> <tr>
@ -793,7 +676,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td> <td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td> <td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_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> </tr>
`; `;
}).join(''); }).join('');
@ -878,99 +761,7 @@
} }
} }
async function previewEconomicUpdate() { async function exportOrder() {
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 = {}) {
const customerId = Number(document.getElementById('customerId').value || 0); const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) { if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning'); showToast('Angiv kunde ID før eksport', 'warning');
@ -991,18 +782,15 @@
description: line.description, description: line.description,
quantity: Number(line.quantity || 0), quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0), unit_price: Number(line.unit_price || 0),
currency: line.currency || 'DKK',
discount_percentage: Number(line.discount_percentage || 0), discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk', unit: line.unit || 'stk',
product_id: line.product_id || null, product_id: line.product_id || null,
ean: line.ean || null,
selected: true, selected: true,
})), })),
notes: document.getElementById('orderNotes').value || null, notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId, draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked, force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
}; };
try { try {
@ -1013,10 +801,6 @@
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede'); 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 import logging
from typing import List, Optional from typing import List, Optional
import json
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, status, Depends, Request from fastapi import APIRouter, HTTPException, status, Depends, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_insert from app.core.database import execute_query, execute_insert
from app.core.config import settings
from app.core.auth_dependencies import require_any_permission from app.core.auth_dependencies import require_any_permission
from app.services.reminder_notification_service import reminder_notification_service from app.services.reminder_notification_service import reminder_notification_service
@ -21,94 +19,6 @@ router = APIRouter()
case_read_access = require_any_permission("cases.view", "tickets.view") case_read_access = require_any_permission("cases.view", "tickets.view")
case_edit_access = require_any_permission("cases.edit", "tickets.edit") 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 # Helper Functions

View File

@ -6,7 +6,6 @@ import re
import hashlib import hashlib
import base64 import base64
import html import html
import math
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional, Dict from typing import Any, List, Optional, Dict
@ -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") 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: def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = [] allowed_statuses = []
seen = set() seen = set()
@ -437,15 +419,7 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(BaseModel): class SagListPreferencesUpdate(BaseModel):
type_filters: Optional[List[str]] = None type_filters: List[str] = Field(default_factory=list)
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",
)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]: def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
@ -1037,25 +1011,8 @@ async def create_sag(request: Request, data: dict):
_validate_group_id(assigned_group_id) _validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") pipeline = data.get("pipeline") if case_type == "pipeline" else None
order_items = data.get("order_items", []) order_items = data.get("order_items") if case_type == "ordre" else []
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)
if pipeline is not None and not isinstance(pipeline, dict): if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt") raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list): if not isinstance(order_items, list):
@ -1070,8 +1027,6 @@ async def create_sag(request: Request, data: dict):
if pipeline_values["amount"] not in (None, ""): if pipeline_values["amount"] not in (None, ""):
try: try:
pipeline_values["amount"] = float(pipeline_values["amount"]) 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: except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else: else:
@ -1126,8 +1081,6 @@ async def create_sag(request: Request, data: dict):
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"): if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus") 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( cursor.execute(
""" """
@ -1145,64 +1098,6 @@ async def create_sag(request: Request, data: dict):
if not result: if not result:
raise HTTPException(status_code=500, detail="Failed to create case") 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") has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items: for item in normalized_items:
if has_purchase_columns: if has_purchase_columns:
@ -1219,11 +1114,7 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", 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"]), (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() conn.commit()
result = dict(result)
result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"]) logger.info("✅ Case created: %s", result["id"])
return dict(result) return dict(result)
except Exception: except Exception:
@ -1237,46 +1128,6 @@ async def create_sag(request: Request, data: dict):
logger.error("❌ Error creating case: %s", e) logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case") 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}") @router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int): async def get_sag(sag_id: int):
"""Get a specific case.""" """Get a specific case."""
@ -1408,14 +1259,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): async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: 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( 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,), (user_id,),
) or [] ) or []
if not rows: if not rows:
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []} return {"type_filters": []}
raw = rows[0].get("type_filters") raw = rows[0].get("type_filters")
parsed = [] parsed = []
@ -1438,43 +1291,20 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item) seen.add(item)
normalized.append(item) normalized.append(item)
raw_order = rows[0].get("column_order") if has_columns else None return {"type_filters": normalized}
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}
except Exception as e: except Exception as e:
if "user_sag_list_preferences" in str(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) 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") 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") @router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate): async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
normalized = [] normalized = []
seen = set() seen = set()
existing = await get_my_sag_list_preferences(request) for value in payload.type_filters or []:
for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
item = str(value or "").strip().lower() item = str(value or "").strip().lower()
if not item or item in seen: if not item or item in seen:
continue continue
@ -1483,31 +1313,18 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item) seen.add(item)
normalized.append(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( execute_query(
""" """
INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at) INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW()) VALUES (%s, %s::jsonb, NOW())
ON CONFLICT (user_id) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
type_filters = EXCLUDED.type_filters, type_filters = EXCLUDED.type_filters,
column_order = EXCLUDED.column_order,
hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW() 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} return {"type_filters": normalized}
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, 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") raise HTTPException(status_code=500, detail="Failed to save list preferences")
@ -1724,7 +1541,6 @@ async def delete_todo_step(step_id: int):
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)): async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
"""Update a case.""" """Update a case."""
try: try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists # Check if case exists
check = execute_query( check = execute_query(
""" """
@ -1746,23 +1562,6 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
if "status" in updates: if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status")) 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: if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates: if "start_date" in updates:
@ -2613,28 +2412,6 @@ async def list_case_contacts(sag_id: int):
logger.error("❌ Error listing case contacts: %s", e) logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts") 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") @router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict): async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case.""" """Add a contact to a case."""
@ -3302,44 +3079,6 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items") 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") @router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict): async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case.""" """Create a sale item for a case."""

View File

@ -1,500 +1,118 @@
import json
import logging import logging
import re from fastapi import APIRouter, HTTPException, Request, Depends
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.core.database import execute_query
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.core.database import execute_query, execute_query_single
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate 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__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
case_edit_access = require_any_permission("cases.edit", "tickets.edit") 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]) @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)): async def get_solution(sag_id: int):
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,)) """Get the solution associated with a case."""
return result[0] if result else None try:
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
result = execute_query(query, (sag_id,))
@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)
if not result: if not result:
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner") return None
result = { return result[0]
"title": case.get("titel") or "", except Exception as e:
"problem": case.get("beskrivelse") or "", logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
"root_cause": "", raise HTTPException(status_code=500, detail="Failed to get solution")
"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}
@router.post(
"/sag/{sag_id}/solution",
response_model=Solution,
dependencies=[Depends(case_edit_access)],
)
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
"""Create a solution for a case."""
try:
# Check if case exists
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")
@router.get("/solutions") # Check if solution already exists
async def list_solutions( check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
q: str = Query("", max_length=200), if check:
approval_status: Optional[str] = None, raise HTTPException(status_code=400, detail="Solution already exists for this case")
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}
query = """
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)]) INSERT INTO sag_solutions
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)): (sag_id, title, description, solution_type, result, created_by_user_id)
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,)) VALUES (%s, %s, %s, %s, %s, %s)
if not solution: RETURNING *
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret") """
user_id = _user_id(current_user) params = (
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)) sag_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)) solution.title,
_version_solution(archived, user_id, "Løsning arkiveret") solution.description,
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]} solution.solution_type,
solution.result,
getattr(request.state, "user_id", None)
@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,),
) )
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke") result = execute_query(query, params)
return article 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 = 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: def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path) safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title) safe_title = json.dumps(page_title)
@ -245,7 +203,6 @@ async def sager_liste(
assigned_group_id: str = Query(None), assigned_group_id: str = Query(None),
unassigned: bool = Query(False), unassigned: bool = Query(False),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
quick: str = Query(None),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
@ -258,7 +215,6 @@ async def sager_liste(
query = """ query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
sk_first.contact_id AS kontakt_id,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name, g.name AS assigned_group_name,
@ -322,8 +278,7 @@ async def sager_liste(
query += ")" query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())" query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_quick = str(quick or "").strip().lower() normalized_status = str(status or "").strip().lower()
normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
normalized_priority = str(priority or "").strip().lower() normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all": if normalized_status == "all":
pass pass
@ -361,7 +316,6 @@ async def sager_liste(
fallback_query = """ fallback_query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
NULL::integer AS kontakt_id,
'' as kontakt_navn, '' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name, NULL::text AS assigned_group_name,
@ -485,7 +439,6 @@ async def sager_liste(
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": status_options, "statuses": status_options,
"status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_priority": normalized_priority, "current_priority": normalized_priority,
@ -498,19 +451,16 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses, "closed_statuses": closed_statuses,
}) })
except Exception: except Exception:
logger.exception("❌ Error displaying case list") logger.exception("❌ Error displaying case list")
fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", { return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request, "request": request,
"sager": [], "sager": [],
"relations_map": {}, "relations_map": {},
"child_ids": [], "child_ids": [],
"statuses": fallback_status_options, "statuses": _fetch_case_status_options(),
"status_options": fallback_status_options,
"all_tags": [], "all_tags": [],
"current_status": status, "current_status": status,
"current_priority": str(priority or "").strip().lower(), "current_priority": str(priority or "").strip().lower(),
@ -523,7 +473,6 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(), "closed_statuses": _fetch_closed_case_statuses(),
}) })
@ -843,31 +792,11 @@ async def sag_detaljer(request: Request, sag_id: int):
c.phone, c.phone,
c.mobile, c.mobile,
c.title, c.title,
company.customer_name, 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
FROM sag_kontakter sk FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT cu.id AS customer_id, cu.name AS customer_name SELECT cu.name AS customer_name
FROM contact_companies cc FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.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,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # 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_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None
@ -1224,25 +1153,11 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
c.phone, c.phone,
c.mobile, c.mobile,
c.title, c.title,
company.customer_name, 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
FROM sag_kontakter sk FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT cu.id AS customer_id, cu.name AS customer_name SELECT cu.name AS customer_name
FROM contact_companies cc FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id WHERE cc.contact_id = c.id
@ -1267,7 +1182,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # 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_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None

View File

@ -4,21 +4,6 @@
{% block extra_css %} {% block extra_css %}
<style> <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 */ /* Gradient Header for the Card */
.card-header-custom { .card-header-custom {
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%); background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
@ -151,144 +136,18 @@
.case-top-alerts .alert-danger { .case-top-alerts .alert-danger {
border-left-color: #e03131; 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> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container py-4 cc-page"> <div class="container py-4">
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-12"> <div class="col-lg-8">
<!-- Main Card --> <!-- Main Card -->
<div class="card card-custom"> <div class="card card-custom">
<div class="card-header-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> <h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span> <p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
</div> </div>
<div class="card-body p-4"> <div class="card-body p-4">
@ -303,14 +162,10 @@
</div> </div>
<form id="createForm" novalidate> <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> <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> <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> <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> </div>
<!-- Section: Relations --> <!-- Section: Relations -->
@ -344,7 +199,6 @@
<input type="hidden" id="customer_id" name="customer_id"> <input type="hidden" id="customer_id" name="customer_id">
</div> </div>
</div> </div>
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
<hr class="my-4 opacity-25"> <hr class="my-4 opacity-25">
@ -532,7 +386,7 @@
let customerContactsLoadToken = 0; let customerContactsLoadToken = 0;
let successAlertTimeout; let successAlertTimeout;
let orderLineCounter = 0; 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; let topAlertLoadToken = 0;
function escapeTopAlertHtml(value) { function escapeTopAlertHtml(value) {
@ -621,13 +475,6 @@
}, duration); }, 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 --- // --- Character Counter ---
const beskrInput = document.getElementById('beskrivelse'); const beskrInput = document.getElementById('beskrivelse');
if (beskrInput) { if (beskrInput) {
@ -647,7 +494,7 @@
if (!source) { if (!source) {
descriptionInput?.focus(); descriptionInput?.focus();
showCreateError('Skriv en beskrivelse først.'); alert('Skriv en beskrivelse først.');
return; return;
} }
@ -682,7 +529,7 @@
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show(); bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
} catch (error) { } catch (error) {
console.error('Case create rewrite failed:', 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 { } finally {
if (button) { if (button) {
button.disabled = false; button.disabled = false;
@ -698,13 +545,10 @@
const titleInput = document.getElementById('titel'); const titleInput = document.getElementById('titel');
const descriptionInput = document.getElementById('beskrivelse'); const descriptionInput = document.getElementById('beskrivelse');
if (suggestedTitle) { if (suggestedTitle) titleInput.value = suggestedTitle;
titleInput.value = suggestedTitle;
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
}
if (suggestedDescription) { if (suggestedDescription) {
descriptionInput.value = suggestedDescription; descriptionInput.value = suggestedDescription;
descriptionInput.dispatchEvent(new Event('input', { bubbles: true })); descriptionInput.dispatchEvent(new Event('input'));
} }
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide(); bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
}); });
@ -728,20 +572,13 @@
const contactInput = document.getElementById('contactSearch'); const contactInput = document.getElementById('contactSearch');
if (contactInput) { if (contactInput) {
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact')); 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', () => { contactInput.addEventListener('focus', () => {
if (selectedCustomer && contactInput.value.trim().length >= 2) { if (selectedCustomer) {
renderCustomerContactResults(contactInput.value); renderCustomerContactResults(contactInput.value);
} }
}); });
document.addEventListener('click', (e) => { 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'); const cr = document.getElementById('contactResults');
if(cr) cr.classList.add('d-none'); if(cr) cr.classList.add('d-none');
} }
@ -760,12 +597,6 @@
clearTimeout(timeoutVar); clearTimeout(timeoutVar);
if (query.length < 2) {
resultsDiv.classList.add('d-none');
resultsDiv.innerHTML = '';
return;
}
if (type === 'contact' && selectedCustomer) { if (type === 'contact' && selectedCustomer) {
renderCustomerContactResults(query); renderCustomerContactResults(query);
return; return;
@ -777,7 +608,6 @@
} }
const timeout = setTimeout(async () => { const timeout = setTimeout(async () => {
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
try { try {
// Show loading state // Show loading state
resultsDiv.classList.remove('d-none'); resultsDiv.classList.remove('d-none');
@ -794,7 +624,6 @@
return; return;
} }
const data = await response.json(); const data = await response.json();
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
if (!Array.isArray(data)) { if (!Array.isArray(data)) {
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>'; resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
return; return;
@ -857,10 +686,6 @@
if (!resultsDiv || !selectedCustomer) return; if (!resultsDiv || !selectedCustomer) return;
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK'); 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) => { const contacts = selectedCustomerContacts.filter((contact) => {
if (!normalizedQuery) return true; if (!normalizedQuery) return true;
return [ return [
@ -906,9 +731,9 @@
selectedCustomerContacts = []; selectedCustomerContacts = [];
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`; 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.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 { try {
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' }); const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
@ -922,7 +747,7 @@
if (loadToken !== customerContactsLoadToken) return; if (loadToken !== customerContactsLoadToken) return;
console.error('Failed to load customer contacts:', error); console.error('Failed to load customer contacts:', error);
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>'; 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(); loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
} }
function readTelefoniPrefill() { function readTelefoniPrefill() {
@ -1012,7 +836,6 @@
const callIdRaw = params.get('telefoni_opkald_id'); const callIdRaw = params.get('telefoni_opkald_id');
const customerIdRaw = params.get('customer_id'); const customerIdRaw = params.get('customer_id');
const descriptionRaw = params.get('description'); const descriptionRaw = params.get('description');
const internetConnectionIdRaw = params.get('internet_connection_id');
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null; const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null; const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
@ -1021,8 +844,6 @@
telefoniPrefill.title = titleRaw ? String(titleRaw) : null; telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null; telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null; telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
} }
async function applyTelefoniPrefill() { async function applyTelefoniPrefill() {
@ -1070,23 +891,6 @@
console.error('Telefoni prefill failed', e); 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) { function removeContact(id) {
@ -1094,7 +898,6 @@
delete selectedContactsCompanies[id]; delete selectedContactsCompanies[id];
renderSelections(); renderSelections();
loadHardwareForContacts(); loadHardwareForContacts();
window.caseCreateUI?.contactsChanged();
} }
function renderSelections() { function renderSelections() {
@ -1196,7 +999,7 @@
if (!anydeskId) return; if (!anydeskId) return;
try { try {
await navigator.clipboard.writeText(anydeskId); await navigator.clipboard.writeText(anydeskId);
showSuccessAlert('AnyDesk ID kopieret'); alert('AnyDesk ID kopieret');
} catch (err) { } catch (err) {
console.error('Copy failed', err); console.error('Copy failed', err);
} }
@ -1209,13 +1012,13 @@
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null; const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
if (!name) { if (!name) {
showCreateError('Navn er påkrævet'); alert('Navn er påkrævet');
return; return;
} }
const customerId = selectedCustomer?.id || getSingleContactCompanyId(); const customerId = selectedCustomer?.id || getSingleContactCompanyId();
if (!customerId) { if (!customerId) {
showCreateError('Vælg et firma før du opretter hardware'); alert('Vælg et firma før du opretter hardware');
return; return;
} }
@ -1241,7 +1044,7 @@
document.getElementById('hardwareAnyDeskIdInput').value = ''; document.getElementById('hardwareAnyDeskIdInput').value = '';
await loadHardwareForContacts(); await loadHardwareForContacts();
} catch (err) { } catch (err) {
showCreateError('Fejl: ' + err.message); alert('Fejl: ' + err.message);
} }
} }
@ -1336,13 +1139,14 @@
function updateCaseTypeSections() { function updateCaseTypeSections() {
const type = document.getElementById('type')?.value || 'ticket'; const type = document.getElementById('type')?.value || 'ticket';
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none')); document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
window.caseCreateUI?.typeChanged(type); document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
const help = document.getElementById('caseTypeHelp'); 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 === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.' : 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() { function renderOrderLinesEmptyState() {
@ -1417,7 +1221,7 @@
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' }; const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
const profile = profileRes.ok ? await profileRes.json() : {}; const profile = profileRes.ok ? await profileRes.json() : {};
const configured = JSON.parse(setting.value || '[]'); 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('pipeline')) types.splice(1, 0, 'pipeline');
if (!types.includes('abonnement')) types.push('abonnement'); if (!types.includes('abonnement')) types.push('abonnement');
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels); const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
@ -1447,18 +1251,18 @@
} }
// --- Initialization --- // --- Initialization ---
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', () => {
initializeSearch(); initializeSearch();
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]); loadCaseTypesSelect();
loadPipelineStages();
selectCurrentUserAsResponsible();
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections); document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
await applyTelefoniPrefill(); applyTelefoniPrefill();
await window.caseCreateUI?.init();
}); });
// --- Form Submission --- // --- Form Submission ---
document.getElementById('createForm').addEventListener('submit', async (e) => { document.getElementById('createForm').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
// UI Reset // UI Reset
const errorDiv = document.getElementById('error'); const errorDiv = document.getElementById('error');
@ -1531,15 +1335,10 @@
customer_id: selectedCustomer ? selectedCustomer.id : null, customer_id: selectedCustomer ? selectedCustomer.id : null,
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null, ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null, assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
deadline: document.getElementById('deadline').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
}; };
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 = { data.pipeline = {
stage_id: document.getElementById('pipeline_stage_id').value || null, stage_id: document.getElementById('pipeline_stage_id').value || null,
amount: document.getElementById('pipeline_amount').value || null, amount: document.getElementById('pipeline_amount').value || null,
@ -1547,10 +1346,9 @@
description: document.getElementById('pipeline_description').value || null description: document.getElementById('pipeline_description').value || null
}; };
} }
if (document.querySelector('#orderLines .order-line')) { if (data.type === 'ordre') {
data.order_items = collectOrderItems(); data.order_items = collectOrderItems();
} }
Object.assign(data, window.caseCreateUI?.relations() || {});
try { try {
const response = await fetch('/api/v1/sag', { const response = await fetch('/api/v1/sag', {
@ -1562,11 +1360,64 @@
if (response.ok) { if (response.ok) {
const result = await response.json(); 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'); 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`; window.location.href = `/sag/${result.id}/v3`;
}, 1000);
} else { } else {
const errorText = await response.text(); const errorText = await response.text();
let errMsg = "Kunne ikke oprette sag"; let errMsg = "Kunne ikke oprette sag";
@ -1586,5 +1437,4 @@
} }
}); });
</script> </script>
<script src="/static/js/case-create.js?v=5"></script>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,54 +5,17 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.search-bar { .search-bar {
position: relative;
margin-bottom: 0; margin-bottom: 0;
flex: 1 1 460px; flex: 1 1 380px;
min-width: 240px; min-width: 240px;
} }
.search-bar input { .search-bar input {
min-height: 46px; border-radius: 8px;
border-radius: 12px; border: 1px solid rgba(0,0,0,0.1);
border: 1px solid rgba(15,76,117,.15); padding: 0.45rem 0.85rem;
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;
} }
.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 { .top-controls-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -127,21 +90,6 @@
background: var(--accent-light); 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 { .sag-table tbody td {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
vertical-align: top; vertical-align: top;
@ -169,75 +117,10 @@
max-width: 360px; 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 { .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-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); color: var(--accent);
background: rgba(15, 76, 117, 0.14); font-size: 0.95rem;
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;
} }
.sag-unread-badge { .sag-unread-badge {
@ -405,64 +288,6 @@
color: #065f46; 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 { .filter-pills {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@ -824,8 +649,7 @@
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;"> <div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div> <div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
<div class="sag-toolbar-card"> <div class="top-controls-row">
<div class="top-controls-row mb-0">
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;"> <h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
<i class="bi bi-list-check"></i> <i class="bi bi-list-check"></i>
</h1> </h1>
@ -842,14 +666,11 @@
</div> </div>
<div class="search-bar"> <div class="search-bar">
<i class="bi bi-search search-icon"></i>
<input type="text" <input type="text"
class="form-control" class="form-control"
id="searchInput" id="searchInput"
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..." placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
autocomplete="off"> 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>
<div class="top-controls-actions"> <div class="top-controls-actions">
@ -861,17 +682,13 @@
</button> </button>
</div> </div>
</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="type-filter-wrap">
<div class="dropdown type-filter-dropdown mb-1"> <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"> <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 }}"> <a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %} {% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
</a> </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> </div>
<!-- Table --> <!-- Table -->
@ -976,15 +780,14 @@
data-status="{{ sag.status }}" data-status="{{ sag.status }}"
data-type="{{ sag.template_key or sag.type or 'ticket' }}" data-type="{{ sag.template_key or sag.type or 'ticket' }}"
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}" 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-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
data-deadline="{{ sag.deadline.isoformat() if sag.deadline else '' }}">
<td class="col-expand" onclick="event.stopPropagation();"> <td class="col-expand" onclick="event.stopPropagation();">
{% if has_relations %} {% if has_relations %}
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span> <span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
{% endif %} {% endif %}
</td> </td>
<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 %} {% if (sag.unread_email_count or 0) > 0 %}
{% set unread_level = sag.unread_email_level or 'fresh' %} {% 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"> <span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
@ -993,10 +796,10 @@
{% endif %} {% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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>
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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>
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <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> <div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
@ -1009,15 +812,13 @@
</td> </td>
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
{% set status_raw = sag.status if sag.status else 'åben' %} {% 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)"> {% set status_class = status_raw|lower|replace(' ', '-') %}
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %} <span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
</select>
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'"> <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)"> <div class="owner-cell">
<option value="">Ikke tildelt</option> {{ initials_bubble(sag.ansvarlig_navn) }}
{% 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 %} </div>
</select>
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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"> <div class="owner-cell">
@ -1054,10 +855,10 @@
{% if related_sag and rel.target_id not in seen_targets %} {% if related_sag and rel.target_id not in seen_targets %}
{% set _ = seen_targets.append(rel.target_id) %} {% 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 %} {% 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 class="col-expand"><span class="child-branch"></span></td>
<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 %} {% if (related_sag.unread_email_count or 0) > 0 %}
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %} {% 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"> <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 %} {% endif %}
</td> </td>
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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>
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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>
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% for rt in all_rel_types %} {% for rt in all_rel_types %}
@ -1085,15 +886,13 @@
</td> </td>
<td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <td onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
{% set related_status_raw = related_sag.status if related_sag.status else 'åben' %} {% 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)"> {% set related_status_class = related_status_raw|lower|replace(' ', '-') %}
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %} <span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
</select>
</td> </td>
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'"> <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)"> <div class="owner-cell">
<option value="">Ikke tildelt</option> {{ initials_bubble(related_sag.ansvarlig_navn) }}
{% 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 %} </div>
</select>
</td> </td>
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;"> <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"> <div class="owner-cell">
@ -1139,127 +938,9 @@
</div> </div>
</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> <script>
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }}; 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) { function escapeTopAlertHtml(value) {
return String(value ?? '') return String(value ?? '')
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')
@ -1345,35 +1026,11 @@
const allRows = document.querySelectorAll('.tree-row'); const allRows = document.querySelectorAll('.tree-row');
let currentSearch = ''; let currentSearch = '';
let currentFilter = 'all'; let currentFilter = 'all';
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
let currentEmployeeId = '';
let currentEmployeeGroupIds = new Set();
let currentTypes = new Set(); let currentTypes = new Set();
let currentAssignees = new Set(); let currentAssignees = new Set();
let currentGroups = new Set(); let currentGroups = new Set();
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }}); 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 assigneeFilter = document.getElementById('assigneeFilter');
const groupFilter = document.getElementById('groupFilter'); const groupFilter = document.getElementById('groupFilter');
const assigneeFilterList = document.getElementById('assigneeFilterList'); const assigneeFilterList = document.getElementById('assigneeFilterList');
@ -1386,20 +1043,6 @@
function applyFilters() { function applyFilters() {
const search = currentSearch; 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 => { allRows.forEach(row => {
const text = row.textContent.toLowerCase(); const text = row.textContent.toLowerCase();
const status = String(row.dataset.status || '').toLowerCase(); const status = String(row.dataset.status || '').toLowerCase();
@ -1408,7 +1051,7 @@
const groupRaw = String(row.dataset.groupId || '').trim(); const groupRaw = String(row.dataset.groupId || '').trim();
const assigneeId = assigneeRaw || '__UNASSIGNED__'; const assigneeId = assigneeRaw || '__UNASSIGNED__';
const groupId = groupRaw; 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 isClosed = closedStatuses.has(status);
const matchesFilter = currentFilter === 'all' const matchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !isClosed) || (currentFilter === 'åben' && !isClosed)
@ -1417,7 +1060,7 @@
const matchesType = currentTypes.size === 0 || currentTypes.has(type); const matchesType = currentTypes.size === 0 || currentTypes.has(type);
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId); const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId); 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'; row.style.display = visible ? '' : 'none';
@ -1432,7 +1075,7 @@
const childGroupRaw = String(child.dataset.groupId || '').trim(); const childGroupRaw = String(child.dataset.groupId || '').trim();
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__'; const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
const childGroupId = childGroupRaw; 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 childIsClosed = closedStatuses.has(childStatus);
const childMatchesFilter = currentFilter === 'all' const childMatchesFilter = currentFilter === 'all'
|| (currentFilter === 'åben' && !childIsClosed) || (currentFilter === 'åben' && !childIsClosed)
@ -1441,7 +1084,7 @@
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType); const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId); const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId); 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'; child.style.display = childVisible ? '' : 'none';
}); });
} }
@ -1552,55 +1195,11 @@
if (searchInput) { if (searchInput) {
searchInput.addEventListener('input', function(e) { searchInput.addEventListener('input', function(e) {
currentSearch = e.target.value.trim().toLowerCase(); currentSearch = e.target.value.toLowerCase();
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
applyFilters(); 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 // Filter functionality
const filterPills = document.querySelectorAll('.filter-pill'); 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() { async function loadTypeFilterPreferences() {
try { try {
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' }); const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
@ -1851,7 +1335,6 @@
const data = await res.json(); const data = await res.json();
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : []; const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean)); currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
applySagColumnPayload(data);
applySelectedTypesToUi(); applySelectedTypesToUi();
applyFilters(); applyFilters();
} catch (err) { } catch (err) {
@ -1868,7 +1351,7 @@
method: 'PATCH', method: 'PATCH',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, 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 (!res.ok) throw new Error(`HTTP ${res.status}`);
if (typeof showNotification === 'function') { 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"> <input type="date" class="form-control" id="ordersDateTo">
</div> </div>
<div class="col-md-2"> <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>
<div class="col-md-3 text-end"> <div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span> <span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
@ -358,26 +358,7 @@
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum); 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', () => { 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(); loadOrders();
}); });
</script> </script>

View File

@ -7,10 +7,10 @@ router = APIRouter()
async def search_customers(q: str = Query(..., min_length=2)): async def search_customers(q: str = Query(..., min_length=2)):
""" """
Autocomplete search for customers. Autocomplete search for customers.
Returns customer identity and registered address for address-aware workflows. Returns list of {id, name, cvr_nummer, email}
""" """
sql = """ 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 FROM customers
WHERE (name ILIKE %s OR cvr_number ILIKE %s) WHERE (name ILIKE %s OR cvr_number ILIKE %s)
AND deleted_at IS NULL 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 ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
LIMIT 1 LIMIT 1
) AS user_company, ) 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') SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
FROM contact_companies cc 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 CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
OR c.phone ILIKE %s OR c.phone ILIKE %s
OR c.mobile 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 ORDER BY c.first_name ASC, c.last_name ASC
LIMIT 20 LIMIT 20
""" """
term = f"%{q}%" 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 return results
@router.get("/search/hardware") @router.get("/search/hardware")

View File

@ -18,7 +18,6 @@ from app.core.auth_dependencies import require_permission
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from app.services.sms_service import SmsService 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 .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
from .service import TelefoniService from .service import TelefoniService
@ -381,13 +380,6 @@ async def yealink_established(
raw_payload=json.dumps(payload), raw_payload=json.dumps(payload),
started_at=datetime.utcnow(), 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 # Send websocket notification to ALL users with this extension
if user_ids: if user_ids:
@ -448,19 +440,10 @@ async def yealink_terminated(
if duration_value is None: if duration_value is None:
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid) 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) updated = TelefoniService.terminate_call(resolved_callid, duration_value)
if not updated: if not updated:
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid) logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None 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", "time_entry_id": time_entry_id}
@ -860,7 +843,6 @@ async def list_calls(
limit: int = Query(200, ge=1, le=2000), limit: int = Query(200, ge=1, le=2000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
TelefoniService.reconcile_stale_calls()
where = [] where = []
params = [] params = []

View File

@ -9,24 +9,6 @@ logger = logging.getLogger(__name__)
class TelefoniService: 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 @staticmethod
def find_user_by_extension(extension: Optional[str]) -> list[int]: def find_user_by_extension(extension: Optional[str]) -> list[int]:
"""Find all users with the given extension - returns list of user_ids.""" """Find all users with the given extension - returns list of user_ids."""
@ -201,7 +183,6 @@ class TelefoniService:
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id), bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id), kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at) started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
, lifecycle_status = 'active', last_event_at = NOW()
RETURNING * RETURNING *
""" """
rows = execute_query( rows = execute_query(
@ -227,9 +208,9 @@ class TelefoniService:
rows = execute_query( rows = execute_query(
""" """
INSERT INTO telefoni_opkald 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 VALUES
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW()) (%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
ON CONFLICT (callid) ON CONFLICT (callid)
DO UPDATE SET DO UPDATE SET
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()), ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
@ -241,7 +222,7 @@ class TelefoniService:
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
ELSE NULL ELSE NULL
END END
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW() )
RETURNING id RETURNING id
""", """,
(callid, duration_sec), (callid, duration_sec),

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

View File

@ -1,397 +0,0 @@
"""Verified e-conomic identities, durable catalog work and deterministic prices."""
import asyncio
import json
import logging
import random
from contextlib import contextmanager
from datetime import date
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from urllib.parse import quote, urljoin, urlparse
import aiohttp
from fastapi import HTTPException
from psycopg2.extras import Json, RealDictCursor
from app.core.config import settings
from app.core.database import get_db_connection, release_db_connection
from app.core.economic_write_policy import assert_economic_write_allowed
logger = logging.getLogger(__name__)
@contextmanager
def transaction():
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
yield cur
conn.commit()
except Exception:
conn.rollback()
raise
finally:
release_db_connection(conn)
def query(sql, params=(), one=False):
with transaction() as cur:
cur.execute(sql, params)
if cur.description:
return cur.fetchone() if one else cur.fetchall()
class RemoteError(Exception):
def __init__(self, status, message, retry_after=0):
super().__init__(message)
self.status = status
self.retry_after = retry_after
class EconomicClient:
def __init__(self):
self.base = settings.ECONOMIC_API_URL.rstrip('/') + '/'
async def request(self, method, path, payload=None):
url = urljoin(self.base, path)
if urlparse(url).netloc != urlparse(self.base).netloc or urlparse(url).scheme != 'https':
raise RemoteError(400, 'Ugyldig e-conomic-adresse')
if method != 'GET' and (settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN):
raise RemoteError(423, 'Safety mode: ekstern skrivning er slået fra')
if method != 'GET':
try:
assert_economic_write_allowed(method, path)
except HTTPException as exc:
raise RemoteError(exc.status_code, exc.detail) from exc
headers = {'X-AppSecretToken': settings.ECONOMIC_APP_SECRET_TOKEN,
'X-AgreementGrantToken': settings.ECONOMIC_AGREEMENT_GRANT_TOKEN}
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30)) as session:
async with session.request(method, url, headers=headers, json=payload, allow_redirects=False) as response:
if 300 <= response.status < 400:
raise RemoteError(502, 'e-conomic returnerede en uventet omdirigering')
try:
body = await response.json(content_type=None)
except (ValueError, UnicodeDecodeError):
body = {}
if response.status >= 400:
# Never retain full payloads, headers or provider HTML in logs.
message = body.get('message', 'e-conomic afviste kaldet') if isinstance(body, dict) else 'e-conomic afviste kaldet'
for secret in headers.values():
if secret:
message = str(message).replace(secret, '[redacted]')
retry = response.headers.get('Retry-After', '0')
raise RemoteError(response.status, f'{response.status}: {str(message)[:400]}', int(retry) if retry.isdigit() else 0)
if not isinstance(body, dict):
raise RemoteError(502, 'Uventet svar fra e-conomic')
return body
async def collection(self, path, checkpoint_job=None):
result, seen = [], set()
next_page = path + ('&' if '?' in path else '?') + 'pagesize=1000'
while next_page:
if next_page in seen:
raise RemoteError(502, 'Gentaget side i e-conomic-import')
seen.add(next_page)
cached = query('SELECT payload FROM economic_catalog_import_pages WHERE job_id=%s AND page_key=%s', (checkpoint_job, next_page), one=True) if checkpoint_job else None
page = cached['payload'] if cached else await self.request('GET', next_page)
if not isinstance(page.get('collection'), list):
raise RemoteError(502, 'Importsvaret mangler en vareliste')
if checkpoint_job and not cached:
query('INSERT INTO economic_catalog_import_pages(job_id,page_key,ordinal,payload,next_page) VALUES(%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING', (checkpoint_job, next_page, len(seen), Json(page), page.get('pagination', {}).get('nextPage')))
result.extend(page['collection'])
next_page = page.get('pagination', {}).get('nextPage')
return result
async def verify(self, connection_id):
connection = query('SELECT * FROM economic_catalog_connections WHERE id=%s', (connection_id,), one=True)
if not connection:
raise RemoteError(409, 'Vælg e-conomic-forbindelse')
agreement = await self.request('GET', 'self')
number = str(agreement.get('agreementNumber') or '')
if not number or number != connection['agreement_number']:
raise RemoteError(409, 'e-conomic-aftalen afviger fra den registrerede forbindelse')
if (agreement.get('settings') or {}).get('baseCurrency') != connection['currency']:
raise RemoteError(409, 'Aftalens basisvaluta afviger; import og eksport er stoppet')
return connection
def product_number(value):
if not isinstance(value, str) or not value.strip() or len(value) > 25:
raise HTTPException(422, 'Varenummer skal være tekst med 125 tegn')
return value
def audit(cur, product_id, action, changes, user_id=None):
cur.execute('INSERT INTO product_audit_log(product_id,event_type,user_id,changes) VALUES(%s,%s,%s,%s)',
(product_id, action, user_id, Json(changes)))
def import_product(connection, remote, user_id=None):
number = product_number(remote.get('productNumber'))
name = str(remote.get('name') or '').strip()
if not name or len(name) > 255:
raise RemoteError(422, f'Vare {number}: navnet mangler eller overstiger Hub-grænsen på 255 tegn')
with transaction() as cur:
cur.execute('SELECT * FROM products WHERE economic_connection_id=%s AND economic_product_number=%s FOR UPDATE', (connection['id'], number))
existing = cur.fetchone()
group = (remote.get('productGroup') or {}).get('productGroupNumber')
unit = (remote.get('unit') or {}).get('unitNumber')
if existing:
if existing['economic_snapshot'] == remote and existing['economic_sync_status'] == 'synced' and existing['name'] == name:
return 'unchanged'
conflict = existing['name'] != name
pending = existing['economic_sync_status'] == 'pending'
baseline = (existing['economic_snapshot'] or {}).get('name')
external_change = name not in (baseline, existing['name'])
state = ('conflict' if external_change else
('local_only' if existing['economic_sync_status'] == 'local_only' and conflict else
('pending' if pending and conflict else ('conflict' if conflict else 'synced'))))
snapshot = dict(remote)
if pending and conflict and not external_change:
snapshot['name'] = baseline
cur.execute('''UPDATE products SET economic_product_group_number=%s,
economic_sales_price_reference=%s,economic_currency=%s,economic_unit_number=%s,
is_active_in_economic=%s,economic_snapshot=%s,economic_sync_status=%s,
economic_last_synced_at=now() WHERE id=%s''',
(group, remote.get('salesPrice'), connection['currency'], unit, not remote.get('barred', False), Json(snapshot), state, existing['id']))
return 'conflict' if state == 'conflict' else 'updated'
cur.execute('''INSERT INTO products(name,status,economic_connection_id,economic_product_number,
economic_product_group_number,economic_sales_price_reference,economic_currency,
economic_unit_number,is_active_in_economic,economic_snapshot,economic_sync_status,economic_last_synced_at,category_id)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'synced',now(),
(SELECT category_id FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s)) RETURNING id''',
(name, 'inactive' if remote.get('barred') else 'active', connection['id'], number,
group, remote.get('salesPrice'), connection['currency'], unit, not remote.get('barred', False), Json(remote), connection['id'], group))
audit(cur, cur.fetchone()['id'], 'economic_import', {'number': number}, user_id)
return 'created'
async def link_product(product_id, connection_id, number, user_id):
number = product_number(number)
client = EconomicClient()
connection = await client.verify(connection_id)
remote = await client.request('GET', 'products/' + quote(number, safe=''))
if remote.get('productNumber') != number:
raise HTTPException(409, 'Det returnerede varenummer afviger')
with transaction() as cur:
cur.execute('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
product = cur.fetchone()
if not product:
raise HTTPException(404, 'Vare ikke fundet')
if product['economic_product_number']:
raise HTTPException(409, 'Varen er allerede koblet; koblingen er låst')
cur.execute('SELECT id FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection_id, number))
if cur.fetchone():
raise HTTPException(409, 'Varenummeret er allerede koblet til et andet Hub-produkt')
cur.execute('''UPDATE products SET economic_connection_id=%s,economic_product_number=%s,
economic_snapshot=%s,economic_sync_status='pending',updated_by=%s WHERE id=%s''',
(connection_id, number, Json(remote), user_id, product_id))
audit(cur, product_id, 'economic_link', {'number': number}, user_id)
import_product(connection, remote, user_id)
# Initial name ownership is explicit; mismatch is a visible conflict.
query("UPDATE products SET economic_sync_status=CASE WHEN name=%s THEN 'synced' ELSE 'conflict' END WHERE id=%s", (remote['name'], product_id))
return {'linked': True, 'number': number}
def enqueue(connection_id, kind, user_id, product_id=None, payload=None):
with transaction() as cur:
# Serialize reservation/import enqueue for this agreement.
cur.execute('SELECT * FROM economic_catalog_connections WHERE id=%s FOR UPDATE', (connection_id,))
connection = cur.fetchone()
if not connection:
raise HTTPException(404, 'Forbindelsen findes ikke')
if kind in ('import', 'preview'):
cur.execute("SELECT id FROM economic_catalog_jobs WHERE connection_id=%s AND kind=%s AND status IN ('pending','running','retry')", (connection_id, kind))
else:
cur.execute("SELECT id FROM economic_catalog_jobs WHERE product_id=%s AND kind='create' AND status NOT IN ('failed','cancelled')", (product_id,))
previous = cur.fetchone()
if previous:
return {'id': previous['id']}
data = dict(payload or {})
if kind == 'create':
cur.execute('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
product = cur.fetchone()
if not product or product['economic_product_number']:
raise HTTPException(409, 'Varen mangler eller er allerede koblet')
data['productNumber'] = product_number(connection['number_prefix'] + str(connection['next_number']))
data['name'] = product['name']
cur.execute('UPDATE economic_catalog_connections SET next_number=next_number+1 WHERE id=%s', (connection_id,))
cur.execute('''INSERT INTO economic_catalog_jobs(connection_id,product_id,kind,payload,created_by)
VALUES(%s,%s,%s,%s,%s) RETURNING id''', (connection_id, product_id, kind, Json(data), user_id))
return dict(cur.fetchone())
async def process_job(job):
client = EconomicClient()
connection = await client.verify(job['connection_id'])
if job['kind'] == 'name':
# Product names stay local. This is intentionally not an e-conomic edit.
return {'local_only': True}
if job['kind'] == 'create' and not connection['enabled']:
raise RemoteError(423, 'Aktivér forbindelsen før ekstern skrivning')
if job['kind'] in ('import', 'preview'):
for kind, number_key in [('product-groups', 'productGroupNumber'), ('units', 'unitNumber')]:
for row in await client.collection(kind):
query('''INSERT INTO economic_catalog_references(connection_id,kind,number,name,payload)
VALUES(%s,%s,%s,%s,%s) ON CONFLICT(connection_id,kind,number)
DO UPDATE SET name=EXCLUDED.name,payload=EXCLUDED.payload''',
(connection['id'], kind, row[number_key], row.get('name', ''), Json(row)))
# Fetch completes before applying products; partial retrieval never masquerades as complete.
products = await client.collection('products', checkpoint_job=job['id'])
result = {'created': 0, 'updated': 0, 'unchanged': 0, 'conflict': 0, 'unlinked': 0, 'total': len(products)}
for remote in products:
if job['kind'] == 'preview':
found = query('SELECT id,name,economic_snapshot FROM products WHERE economic_connection_id=%s AND economic_product_number=%s', (connection['id'], remote['productNumber']), one=True)
state = 'unlinked' if not found else ('unchanged' if found['economic_snapshot'] == remote and found['name'] == remote['name'] else 'updated' if found['name'] == remote['name'] else 'conflict')
else:
state = import_product(connection, remote, job['created_by'])
result[state] += 1
if job['kind'] == 'import':
query('UPDATE economic_catalog_connections SET last_synced_at=now() WHERE id=%s', (connection['id'],))
return result
product = query('SELECT * FROM products WHERE id=%s', (job['product_id'],), one=True)
if not product or product['deleted_at']:
raise RemoteError(409, 'Varen er deaktiveret eller slettet')
if job['kind'] == 'name':
if product['economic_sync_status'] == 'conflict':
raise RemoteError(409, 'Navnekonflikten skal løses før synkronisering')
if job['version'] != product['economic_name_version']:
return {'superseded': True}
number = product['economic_product_number']
remote = await client.request('GET', 'products/' + quote(number, safe=''))
baseline = (product['economic_snapshot'] or {}).get('name')
if remote['name'] not in (baseline, product['name']) and not job['payload'].get('force'):
query("UPDATE products SET economic_sync_status='conflict',economic_snapshot=%s WHERE id=%s", (Json(remote), product['id']))
raise RemoteError(409, 'Navnet er også ændret i e-conomic; vælg hvilket navn der skal bruges')
allowed = ('productNumber','name','description','costPrice','recommendedPrice','salesPrice','barCode','barred','unit','productGroup','departmentalDistribution')
payload = {key: remote[key] for key in allowed if key in remote}
payload['name'] = product['name']
await client.request('PUT', 'products/' + quote(number, safe=''), payload)
query('''UPDATE products SET economic_snapshot=%s,economic_sync_status='synced',economic_last_synced_at=now()
WHERE id=%s AND economic_name_version=%s''', (Json(payload), product['id'], job['version']))
return {'number': number}
if job['kind'] == 'create':
payload = dict(job['payload'])
number = payload['productNumber']
# A known collision is not our product. Ambiguous prior POST is never retried automatically.
try:
await client.request('GET', 'products/' + quote(number, safe=''))
except RemoteError as exc:
if exc.status != 404:
raise
else:
raise RemoteError(409, 'Det reserverede nummer findes allerede; vælg et nyt oprettelsesforsøg')
group = payload.get('productGroup', {}).get('productGroupNumber')
reference = query("SELECT * FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s", (connection['id'], group), one=True)
if not reference or not reference['allow_new'] or reference['phasing_out']:
raise RemoteError(422, 'Vælg en tilladt varegruppe')
await client.request('GET', f'product-groups/{int(group)}')
if payload.get('unit'):
await client.request('GET', f"units/{int(payload['unit']['unitNumber'])}")
# Persist that a POST can have happened before making it.
query("UPDATE economic_catalog_jobs SET status='uncertain' WHERE id=%s", (job['id'],))
remote = await client.request('POST', 'products', payload)
if remote.get('productNumber') != number:
raise RemoteError(502, 'Oprettelsessvaret skal afklares manuelt')
with transaction() as cur:
cur.execute('UPDATE products SET economic_connection_id=%s,economic_product_number=%s WHERE id=%s', (connection['id'], number, product['id']))
audit(cur, product['id'], 'economic_created', {'number': number}, job['created_by'])
import_product(connection, remote, job['created_by'])
return {'number': number}
raise RemoteError(422, 'Ukendt jobtype')
async def run_catalog_jobs():
# Single DB advisory lock works across web workers. Keep this connection for the lease.
with transaction() as lock:
lock.execute("SELECT to_regclass('economic_catalog_jobs') AS present")
if not lock.fetchone()['present']:
return
lock.execute('SELECT pg_try_advisory_xact_lock(24201) AS acquired')
if not lock.fetchone()['acquired']:
return
query("UPDATE economic_catalog_jobs SET status=CASE WHEN kind='create' THEN 'uncertain' ELSE 'retry' END WHERE status='running' AND lease_until < now()")
for connection in query('SELECT * FROM economic_catalog_connections WHERE enabled AND auto_sync AND (next_sync_at IS NULL OR next_sync_at<=now())'):
enqueue(connection['id'], 'import', None)
query("UPDATE economic_catalog_connections SET next_sync_at=now()+sync_interval_minutes*interval '1 minute' WHERE id=%s", (connection['id'],))
jobs = query("SELECT * FROM economic_catalog_jobs WHERE status IN ('pending','retry') AND next_attempt_at<=now() ORDER BY created_at LIMIT 5")
for job in jobs:
query("UPDATE economic_catalog_jobs SET status='running',attempts=attempts+1,lease_until=now()+interval '10 minutes',updated_at=now() WHERE id=%s", (job['id'],))
try:
result = await process_job(job)
query("UPDATE economic_catalog_jobs SET status='done',result=%s,error=NULL,updated_at=now() WHERE id=%s", (Json(result), job['id']))
except (RemoteError, aiohttp.ClientError, asyncio.TimeoutError) as exc:
current = query('SELECT status FROM economic_catalog_jobs WHERE id=%s', (job['id'],), one=True)
status_code = getattr(exc, 'status', 503)
ambiguous = current['status'] == 'uncertain' and status_code >= 500
state = 'uncertain' if ambiguous else ('retry' if status_code in (423,429,500,502,503,504) and job['attempts'] < 4 else 'failed')
delay = max(getattr(exc, 'retry_after', 0), 30 * 2 ** min(job['attempts'], 8) + random.randint(0, 10))
message = str(exc) if isinstance(exc, RemoteError) else 'Forbindelsen blev afbrudt; resultatet skal kontrolleres'
query("UPDATE economic_catalog_jobs SET status=%s,error=%s,next_attempt_at=now()+%s*interval '1 second',updated_at=now() WHERE id=%s", (state, message, delay, job['id']))
if status_code in (401, 403):
query('UPDATE economic_catalog_connections SET enabled=false,auto_sync=false WHERE id=%s', (job['connection_id'],))
except Exception:
logger.exception('Catalog job failed: %s', job['id'])
query("UPDATE economic_catalog_jobs SET status=CASE WHEN status='uncertain' THEN status ELSE 'failed' END,error='Intern fejl; se korrelations-id',updated_at=now() WHERE id=%s", (job['id'],))
def decimal(value):
try:
number = Decimal(str(value))
if not number.is_finite():
raise ValueError()
return number
except (InvalidOperation, ValueError, TypeError):
raise HTTPException(422, 'Ugyldigt beløb')
def calculate_price(product, rules, customer_id=None, currency='DKK', manual=None):
"""Pure calculation; never changes saved document lines."""
source, rule_id, rule_version = 'hub_standard', None, None
base = product.get('sales_price') if product.get('sales_currency', 'DKK') == currency else None
if base is None and product.get('economic_currency') == currency:
base, source = product.get('economic_sales_price_reference'), 'economic_reference'
eligible = []
today = date.today()
for rule in rules:
if not rule.get('active', True) or rule['currency'] != currency:
continue
if rule.get('valid_from') and rule['valid_from'] > today or rule.get('valid_to') and rule['valid_to'] < today:
continue
if rule.get('customer_id') and rule['customer_id'] != customer_id:
continue
if rule.get('product_id') and rule['product_id'] != product['id']:
continue
if rule.get('category_id') and rule['category_id'] not in (product.get('category_id'), product.get('subcategory_id')):
continue
rank = 4 if rule.get('customer_id') and rule.get('product_id') else 3 if rule.get('customer_id') else 2 if rule.get('product_id') else 1
eligible.append(((rank, rule.get('priority', 0)), rule))
eligible.sort(key=lambda item: item[0], reverse=True)
if manual is not None:
price, source = decimal(manual), 'manual'
elif eligible:
if len(eligible) > 1 and eligible[0][0] == eligible[1][0]:
raise HTTPException(409, 'Flere prisregler har samme prioritet')
rule = eligible[0][1]
value = decimal(rule['value'])
source, rule_id = rule['name'], rule['id']
rule_version = rule.get('version', 1)
if rule['kind'] == 'fixed':
price = value
elif rule['kind'] == 'discount':
if base is None:
raise HTTPException(422, 'Rabatreglen mangler en grundpris i den valgte valuta')
price = decimal(base) * (1 - value / 100)
else:
if product.get('cost_price') is None or product.get('cost_currency') != currency:
raise HTTPException(422, 'Tillæg kræver kost i samme valuta')
price = decimal(product['cost_price']) * (1 + value / 100)
else:
if base is None:
raise HTTPException(422, 'Ingen pris i den valgte valuta')
price = decimal(base)
if price < 0:
raise HTTPException(422, 'Prisen må ikke være negativ')
return {'unit_price': str(price.quantize(Decimal('.01'), rounding=ROUND_HALF_UP)),
'currency': currency, 'source': source, 'rule_id': rule_id, 'rule_version': rule_version,
'product_id': product['id'], 'economic_product_number': product.get('economic_product_number'),
'name': product['name'], 'calculated_at': today.isoformat()}

View File

@ -1,255 +0,0 @@
"""Catalog-aware draft exports. Uncertain POSTs are never repeated automatically."""
import asyncio
import hashlib
import json
from datetime import date
from decimal import Decimal, ROUND_HALF_UP
from urllib.parse import quote
import aiohttp
from fastapi import HTTPException
from psycopg2.extras import Json
from app.products.backend.economic_catalog import EconomicClient, RemoteError, decimal, query, transaction
from app.core.config import settings
def active_connection():
present = query("SELECT to_regclass('economic_catalog_connections') AS present", one=True)
if not present['present']:
return None
connections = query('SELECT * FROM economic_catalog_connections WHERE enabled ORDER BY id')
if len(connections) > 1:
raise HTTPException(409, 'Kun én e-conomic-aftale må være aktiv for eksport')
return connections[0] if connections else None
def amount(value):
return decimal(value).quantize(Decimal('.01'), rounding=ROUND_HALF_UP)
def external_reference(export_row_id, external_id=None):
"""Use the saved Hub order id in e-conomic, with the legacy marker as fallback."""
return str(external_id)[:100] if external_id is not None else 'BMC-HUB:' + str(export_row_id)
def line_snapshot(line, product, connection_id, currency):
if not product or not product.get('economic_product_number') or product.get('economic_connection_id') != connection_id:
raise HTTPException(409, 'Varen mangler en verificeret kobling. Åbn Varer og e-conomic for at knytte eller oprette varen.')
previous = line.get('document_snapshot') or {}
if previous.get('economic_product_number') and (previous['economic_product_number'] != product['economic_product_number'] or previous.get('economic_connection_id') != connection_id):
raise HTTPException(409, 'Varens kobling er ændret siden kladden blev gemt. Opret en ny dokumentrevision; historikken ændres ikke.')
if product.get('deleted_at') or product.get('status') != 'active' or product.get('lifecycle_status') in ('inactive', 'phasing_out') or product.get('is_active_in_economic') is False:
raise HTTPException(409, f"Varen {product['name']} er inaktiv eller spærret")
if line.get('currency', 'DKK') != currency:
raise HTTPException(409, 'Linjevaluta og dokumentvaluta er forskellige; ingen automatisk ommærkning er tilladt')
quantity, price, discount = amount(line.get('quantity')), amount(line.get('unit_price')), amount(line.get('discount_percentage', 0))
if quantity <= 0 or price < 0 or not 0 <= discount <= 100:
raise HTTPException(422, 'Ugyldig mængde, pris eller rabat')
return {'product_id': product['id'], 'economic_connection_id': connection_id,
'economic_product_number': product['economic_product_number'], 'name': product['name'],
'description': line.get('description') or product['name'], 'quantity': str(quantity),
'unit_price': str(price), 'discount_percentage': str(discount), 'currency': currency,
'unit_number': product.get('economic_unit_number'),
'net_total': str(amount(quantity * price * (1 - discount / 100))),
'price_source': line.get('price_source', 'saved_line'),
'price_rule_version': line.get('price_rule_version'), 'captured_at': date.today().isoformat()}
async def preflight(connection, customer_id, lines, layout_number=None, currency='DKK', notes=None):
client = EconomicClient()
await client.verify(connection['id'])
customer = query('SELECT id,name,economic_customer_number FROM customers WHERE id=%s', (customer_id,), one=True)
if not customer or not customer['economic_customer_number']:
raise HTTPException(409, 'Kunden mangler e-conomic-debitornummer')
remote = await client.request('GET', f"customers/{int(customer['economic_customer_number'])}")
if remote.get('barred'):
raise HTTPException(409, 'Kunden er spærret i e-conomic')
payment = (remote.get('paymentTerms') or {}).get('paymentTermsNumber')
vat = (remote.get('vatZone') or {}).get('vatZoneNumber')
layout = layout_number or (remote.get('layout') or {}).get('layoutNumber')
if not payment or not vat or not layout:
raise HTTPException(409, 'Kunden mangler betalingsbetingelser, momszone eller layout')
await client.request('GET', f'layouts/{int(layout)}')
terms = await client.request('GET', f'payment-terms/{int(payment)}')
if str(terms.get('paymentTermsType', '')).lower() == 'duedate':
raise HTTPException(409, 'Betalingsbetingelsen kræver en eksplicit forfaldsdato; vælg en kreditbetingelse før eksport')
await client.request('GET', f'vat-zones/{int(vat)}')
await client.request('GET', 'currencies/' + quote(currency, safe=''))
snapshots, payload_lines, verified = [], [], {}
for line in lines:
if not line.get('selected', True):
continue
product = query('SELECT * FROM products WHERE id=%s', (line.get('product_id'),), one=True)
snap = line_snapshot(line, product, connection['id'], currency)
number = snap['economic_product_number']
if number not in verified:
external = await client.request('GET', 'products/' + quote(number, safe=''))
if external.get('productNumber') != number or external.get('barred'):
raise HTTPException(409, f'Varen {number} mangler eller er spærret i e-conomic')
group = (external.get('productGroup') or {}).get('productGroupNumber')
if not group:
raise HTTPException(409, f'Varen {number} mangler varegruppe')
await client.request('GET', f'product-groups/{int(group)}')
unit = (external.get('unit') or {}).get('unitNumber')
if unit:
await client.request('GET', f'units/{int(unit)}')
verified[number] = external
external = verified[number]
if (external.get('unit') or {}).get('unitNumber') != snap['unit_number']:
raise HTTPException(409, f'Enheden på vare {number} er ændret; synkronisér varen først')
payload_line = {'product': {'productNumber': number}, 'description': snap['description'],
'quantity': float(snap['quantity']), 'unitNetPrice': float(snap['unit_price']),
'discountPercentage': float(snap['discount_percentage'])}
if snap['unit_number']:
payload_line['unit'] = {'unitNumber': snap['unit_number']}
snapshots.append(snap)
payload_lines.append(payload_line)
if not snapshots:
raise HTTPException(422, 'Ingen linjer valgt')
payload = {'date': date.today().isoformat(), 'currency': currency,
'customer': {'customerNumber': int(customer['economic_customer_number'])},
'paymentTerms': {'paymentTermsNumber': int(payment)}, 'layout': {'layoutNumber': int(layout)},
'recipient': {k: str(remote.get(k) or '') for k in ('name','address','zip','city','country')},
'lines': payload_lines}
payload['recipient']['vatZone'] = {'vatZoneNumber': int(vat)}
if notes:
payload['notes'] = {'textLine1': str(notes)[:1000]}
return {'payload': payload, 'lines': snapshots, 'customer_id': customer_id}
def result_for(row):
key = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
snapshot = row.get('snapshot') or {}
payload = snapshot.get('payload') or {}
response = row.get('response') or {}
return {'success': True, 'dry_run': False, key: row['economic_number'],
'economic_draft_id': row['economic_number'], 'export_id': str(row['id']),
'details': {'request_payload': payload, 'economic_response': response,
'economic_customer_number': (payload.get('customer') or {}).get('customerNumber'),
'currency': payload.get('currency'),
'layout_number': (payload.get('layout') or {}).get('layoutNumber'),
'economic_order_url': response.get('self'), 'created_product_numbers': []},
'message': 'Eksporteret til e-conomic-kladde'}
def store_success(row, response, number):
with transaction() as cur:
cur.execute("UPDATE economic_document_exports SET status='verified',response=%s,economic_number=%s,error=NULL,updated_at=now() WHERE id=%s RETURNING *", (Json(response), str(number), row['id']))
saved = cur.fetchone()
if row['document_key'].startswith('order-draft:') and row['document_key'].split(':')[-1].isdigit():
column = 'economic_order_number' if row['kind'] == 'order' else 'economic_invoice_number'
cur.execute(f"UPDATE ordre_drafts SET {column}=%s,sync_status='exported',last_exported_at=now(),updated_at=now() WHERE id=%s", (str(number), int(row['document_key'].split(':')[-1])))
return saved
async def export_document(connection, kind, document_key, customer_id, lines, layout_number=None,
currency='DKK', notes=None, user_id=None, module='orders', external_id=None):
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: ekstern skrivning er slået fra')
if module == 'timetracking' and (settings.TIMETRACKING_ECONOMIC_READ_ONLY or settings.TIMETRACKING_ECONOMIC_DRY_RUN):
raise HTTPException(409, 'Tidsmodulets safety mode blokerer ekstern skrivning')
if kind == 'order' and module == 'orders':
from app.modules.orders.backend.economic_export import ordre_economic_export_service
if ordre_economic_export_service.read_only or ordre_economic_export_service.dry_run:
raise HTTPException(409, 'Ordremodulets safety mode blokerer ekstern skrivning')
# Preserve the identity of the saved local document, even if a caller changes its contents.
previous = query('SELECT * FROM economic_document_exports WHERE connection_id=%s AND kind=%s AND document_key=%s',
(connection['id'], kind, document_key), one=True)
if previous:
if previous['status'] == 'verified':
return result_for(previous)
raise HTTPException(409, f"Eksporten er {previous['status']}. Afklar det eksisterende forsøg før et nyt.")
snapshot = await preflight(connection, customer_id, lines, layout_number, currency, notes)
with transaction() as cur:
cur.execute('''INSERT INTO economic_document_exports(connection_id,kind,document_key,snapshot,created_by)
VALUES(%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING RETURNING *''',
(connection['id'], kind, document_key, Json(snapshot), user_id))
row = cur.fetchone()
if not row:
raise HTTPException(409, 'Eksporten behandles allerede')
marker = external_reference(row['id'], external_id)
payload = snapshot['payload']
payload['references'] = {'other': marker}
snapshot['payload'] = payload
query("UPDATE economic_document_exports SET snapshot=%s,status='uncertain',updated_at=now() WHERE id=%s", (Json(snapshot), row['id']))
try:
response = await EconomicClient().request('POST', 'orders/drafts' if kind == 'order' else 'invoices/drafts', payload)
number = response.get('orderNumber') or response.get('draftOrderNumber') if kind == 'order' else response.get('draftInvoiceNumber')
if not number:
raise RemoteError(502, 'Eksportsvaret mangler dokumentnummer; afklar før nyt forsøg')
saved = store_success(row, response, number)
return result_for(saved)
except RemoteError as exc:
# Only definitive client rejection permits a later, explicit retry.
rejected = 400 <= exc.status < 500 and exc.status not in (408,)
query('UPDATE economic_document_exports SET status=%s,error=%s,updated_at=now() WHERE id=%s',
('rejected' if rejected else 'uncertain', str(exc), row['id']))
raise HTTPException(409 if rejected else 502, str(exc)) from exc
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
query("UPDATE economic_document_exports SET error='Timeout: kontrollér ekstern kladde før nyt forsøg' WHERE id=%s", (row['id'],))
raise HTTPException(502, 'Eksportresultatet er uafklaret. Brug Afstem under Varer og e-conomic; send ikke igen.') from exc
async def reconcile(export_id):
row = query('SELECT * FROM economic_document_exports WHERE id=%s', (export_id,), one=True)
if not row:
raise HTTPException(404, 'Eksportforsøg findes ikke')
if row['status'] == 'verified':
return result_for(row)
client = EconomicClient()
await client.verify(row['connection_id'])
if row['kind'] != 'order':
raise HTTPException(403, 'Hub må kun afstemme ordrekladder')
path = 'orders/drafts'
expected = row['snapshot']['payload']
marker = (expected.get('references') or {}).get('other') or external_reference(row['id'])
matches = [r for r in await client.collection(path) if (r.get('references') or {}).get('other') == marker]
if len(matches) != 1:
raise HTTPException(409, f'Fandt {len(matches)} kladder med den præcise Hub-reference. Ingen ny eksport tilladt; kontrollér også bogførte/slettede dokumenter i e-conomic.')
match = matches[0]
number = match.get('orderNumber') or match.get('draftOrderNumber') if row['kind'] == 'order' else match.get('draftInvoiceNumber')
if not number:
raise HTTPException(409, 'Matchet mangler dokumentnummer')
remote = await client.request('GET', path + '/' + str(int(number)))
if (remote.get('references') or {}).get('other') != marker or remote.get('currency') != expected['currency'] or (remote.get('customer') or {}).get('customerNumber') != expected['customer']['customerNumber']:
raise HTTPException(409, 'Dokumentets kunde, valuta eller reference afviger')
actual_lines = remote.get('lines', [])
if len(actual_lines) != len(expected['lines']) or any(
(a.get('product') or {}).get('productNumber') != e['product']['productNumber'] or
any(decimal(a.get(k, 0)) != decimal(e.get(k, 0)) for k in ('quantity','unitNetPrice','discountPercentage'))
for a, e in zip(actual_lines, expected['lines'])
):
raise HTTPException(409, 'Dokumentets linjer afviger fra det gemte snapshot')
saved = store_success(row, remote, number)
return result_for(saved)
def unsaved_key(customer_id, lines, notes, layout):
"""Legacy callers without a saved draft cannot bypass duplicate protection by clicking twice."""
data = json.dumps([customer_id, lines, notes, layout], sort_keys=True, default=str)
return 'unsaved:' + hashlib.sha256(data.encode()).hexdigest()
def snapshot_draft_lines(lines):
"""Add identity/price evidence without recalculating or requiring an external link."""
saved = []
for original in lines:
line = dict(original)
p = query('SELECT id,name,economic_connection_id,economic_product_number,economic_unit_number FROM products WHERE id=%s', (line.get('product_id'),), one=True) if line.get('product_id') else None
previous = line.get('document_snapshot') or {}
identity = {'product_id': p['id'] if p else None, 'name': p['name'] if p else line.get('description'),
'economic_connection_id': p['economic_connection_id'] if p else None,
'economic_product_number': p['economic_product_number'] if p else None,
'unit_number': p['economic_unit_number'] if p else None,
'unit_price': str(decimal(line.get('unit_price', 0))),
'quantity': str(decimal(line.get('quantity', 1))),
'discount_percentage': str(decimal(line.get('discount_percentage', 0))),
'currency': line.get('currency', 'DKK'), 'description': line.get('description')}
if {k:v for k,v in previous.items() if k != 'captured_at'} != identity:
identity['captured_at'] = date.today().isoformat()
line['document_snapshot'] = identity
saved.append(line)
return saved

View File

@ -1,77 +0,0 @@
"""Explicit repricing of local drafts; saved contracts are never auto-repriced."""
import hashlib
import json
from fastapi import HTTPException
from psycopg2.extras import Json
from app.products.backend.economic_catalog import calculate_price, decimal, query, transaction
def validate_manual_prices(lines, customer_id, can_override, saved_lines=None):
"""Accept preserved agreed prices; new deviations require the dedicated permission."""
if can_override:
return
saved = {str(line.get('line_key')): line for line in (saved_lines or []) if line.get('line_key')}
rules = query('SELECT * FROM economic_price_rules WHERE active')
for line in lines:
old = saved.get(str(line.get('line_key')))
if old and old.get('product_id') == line.get('product_id') and all(
decimal(old.get(key, 0)) == decimal(line.get(key, 0)) for key in ('unit_price', 'discount_percentage')
) and old.get('currency', 'DKK') == line.get('currency', 'DKK'):
continue
product = query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (line.get('product_id'),), one=True)
if not product:
raise HTTPException(403, 'Vælg en vare med beregnet pris, eller få rettighed til manuel pris')
calculated = calculate_price(product, rules, customer_id, line.get('currency', 'DKK'))
if decimal(calculated['unit_price']) != decimal(line.get('unit_price', 0)) or decimal(line.get('discount_percentage', 0)) != 0:
raise HTTPException(403, 'Prisen/rabatten afviger fra den beregnede pris. Manuel pris kræver særskilt rettighed.')
def proposals(draft):
lines = draft.get('lines_json') or []
if isinstance(lines, str):
lines = json.loads(lines)
rules = query('SELECT * FROM economic_price_rules WHERE active')
result = []
for index, line in enumerate(lines):
if not line.get('product_id') or line.get('source_type') == 'subscription':
# The existing subscription price is an agreed contract price.
continue
p = query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (line['product_id'],), one=True)
if not p:
raise HTTPException(409, f'Produktet på linje {index+1} findes ikke')
price = calculate_price(p, rules, draft['customer_id'], line.get('currency', 'DKK'))
result.append({'index': index, 'description': line.get('description', p['name']),
'before': str(line.get('unit_price')), 'after': price['unit_price'], 'snapshot': price})
token = hashlib.sha256(json.dumps([draft['id'], lines, result], sort_keys=True, default=str).encode()).hexdigest()
return {'changes': result, 'token': token, 'subscription_prices_preserved': True}
def reprice(draft_id, token=None, user_id=None):
with transaction() as cur:
cur.execute('SELECT * FROM ordre_drafts WHERE id=%s FOR UPDATE', (draft_id,))
draft = cur.fetchone()
if not draft:
raise HTTPException(404, 'Ordrekladde ikke fundet')
if draft.get('sync_status') in ('exported','posted','paid'):
raise HTTPException(409, 'Eksporterede/bogførte dokumenter må ikke genberegnes')
cur.execute("SELECT id FROM economic_document_exports WHERE document_key=%s AND status NOT IN ('rejected','archived')", (f'order-draft:{draft_id}',))
if cur.fetchone():
raise HTTPException(409, 'Dokumentet har en afsendt eller uafklaret eksport')
preview = proposals(draft)
if token is None:
return preview
if token != preview['token']:
raise HTTPException(409, 'Priser eller linjer er ændret siden preview. Kontrollér priserne igen.')
lines = draft['lines_json'] if isinstance(draft['lines_json'], list) else json.loads(draft['lines_json'])
for change in preview['changes']:
line = lines[change['index']]
line['unit_price'] = float(change['after'])
line['price_snapshot'] = change['snapshot']
line['price_source'] = change['snapshot']['source']
line['price_rule_version'] = change['snapshot']['rule_version']
line['amount'] = float(decimal(line.get('quantity', 1)) * decimal(change['after']) * (1-decimal(line.get('discount_percentage', 0))/100))
cur.execute('UPDATE ordre_drafts SET lines_json=%s,updated_at=now() WHERE id=%s', (Json(lines), draft_id))
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('order',%s,'reprice',%s,%s)", (str(draft_id), user_id, Json(preview)))
return {'saved': True, **preview}

View File

@ -1,579 +0,0 @@
"""Catalog administration uses existing Hub authentication and product records."""
from typing import Any, Dict, Optional
from datetime import date
from decimal import Decimal
from uuid import UUID
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.routing import APIRoute
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.auth_dependencies import require_permission
from app.core.config import settings
from app.core.economic_write_policy import approved_four_eyes_write
from app.products.backend import economic_catalog as catalog
class CatalogRoute(APIRoute):
def get_route_handler(self):
handler = super().get_route_handler()
async def wrapped(request):
try:
return await handler(request)
except catalog.RemoteError as exc:
raise HTTPException(409 if exc.status < 500 else 502, str(exc)) from exc
return wrapped
router = APIRouter(route_class=CatalogRoute)
views = APIRouter()
read = require_permission('economic.catalog.view')
manage = require_permission('economic.catalog.manage')
class ConnectionInput(BaseModel):
agreement_number: str = Field(min_length=1, max_length=30)
name: str = Field(min_length=1, max_length=100)
currency: str = Field(pattern='^[A-Z]{3}$')
class ConfigInput(BaseModel):
enabled: bool = False
auto_sync: bool = False
sync_interval_minutes: int = Field(default=15, ge=5, le=1440)
number_prefix: str = Field(default='BMC-', min_length=1, max_length=10, pattern='^[A-Za-z0-9-]+$')
time_unit_number: Optional[int] = Field(default=None, gt=0)
class LinkInput(BaseModel):
connection_id: int
number: str = Field(min_length=1, max_length=25)
class CreateInput(BaseModel):
connection_id: int
group_number: int = Field(gt=0)
unit_number: Optional[int] = Field(default=None, gt=0)
class RuleInput(BaseModel):
name: str = Field(min_length=1, max_length=100)
product_id: Optional[int] = None
customer_id: Optional[int] = None
category_id: Optional[int] = None
kind: str = Field(pattern='^(fixed|discount|cost_markup)$')
value: Decimal = Field(ge=0, allow_inf_nan=False)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
priority: int = 0
valid_from: Optional[date] = None
valid_to: Optional[date] = None
class PriceInput(BaseModel):
product_id: int
customer_id: Optional[int] = None
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
class StandardPriceInput(BaseModel):
sales_price: str
sales_currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
cost_currency: Optional[str] = Field(default=None, pattern='^[A-Z]{3}$')
class StateInput(BaseModel):
lifecycle_status: str = Field(pattern='^(draft|active|phasing_out|inactive)$')
category_id: Optional[int] = None
subcategory_id: Optional[int] = None
class EconomicChangeInput(BaseModel):
connection_id: int
target_type: str = Field(pattern='^(customer|product)$')
target_number: str = Field(min_length=1, max_length=25)
changes: Dict[str, Any]
reason: str = Field(min_length=10, max_length=500)
class EconomicReviewInput(BaseModel):
note: Optional[str] = Field(default=None, max_length=500)
EDITABLE_ECONOMIC_FIELDS = {
'product': {'name', 'salesPrice', 'barred', 'productGroup', 'unit'},
'customer': {'name', 'address', 'zip', 'city', 'email', 'telephoneAndFaxNumber',
'corporateIdentificationNumber', 'currency', 'customerGroup',
'paymentTerms', 'vatZone'},
}
def _economic_target_path(target_type: str, target_number: str) -> str:
collection = 'customers' if target_type == 'customer' else 'products'
return f'{collection}/{quote(str(target_number).strip(), safe="")}'
def _editable_snapshot(target_type: str, source: dict) -> dict:
return {key: source.get(key) for key in EDITABLE_ECONOMIC_FIELDS[target_type] if key in source}
def _validate_change_fields(target_type: str, changes: dict) -> dict:
if not changes:
raise HTTPException(422, 'Vælg mindst ét felt, der skal ændres')
unknown = set(changes) - EDITABLE_ECONOMIC_FIELDS[target_type]
if unknown:
raise HTTPException(422, 'Feltet kan ikke ændres via Hub: ' + ', '.join(sorted(unknown)))
if 'name' in changes and not str(changes['name']).strip():
raise HTTPException(422, 'Navn må ikke være tomt')
return changes
@router.patch('/products/{product_id}/economic-state')
async def product_state(product_id: int, payload: StateInput, user=Depends(manage)):
with catalog.transaction() as cur:
cur.execute('SELECT lifecycle_status,category_id,subcategory_id FROM products WHERE id=%s FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Vare ikke fundet')
cur.execute('UPDATE products SET lifecycle_status=%s,category_id=%s,subcategory_id=%s,updated_by=%s WHERE id=%s', (payload.lifecycle_status,payload.category_id,payload.subcategory_id,user['id'],product_id))
catalog.audit(cur, product_id, 'economic_internal_state', {'old': dict(old), 'new': payload.model_dump()}, user['id'])
return {'saved': True}
@router.patch('/products/{product_id}/economic-pricing')
async def standard_price(product_id: int, payload: StandardPriceInput, user=Depends(require_permission('economic.pricing.manage'))):
value = catalog.decimal(payload.sales_price)
if value < 0:
raise HTTPException(422, 'Prisen må ikke være negativ')
with catalog.transaction() as cur:
cur.execute('SELECT sales_price,sales_currency FROM products WHERE id=%s AND deleted_at IS NULL FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Vare ikke fundet')
cur.execute('UPDATE products SET sales_price=%s,sales_currency=%s,cost_currency=COALESCE(%s,cost_currency),updated_by=%s WHERE id=%s', (value, payload.sales_currency, payload.cost_currency, user['id'], product_id))
cur.execute("INSERT INTO product_price_history(product_id,price_type,old_price,new_price,note,changed_by) VALUES(%s,'sales_price',%s,%s,%s,%s)", (product_id, old['sales_price'], value, f"Valuta {old['sales_currency']}{payload.sales_currency}", user['id']))
catalog.audit(cur, product_id, 'economic_hub_price', {'old': str(old['sales_price']), 'new': str(value), 'currency': payload.sales_currency}, user['id'])
return {'saved': True}
@views.get('/settings/economic-products')
async def page(request: Request, user=Depends(read)):
return Jinja2Templates(directory='app').TemplateResponse('products/frontend/economic.html', {'request': request})
@router.get('/economic/catalog/status')
async def status(user=Depends(read)):
from app.core.auth_service import AuthService
from app.modules.orders.backend.economic_export import ordre_economic_export_service
permissions = {name: bool(user.get('is_superadmin') or AuthService.user_has_permission(user['id'], name)) for name in (
'economic.catalog.manage', 'economic.catalog.rebind', 'economic.pricing.manage', 'economic.documents.export',
'economic.changes.request', 'economic.changes.approve', 'products.update')}
return {'connections': catalog.query('SELECT * FROM economic_catalog_connections ORDER BY id'),
'permissions': permissions,
'read_only': settings.ECONOMIC_READ_ONLY, 'dry_run': settings.ECONOMIC_DRY_RUN,
'order_read_only': ordre_economic_export_service.read_only,
'order_dry_run': ordre_economic_export_service.dry_run,
'time_read_only': settings.TIMETRACKING_ECONOMIC_READ_ONLY,
'time_dry_run': settings.TIMETRACKING_ECONOMIC_DRY_RUN,
'jobs': catalog.query('SELECT id,kind,status,error,result,product_id,created_at,attempts FROM economic_catalog_jobs ORDER BY created_at DESC LIMIT 100'),
'exports': catalog.query('SELECT id,kind,status,economic_number,error,created_at FROM economic_document_exports ORDER BY created_at DESC LIMIT 100'),
'change_requests': catalog.query('''SELECT r.*, requester.full_name AS requester_name,
reviewer.full_name AS reviewer_name FROM economic_change_requests r
LEFT JOIN users requester ON requester.user_id=r.requested_by_user_id
LEFT JOIN users reviewer ON reviewer.user_id=r.reviewed_by_user_id
ORDER BY r.requested_at DESC LIMIT 100''')}
@router.post('/economic/catalog/connections')
async def connect(payload: ConnectionInput, user=Depends(manage)):
remote = await catalog.EconomicClient().request('GET', 'self')
actual = str(remote.get('agreementNumber') or '')
if actual != payload.agreement_number:
raise HTTPException(409, 'Aftalen matcher ikke de konfigurerede credentials')
if payload.currency != (remote.get('settings') or {}).get('baseCurrency'):
raise HTTPException(409, 'Valutaen skal svare til aftalens basisvaluta')
return catalog.query('''INSERT INTO economic_catalog_connections(agreement_number,name,currency)
VALUES(%s,%s,%s) ON CONFLICT(agreement_number) DO UPDATE SET name=EXCLUDED.name RETURNING *''',
(actual, payload.name, payload.currency), one=True)
@router.get('/economic/catalog/identity')
async def identity(user=Depends(manage)):
remote = await catalog.EconomicClient().request('GET', 'self')
return {'agreement_number':str(remote.get('agreementNumber') or ''),
'name':(remote.get('company') or {}).get('name') or 'e-conomic',
'currency':(remote.get('settings') or {}).get('baseCurrency') or ''}
@router.patch('/economic/catalog/connections/{connection_id}')
async def configure(connection_id: int, payload: ConfigInput, user=Depends(manage)):
await catalog.EconomicClient().verify(connection_id)
if payload.time_unit_number:
await catalog.EconomicClient().request('GET', f'units/{payload.time_unit_number}')
return catalog.query('''UPDATE economic_catalog_connections SET enabled=%s,auto_sync=%s,
sync_interval_minutes=%s,number_prefix=%s,time_unit_number=%s WHERE id=%s RETURNING *''',
(payload.enabled, payload.auto_sync, payload.sync_interval_minutes, payload.number_prefix, payload.time_unit_number, connection_id), one=True)
@router.post('/economic/catalog/connections/{connection_id}/sync')
async def sync(connection_id: int, preview: bool = True, user=Depends(manage)):
await catalog.EconomicClient().verify(connection_id)
return catalog.enqueue(connection_id, 'preview' if preview else 'import', user['id'])
@router.get('/economic/catalog/products')
async def products(q: str = '', offset: int = Query(0, ge=0), include_inactive: bool = False, user=Depends(read)):
# Deliberate field selection: supplier cost and remote payload are not returned.
return catalog.query('''SELECT id,name,sku_internal,er_number,economic_product_number,
economic_connection_id,economic_product_group_number,economic_unit_number,sales_price,
sales_currency,economic_sales_price_reference,economic_currency,status,lifecycle_status,
is_active_in_economic,economic_sync_status,economic_last_synced_at,category_id,subcategory_id
FROM products WHERE deleted_at IS NULL AND (%s OR (status='active' AND lifecycle_status IN ('active','draft') AND is_active_in_economic IS DISTINCT FROM false))
AND (name ILIKE %s OR economic_product_number ILIKE %s OR sku_internal ILIKE %s)
ORDER BY name,id LIMIT 100 OFFSET %s''', (include_inactive, '%'+q+'%', '%'+q+'%', '%'+q+'%', offset))
@router.get('/economic/catalog/connections/{connection_id}/references')
async def references(connection_id: int, user=Depends(read)):
return catalog.query('SELECT connection_id,kind,number,name,category_id,allow_new,phasing_out FROM economic_catalog_references WHERE connection_id=%s ORDER BY kind,name', (connection_id,))
class MappingInput(BaseModel):
category_id: Optional[int] = None
allow_new: bool = True
phasing_out: bool = False
@router.patch('/economic/catalog/connections/{connection_id}/groups/{number}')
async def group_mapping(connection_id: int, number: int, payload: MappingInput, user=Depends(manage)):
with catalog.transaction() as cur:
cur.execute("SELECT category_id,allow_new,phasing_out FROM economic_catalog_references WHERE connection_id=%s AND kind='product-groups' AND number=%s FOR UPDATE", (connection_id, number))
old = cur.fetchone()
if not old:
raise HTTPException(404, 'Varegruppen er ikke importeret')
cur.execute("UPDATE economic_catalog_references SET category_id=%s,allow_new=%s,phasing_out=%s WHERE connection_id=%s AND kind='product-groups' AND number=%s", (payload.category_id,payload.allow_new,payload.phasing_out,connection_id,number))
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('group',%s,'mapping',%s,%s)", (f'{connection_id}:{number}',user['id'],Json({'old':dict(old),'new':payload.model_dump()})))
return {'saved': True}
@router.post('/products/{product_id}/economic-link')
async def link(product_id: int, payload: LinkInput, user=Depends(manage)):
return await catalog.link_product(product_id, payload.connection_id, payload.number, user['id'])
class RebindInput(LinkInput):
reason: str = Field(min_length=10, max_length=500)
confirmation_number: str
@router.post('/products/{product_id}/economic-rebind')
async def rebind(product_id: int, payload: RebindInput, user=Depends(require_permission('economic.catalog.rebind'))):
from urllib.parse import quote
number = catalog.product_number(payload.number)
client = catalog.EconomicClient()
await client.verify(payload.connection_id)
remote = await client.request('GET', 'products/' + quote(number, safe=''))
if remote.get('productNumber') != number:
raise HTTPException(409, 'Det eksterne varenummer afviger')
with catalog.transaction() as cur:
cur.execute('SELECT pg_try_advisory_xact_lock(24201) AS acquired')
if not cur.fetchone()['acquired']:
raise HTTPException(409, 'Synkronisering kører. Prøv omkobling, når den er færdig.')
cur.execute('SELECT * FROM products WHERE id=%s FOR UPDATE', (product_id,))
old = cur.fetchone()
if not old or not old['economic_product_number'] or old['economic_product_number'] != payload.confirmation_number:
raise HTTPException(409, 'Bekræft det nuværende varenummer præcist')
cur.execute("SELECT id FROM economic_catalog_jobs WHERE product_id=%s AND status IN ('running','uncertain')", (product_id,))
if cur.fetchone():
raise HTTPException(409, 'Varen har et uafklaret eller igangværende job')
cur.execute('SELECT id FROM products WHERE economic_connection_id=%s AND economic_product_number=%s AND id<>%s', (payload.connection_id, number, product_id))
if cur.fetchone():
raise HTTPException(409, 'Målet er allerede koblet til et andet Hub-produkt')
cur.execute("SELECT set_config('bmc.economic_rebind',%s,true)", (str(product_id),))
cur.execute("UPDATE economic_catalog_jobs SET status='cancelled',error='Administrativ omkobling' WHERE product_id=%s AND status IN ('pending','retry','failed')", (product_id,))
cur.execute('''UPDATE products SET economic_connection_id=%s,economic_product_number=%s,
economic_name_version=economic_name_version+1,economic_snapshot=%s,economic_sync_status='unlinked',updated_by=%s WHERE id=%s''',
(payload.connection_id, number, Json(remote), user['id'], product_id))
catalog.audit(cur, product_id, 'economic_rebind', {'old_connection': old['economic_connection_id'],
'old_number': old['economic_product_number'], 'new_connection':payload.connection_id,
'new_number':number,'reason':payload.reason}, user['id'])
connection = catalog.query('SELECT * FROM economic_catalog_connections WHERE id=%s', (payload.connection_id,), one=True)
catalog.import_product(connection, remote, user['id'])
return {'rebound': True, 'historical_snapshots_unchanged': True}
@router.post('/products/{product_id}/economic-create')
async def create(product_id: int, payload: CreateInput, user=Depends(manage)):
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: vareoprettelse er slået fra')
await catalog.EconomicClient().verify(payload.connection_id)
data = {'productGroup': {'productGroupNumber': payload.group_number}}
if payload.unit_number:
data['unit'] = {'unitNumber': payload.unit_number}
return catalog.enqueue(payload.connection_id, 'create', user['id'], product_id, data)
class ResolveInput(BaseModel):
choice: str = Field(pattern='^economic$')
@router.post('/products/{product_id}/economic-resolve')
async def resolve(product_id: int, payload: ResolveInput, user=Depends(manage)):
product = catalog.query('SELECT * FROM products WHERE id=%s', (product_id,), one=True)
if not product or not product['economic_product_number']:
raise HTTPException(404, 'Koblet vare ikke fundet')
client = catalog.EconomicClient()
await client.verify(product['economic_connection_id'])
from urllib.parse import quote
remote = await client.request('GET', 'products/' + quote(product['economic_product_number'], safe=''))
with catalog.transaction() as cur:
cur.execute('SELECT * FROM products WHERE id=%s FOR UPDATE', (product_id,))
product = cur.fetchone()
if payload.choice == 'economic':
cur.execute("UPDATE products SET name=%s,economic_snapshot=%s,economic_name_version=economic_name_version+1,economic_sync_status='synced',updated_by=%s WHERE id=%s", (remote['name'], Json(remote), user['id'], product_id))
else:
raise HTTPException(403, 'Hub må ikke ændre eksisterende e-conomic-varer')
catalog.audit(cur, product_id, 'economic_conflict_resolved', {'choice': payload.choice}, user['id'])
return {'saved': True}
@router.post('/economic/catalog/jobs/{job_id}/retry')
async def retry(job_id: UUID, user=Depends(manage)):
job = catalog.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (str(job_id),), one=True)
if not job or job['status'] not in ('failed','retry'):
raise HTTPException(409, 'Jobbet kan ikke gentages; uafklarede oprettelser skal kontrolleres manuelt')
if job['kind'] == 'create':
raise HTTPException(409, 'Opret et nyt forsøg fra varen; det gamle nummer må ikke genbruges blindt')
catalog.query("UPDATE economic_catalog_jobs SET status='pending',attempts=0,next_attempt_at=now() WHERE id=%s", (str(job_id),))
return {'queued': True}
@router.get('/economic/catalog/price-rules')
async def price_rules(user=Depends(require_permission('economic.pricing.manage'))):
return catalog.query('SELECT * FROM economic_price_rules ORDER BY priority DESC,id')
@router.post('/economic/catalog/price-rules')
async def add_rule(payload: RuleInput, user=Depends(require_permission('economic.pricing.manage'))):
if payload.kind == 'discount' and payload.value > 100 or payload.valid_from and payload.valid_to and payload.valid_to < payload.valid_from:
raise HTTPException(422, 'Ugyldig rabat eller gyldighedsperiode')
with catalog.transaction() as cur:
cur.execute('''INSERT INTO economic_price_rules(name,product_id,customer_id,category_id,kind,value,currency,priority,valid_from,valid_to,created_by)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''',
(payload.name,payload.product_id,payload.customer_id,payload.category_id,payload.kind,payload.value,payload.currency,payload.priority,payload.valid_from,payload.valid_to,user['id']))
created = cur.fetchone()
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('price_rule',%s,'created',%s,%s)", (str(created['id']), user['id'], Json(payload.model_dump(mode='json'))))
return created
@router.delete('/economic/catalog/price-rules/{rule_id}')
async def disable_rule(rule_id: int, user=Depends(require_permission('economic.pricing.manage'))):
with catalog.transaction() as cur:
cur.execute('UPDATE economic_price_rules SET active=false,version=version+1 WHERE id=%s RETURNING id', (rule_id,))
if not cur.fetchone():
raise HTTPException(404, 'Prisregel ikke fundet')
cur.execute("INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes) VALUES('price_rule',%s,'disabled',%s,'{}')", (str(rule_id), user['id']))
return {'disabled': True}
@router.post('/pricing/calculate')
async def price(payload: PriceInput, user=Depends(read)):
product = catalog.query('SELECT * FROM products WHERE id=%s AND deleted_at IS NULL', (payload.product_id,), one=True)
if not product:
raise HTTPException(404, 'Vare ikke fundet')
return catalog.calculate_price(product, catalog.query('SELECT * FROM economic_price_rules WHERE active'), payload.customer_id, payload.currency)
@router.post('/economic/catalog/jobs/{job_id}/reconcile')
async def reconcile_product(job_id: UUID, user=Depends(manage)):
from urllib.parse import quote
job = catalog.query('SELECT * FROM economic_catalog_jobs WHERE id=%s', (str(job_id),), one=True)
if not job or job['kind'] != 'create' or job['status'] != 'uncertain':
raise HTTPException(409, 'Kun uafklarede vareoprettelser kan afstemmes')
client = catalog.EconomicClient()
await client.verify(job['connection_id'])
expected = job['payload']
remote = await client.request('GET', 'products/' + quote(expected['productNumber'], safe=''))
if any(remote.get(k) != expected.get(k) for k in ('productNumber', 'name')) or (remote.get('productGroup') or {}).get('productGroupNumber') != expected['productGroup']['productGroupNumber'] or (remote.get('unit') or {}).get('unitNumber') != (expected.get('unit') or {}).get('unitNumber'):
raise HTTPException(409, 'Den eksterne vare afviger; ingen automatisk tilknytning')
product = catalog.query('SELECT economic_product_number FROM products WHERE id=%s', (job['product_id'],), one=True)
if product and product['economic_product_number'] != expected['productNumber']:
await catalog.link_product(job['product_id'], job['connection_id'], expected['productNumber'], user['id'])
catalog.query("UPDATE economic_catalog_jobs SET status='done',result=%s,error=NULL,updated_at=now() WHERE id=%s", (Json({'number': expected['productNumber'], 'reconciled_by': user['id']}), str(job_id)))
return {'reconciled': True}
@router.post('/economic/change-requests')
async def request_economic_change(
payload: EconomicChangeInput,
user=Depends(require_permission('economic.changes.request')),
):
"""Store a reviewed before/after proposal without changing e-conomic."""
changes = _validate_change_fields(payload.target_type, payload.changes)
client = catalog.EconomicClient()
await client.verify(payload.connection_id)
target_number = str(payload.target_number).strip()
path = _economic_target_path(payload.target_type, target_number)
remote = await client.request('GET', path)
before = _editable_snapshot(payload.target_type, remote)
proposed = dict(before)
proposed.update(changes)
if proposed == before:
raise HTTPException(409, 'Forslaget ændrer ingen værdier')
try:
with catalog.transaction() as cur:
cur.execute('''INSERT INTO economic_change_requests(
connection_id,target_type,target_number,requested_changes,before_snapshot,
proposed_snapshot,reason,requested_by_user_id)
VALUES(%s,%s,%s,%s,%s,%s,%s,%s) RETURNING *''',
(payload.connection_id, payload.target_type, target_number, Json(changes),
Json(before), Json(proposed), payload.reason.strip(), user['id']))
created = cur.fetchone()
cur.execute("""INSERT INTO economic_integration_audit(
object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'requested',%s,%s)""",
(str(created['id']), user['id'], Json({'target_type': payload.target_type,
'target_number': target_number, 'before': before, 'proposed': proposed,
'reason': payload.reason.strip()})))
return created
except Exception as exc:
if getattr(exc, 'pgcode', None) == '23505':
raise HTTPException(409, 'Der findes allerede en afventende ændring til dette objekt') from exc
raise
@router.post('/economic/change-requests/{request_id}/reject')
async def reject_economic_change(
request_id: UUID,
payload: EconomicReviewInput,
user=Depends(require_permission('economic.changes.approve')),
):
with catalog.transaction() as cur:
cur.execute("""UPDATE economic_change_requests SET status='rejected',
reviewed_by_user_id=%s,reviewed_at=now(),review_note=%s
WHERE id=%s AND status='pending' AND requested_by_user_id<>%s RETURNING *""",
(user['id'], payload.note, str(request_id), user['id']))
row = cur.fetchone()
if not row:
raise HTTPException(409, 'Anmodningen kan ikke afvises, eller du har selv oprettet den')
cur.execute("""INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'rejected',%s,%s)""",
(str(request_id), user['id'], Json({'note': payload.note})))
return row
@router.post('/economic/change-requests/{request_id}/approve')
async def approve_economic_change(
request_id: UUID,
payload: EconomicReviewInput,
user=Depends(require_permission('economic.changes.approve')),
):
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: ændringen kan ikke udføres i e-conomic')
with catalog.transaction() as cur:
cur.execute('SELECT * FROM economic_change_requests WHERE id=%s FOR UPDATE', (str(request_id),))
row = cur.fetchone()
if not row or row['status'] != 'pending':
raise HTTPException(409, 'Anmodningen afventer ikke længere godkendelse')
if row['requested_by_user_id'] == user['id']:
raise HTTPException(403, 'Du kan ikke godkende din egen ændring')
cur.execute("""UPDATE economic_change_requests SET status='applying',
reviewed_by_user_id=%s,reviewed_at=now(),review_note=%s WHERE id=%s""",
(user['id'], payload.note, str(request_id)))
client = catalog.EconomicClient()
path = _economic_target_path(row['target_type'], row['target_number'])
try:
await client.verify(row['connection_id'])
current = await client.request('GET', path)
current_editable = _editable_snapshot(row['target_type'], current)
if current_editable != row['before_snapshot']:
raise HTTPException(409, 'Data er ændret i e-conomic siden anmodningen. Opret et nyt forslag.')
outgoing = dict(current)
outgoing.update(row['requested_changes'])
with approved_four_eyes_write():
response = await client.request('PUT', path, outgoing)
except Exception as exc:
message = str(exc.detail if isinstance(exc, HTTPException) else exc)[:500]
catalog.query("""UPDATE economic_change_requests SET status='failed',error=%s
WHERE id=%s AND status='applying'""", (message, str(request_id)))
if isinstance(exc, HTTPException):
raise
raise
with catalog.transaction() as cur:
cur.execute("""UPDATE economic_change_requests SET status='approved',applied_at=now(),
response_snapshot=%s,error=NULL WHERE id=%s RETURNING *""",
(Json(_editable_snapshot(row['target_type'], response)), str(request_id)))
completed = cur.fetchone()
cur.execute("""INSERT INTO economic_integration_audit(object_type,object_id,action,user_id,changes)
VALUES('change_request',%s,'approved_and_applied',%s,%s)""",
(str(request_id), user['id'], Json({'review_note': payload.note,
'target_type': row['target_type'], 'target_number': row['target_number']})))
return completed
class DocumentInput(BaseModel):
document_key: str = Field(min_length=1, max_length=150)
customer_id: int
lines: list[dict]
layout_number: Optional[int] = Field(default=None, gt=0)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
notes: Optional[str] = None
@router.post('/economic/documents/{kind}/preview')
async def document_preview(kind: str, payload: DocumentInput, user=Depends(require_permission('economic.documents.export'))):
from app.products.backend import economic_documents as documents
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
connection = documents.active_connection()
if not connection:
raise HTTPException(409, 'Aktivér først den verificerede e-conomic-forbindelse')
return await documents.preflight(connection, payload.customer_id, payload.lines, payload.layout_number, payload.currency, payload.notes)
@router.post('/economic/documents/{kind}/export')
async def document_export(kind: str, payload: DocumentInput, user=Depends(require_permission('economic.documents.export'))):
from app.products.backend import economic_documents as documents
from app.products.backend.economic_pricing import validate_manual_prices
from app.core.auth_service import AuthService
saved = None
if payload.document_key.startswith('order-draft:') and payload.document_key.split(':')[-1].isdigit():
saved = catalog.query('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (int(payload.document_key.split(':')[-1]),), one=True)
saved_lines = saved['lines_json'] if saved and saved['customer_id'] == payload.customer_id else []
validate_manual_prices(payload.lines, payload.customer_id, user.get('is_superadmin') or AuthService.user_has_permission(user['id'], 'economic.pricing.override'), saved_lines)
if kind != 'order':
raise HTTPException(403, 'Hub må kun oprette ordrekladder i e-conomic')
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
raise HTTPException(409, 'Safety mode: brug preview; ingen ekstern skrivning')
connection = documents.active_connection()
if not connection:
raise HTTPException(409, 'Aktivér først den verificerede e-conomic-forbindelse')
return await documents.export_document(connection, kind, payload.document_key, payload.customer_id,
payload.lines, payload.layout_number, payload.currency, payload.notes, user['id'])
@router.post('/economic/documents/exports/{export_id}/reconcile')
async def reconcile_document(export_id: UUID, user=Depends(manage)):
from app.products.backend.economic_documents import reconcile
return await reconcile(str(export_id))
@router.post('/economic/documents/exports/{export_id}/retry')
async def retry_document(export_id: UUID, user=Depends(require_permission('economic.documents.export'))):
# Only definitively rejected attempts can release their local key. Retain their audit row.
result = catalog.query("UPDATE economic_document_exports SET document_key=document_key||':rejected:'||id::text,status='archived',updated_at=now() WHERE id=%s AND status='rejected' RETURNING id", (str(export_id),), one=True)
if not result:
raise HTTPException(409, 'Kun et definitivt afvist forsøg kan frigives. Uafklarede forsøg skal afstemmes.')
return {'retry_allowed': True}
class RepriceInput(BaseModel):
token: Optional[str] = None
@router.post('/economic/ordre-drafts/{draft_id}/reprice')
async def reprice(draft_id: int, payload: RepriceInput, user=Depends(require_permission('economic.pricing.override'))):
from app.products.backend.economic_pricing import reprice as calculate
return calculate(draft_id, payload.token, user['id'])

View File

@ -13,8 +13,7 @@ import json
import asyncio import asyncio
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from app.products.backend.economic_access import ProductAccessRoute router = APIRouter()
router = APIRouter(route_class=ProductAccessRoute)
def _apigw_headers() -> Dict[str, str]: def _apigw_headers() -> Dict[str, str]:
@ -79,25 +78,9 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_name = payload.get("supplier_name") supplier_name = payload.get("supplier_name")
supplier_code = payload.get("supplier_code") supplier_code = payload.get("supplier_code")
supplier_sku = payload.get("supplier_sku") or payload.get("sku") supplier_sku = payload.get("supplier_sku") or payload.get("sku")
supplier_price = payload.get("supplier_price") if payload.get("supplier_price") is not None else payload.get("price") supplier_price = payload.get("supplier_price") or payload.get("price")
supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK" supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
supplier_stock = payload.get("supplier_stock") if payload.get("supplier_stock") is not None else payload.get("stock_qty") supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
variant_key = str(payload.get('variant_key') or '')
license_term = str(payload.get('license_term') or '')
billing_interval = str(payload.get('billing_interval') or '')
market = str(payload.get('market') or '').upper()
valid_until = payload.get('valid_until') or None
if supplier_price is not None:
from app.products.backend.economic_catalog import decimal
supplier_price = decimal(supplier_price)
if supplier_price < 0:
raise HTTPException(422, 'Leverandørprisen må ikke være negativ')
if valid_until:
from datetime import date
try:
valid_until = date.fromisoformat(str(valid_until))
except ValueError:
raise HTTPException(422, 'Ugyldig gyldighedsdato')
supplier_url = payload.get("supplier_url") or payload.get("supplier_link") supplier_url = payload.get("supplier_url") or payload.get("supplier_link")
supplier_product_url = ( supplier_product_url = (
payload.get("supplier_product_url") payload.get("supplier_product_url")
@ -132,8 +115,6 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
existing = None existing = None
if match_query and match_params is not None: if match_query and match_params is not None:
match_query = match_query.replace('LIMIT 1', "AND COALESCE(variant_key,'')=%s AND COALESCE(license_term,'')=%s AND COALESCE(billing_interval,'')=%s AND COALESCE(market,'')=%s LIMIT 1")
match_params = (*match_params, variant_key, license_term, billing_interval, market)
existing = execute_query_single(match_query, match_params) existing = execute_query_single(match_query, match_params)
if existing: if existing:
@ -148,7 +129,6 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url = %s, supplier_url = %s,
supplier_product_url = %s, supplier_product_url = %s,
source = %s, source = %s,
variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s,
last_updated_at = CURRENT_TIMESTAMP last_updated_at = CURRENT_TIMESTAMP
WHERE id = %s WHERE id = %s
RETURNING * RETURNING *
@ -165,7 +145,6 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url, supplier_url,
supplier_product_url, supplier_product_url,
source, source,
variant_key, license_term, billing_interval, market, valid_until,
existing.get("id"), existing.get("id"),
) )
) )
@ -183,10 +162,8 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url, supplier_url,
supplier_product_url, supplier_product_url,
source, source,
variant_key, license_term, billing_interval, market, valid_until,
last_updated_at last_updated_at
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP) ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
ON CONFLICT DO NOTHING
RETURNING * RETURNING *
""" """
result = execute_query( result = execute_query(
@ -202,10 +179,9 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
supplier_url, supplier_url,
supplier_product_url, supplier_product_url,
source, source,
variant_key, license_term, billing_interval, market, valid_until,
) )
) )
return result[0] if result else (execute_query_single(match_query, match_params) if match_query else {}) return result[0] if result else {}
def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None: def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
@ -983,13 +959,11 @@ async def update_product(
): ):
"""Update product fields for core metadata and billing validation flags.""" """Update product fields for core metadata and billing validation flags."""
try: try:
if any(key in payload for key in ('economic_product_number', 'economic_connection_id')):
raise HTTPException(409, 'Den permanente e-conomic-kobling kan ikke ændres ved almindelig produktredigering')
name = payload.get("name") name = payload.get("name")
if name is not None: if name is not None:
name = name.strip() name = name.strip()
if not name or len(name) > 255: if not name:
raise HTTPException(status_code=400, detail="Navnet skal være 1255 tegn") raise HTTPException(status_code=400, detail="name cannot be empty")
serial_number_required = payload.get("serial_number_required") serial_number_required = payload.get("serial_number_required")
asset_required = payload.get("asset_required") asset_required = payload.get("asset_required")
@ -1006,8 +980,8 @@ async def update_product(
if not existing: if not existing:
raise HTTPException(status_code=404, detail="Product not found") raise HTTPException(status_code=404, detail="Product not found")
updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"] updates = ["updated_at = CURRENT_TIMESTAMP"]
values: List[Any] = [current_user.get("id")] values: List[Any] = []
if name is not None: if name is not None:
updates.append("name = %s") updates.append("name = %s")

View File

@ -82,7 +82,7 @@
<div class="badge-soft" id="productStatus">-</div> <div class="badge-soft" id="productStatus">-</div>
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div> <div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
<div class="product-muted" id="productSku">-</div> <div class="product-muted" id="productSku">-</div>
<div class="product-muted" id="productSupplierPrice" data-cost-section>-</div> <div class="product-muted" id="productSupplierPrice">-</div>
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div> <div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
</div> </div>
</div> </div>
@ -116,7 +116,7 @@
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button> <button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
<div class="small mt-3" id="priceUpdateMessage"></div> <div class="small mt-3" id="priceUpdateMessage"></div>
</div> </div>
<div class="product-card mt-3" data-cost-section> <div class="product-card mt-3">
<h5 class="mb-3">Opdater leverandør</h5> <h5 class="mb-3">Opdater leverandør</h5>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Leverandør</label> <label class="form-label">Leverandør</label>
@ -191,7 +191,7 @@
</table> </table>
</div> </div>
</div> </div>
<div class="product-card mt-3" data-cost-section> <div class="product-card mt-3">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2"> <div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
<h5 class="mb-0">Grosister og priser</h5> <h5 class="mb-0">Grosister og priser</h5>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
@ -258,13 +258,6 @@
<div class="col-md-3"> <div class="col-md-3">
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button> <button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
</div> </div>
<div class="col-12"><details><summary>Licensvariant og gyldighed (valgfrit)</summary><div class="row g-2 mt-1">
<div class="col-md-3"><label class="form-label">Variant-ID</label><input class="form-control" id="supplierVariant"></div>
<div class="col-md-2"><label class="form-label">Licensperiode</label><input class="form-control" id="supplierTerm" placeholder="P1Y / P1M"></div>
<div class="col-md-2"><label class="form-label">Betalingsinterval</label><input class="form-control" id="supplierInterval" placeholder="Monthly / Annual"></div>
<div class="col-md-2"><label class="form-label">Marked</label><input class="form-control" id="supplierMarket" placeholder="DK"></div>
<div class="col-md-3"><label class="form-label">Pris gyldig til</label><input class="form-control" id="supplierValidUntil" type="date"></div>
</div><p class="small text-muted mt-2">Forskellige varianter, perioder og markeder gemmes som separate leverandørtilbud.</p></details></div>
<div class="col-12"> <div class="col-12">
<div class="small" id="supplierListMessage"></div> <div class="small" id="supplierListMessage"></div>
</div> </div>
@ -396,10 +389,6 @@ async function loadProductDetail() {
const res = await fetch(`/api/v1/products/${productId}`); const res = await fetch(`/api/v1/products/${productId}`);
if (!res.ok) throw new Error('Kunne ikke hente produkt'); if (!res.ok) throw new Error('Kunne ikke hente produkt');
const product = await res.json(); const product = await res.json();
const permissions = product._permissions || {};
document.querySelectorAll('[data-cost-section]').forEach(el => el.hidden = permissions.cost_view === false);
document.querySelectorAll('[onclick="submitPriceUpdate()"]').forEach(el => el.hidden = permissions.pricing === false);
if (permissions.cost_view !== false) await loadSupplierList();
document.getElementById('productName').textContent = product.name || 'Produkt'; document.getElementById('productName').textContent = product.name || 'Produkt';
document.getElementById('productMeta').textContent = [ document.getElementById('productMeta').textContent = [
product.manufacturer, product.manufacturer,
@ -493,11 +482,11 @@ async function loadSupplierList() {
return ` return `
<tr> <tr>
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td> <td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
<td>${escapeHtml(entry.supplier_sku || '-')}<small class="d-block text-muted">${escapeHtml([entry.variant_key,entry.license_term,entry.billing_interval,entry.market].filter(Boolean).join(' · '))}</small></td> <td>${escapeHtml(entry.supplier_sku || '-')}</td>
<td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td> <td>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td> <td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td> <td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}${entry.valid_until ? `<small class="d-block">Gyldig til ${escapeHtml(entry.valid_until)}</small>` : ''}</td> <td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</td>
<td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td> <td><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
</tr> </tr>
`; `;
@ -536,11 +525,6 @@ async function submitSupplierList() {
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null, supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null, supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : null,
supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null, supplier_product_url: document.getElementById('supplierListUrl').value.trim() || null,
variant_key: document.getElementById('supplierVariant').value.trim(),
license_term: document.getElementById('supplierTerm').value.trim(),
billing_interval: document.getElementById('supplierInterval').value.trim(),
market: document.getElementById('supplierMarket').value.trim(),
valid_until: document.getElementById('supplierValidUntil').value || null,
source: 'manual' source: 'manual'
}; };
@ -724,6 +708,7 @@ document.addEventListener('DOMContentLoaded', () => {
loadProductDetail(); loadProductDetail();
loadPriceHistory(); loadPriceHistory();
loadSalesHistory(); loadSalesHistory();
loadSupplierList();
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,41 +0,0 @@
{% extends 'shared/frontend/base.html' %}
{% block title %}Varer og e-conomic BMC Hub{% endblock %}
{% block content %}
<main id="economicCatalog" class="container-fluid py-4">
<div class="d-flex justify-content-between"><h2>Varer og e-conomic</h2><a href="/products" class="btn btn-outline-primary">Produktkatalog</a></div>
<p>Importér varer, knyt Hub-produkter til e-conomic og følg synkroniseringen.</p>
<div id="ecoFeedback" class="alert d-none" role="status"></div>
<div class="card mb-3"><div class="card-body">
<div class="d-flex flex-wrap gap-2 align-items-center">
<select id="ecoConnection" class="form-select w-auto" aria-label="e-conomic-forbindelse"></select>
<button class="btn btn-outline-primary" data-action="connect">Tilknyt aftale</button>
<button class="btn btn-primary" data-action="preview">Kontrollér import</button>
<button class="btn btn-success" data-action="import">Importér alle varer</button>
<button class="btn btn-outline-secondary" data-action="config">Automatisk sync</button>
<span id="ecoMode"></span>
</div>
</div></div>
<nav class="nav nav-tabs mb-3"><button class="nav-link active" data-tab="products">Varer</button><button class="nav-link" data-tab="groups">Varegrupper</button><button class="nav-link" data-tab="rules">Prisregler</button><button class="nav-link" data-tab="changes">4-øjne-godkendelser</button><button class="nav-link" data-tab="jobs">Sync og fejl</button></nav>
<section id="ecoProducts">
<div class="d-flex gap-2 mb-3"><input id="ecoSearch" class="form-control" placeholder="Søg navn, SKU eller varenummer"><button class="btn btn-primary" data-action="search">Søg</button></div>
<label class="mb-3"><input id="ecoInactive" type="checkbox"> Vis også inaktive, spærrede og udfasede varer</label>
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Hub-ID</th><th>Varenummer</th><th>Navn</th><th>Hub-pris</th><th>Referencepris</th><th>Status</th><th>Handling</th></tr></thead><tbody id="ecoProductRows"></tbody></table></div>
<button class="btn btn-outline-secondary" data-action="previous">Forrige</button> <button class="btn btn-outline-secondary" data-action="next">Næste 100</button>
</section>
<section id="ecoGroups" hidden><table class="table table-sm"><thead><tr><th>Nummer</th><th>Navn</th><th>Hub-kategori</th><th>Nye varer</th><th>Handling</th></tr></thead><tbody id="ecoGroupRows"></tbody></table></section>
<section id="ecoRules" hidden><button class="btn btn-primary mb-3" data-action="rule">Ny prisregel</button><table class="table table-sm"><thead><tr><th>Navn</th><th>Type</th><th>Værdi</th><th>Prioritet</th><th>Handling</th></tr></thead><tbody id="ecoRuleRows"></tbody></table></section>
<section id="ecoChanges" hidden>
<div class="d-flex justify-content-between align-items-center mb-3"><div><h3>Ændringer i e-conomic</h3><p class="mb-0">Opretteren kan aldrig godkende sin egen ændring.</p></div><button class="btn btn-warning" data-action="change">Foreslå ændring</button></div>
<div class="table-responsive"><table class="table table-sm align-middle"><thead><tr><th>Tid</th><th>Objekt</th><th>Oprettet af</th><th>Før → efter</th><th>Begrundelse</th><th>Status</th><th>Handling</th></tr></thead><tbody id="ecoChangeRows"></tbody></table></div>
</section>
<section id="ecoJobs" hidden><p>Opdateres automatisk. Uafklarede oprettelser kræver kontrol før et nyt forsøg.</p><table class="table table-sm"><thead><tr><th>Tid</th><th>Type / vare</th><th>Status</th><th>Resultat / fejl</th><th>Handling</th></tr></thead><tbody id="ecoJobRows"></tbody></table>
<h3>Ordreeksport</h3><button class="btn btn-primary mb-3" data-action="document">Eksportér gemt ordre som kladde</button>
<p>Hub opretter kun en ordrekladde. Der bliver ikke bogført eller sendt til kunden.</p>
<table class="table table-sm"><thead><tr><th>Tid</th><th>Type</th><th>Status</th><th>Nummer / fejl</th><th>Handling</th></tr></thead><tbody id="ecoExportRows"></tbody></table>
</section>
<dialog id="ecoDialog" class="border rounded p-4" style="max-width:650px;width:95%">
<form id="ecoForm"><h3 id="ecoDialogTitle"></h3><div id="ecoFields"></div><p id="ecoFormError" class="text-danger" role="alert"></p><div class="d-flex gap-2 mt-3"><button type="submit" class="btn btn-primary">Gem</button><button type="button" class="btn btn-secondary" id="ecoCancel">Annullér</button></div></form>
</dialog>
</main>
<script src="/static/js/economic-catalog.js"></script>
{% endblock %}

View File

@ -324,7 +324,6 @@
<p>Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.</p> <p>Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.</p>
</div> </div>
<div class="products-actions"> <div class="products-actions">
<a class="btn btn-outline-primary" href="/settings/economic-products">Varer og e-conomic</a>
<button class="btn btn-primary" onclick="openCreateProductModal()"> <button class="btn btn-primary" onclick="openCreateProductModal()">
<i class="bi bi-plus-circle"></i> Opret Produkt <i class="bi bi-plus-circle"></i> Opret Produkt
</button> </button>

View File

@ -287,8 +287,9 @@ class EconomicService:
Returns: Returns:
True if successful, False otherwise True if successful, False otherwise
""" """
# Customer creation is allowed, updates are deliberately not. if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
logger.warning('e-conomic customer update blocked by the Hub write policy') logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
logger.info(f"Would update customer {customer_number} with: {update_data}")
return False return False
try: try:
@ -378,7 +379,7 @@ class EconomicService:
Returns: Returns:
Created supplier data with supplierNumber or None if failed Created supplier data with supplierNumber or None if failed
""" """
logger.warning('e-conomic supplier creation blocked by the Hub write policy') if not self._check_write_permission("create_supplier"):
return None return None
try: try:
@ -823,8 +824,8 @@ class EconomicService:
Dict with voucher details or error info Dict with voucher details or error info
""" """
# 🚨 SAFETY CHECK # 🚨 SAFETY CHECK
logger.warning('e-conomic voucher posting blocked by the Hub write policy') if not self._check_write_permission("create_journal_supplier_invoice"):
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"} return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
try: try:
# Extract year from invoice date for accounting year # Extract year from invoice date for accounting year
@ -1051,8 +1052,8 @@ class EconomicService:
Dict with success status Dict with success status
""" """
# 🚨 SAFETY CHECK # 🚨 SAFETY CHECK
logger.warning('e-conomic voucher attachment blocked by the Hub write policy') if not self._check_write_permission("upload_voucher_attachment"):
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"} return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
try: try:
# Read PDF file # Read PDF file

View File

@ -136,23 +136,6 @@ class EmailProcessorService:
} }
try: try:
# Do not let a Graph metadata-only attachment turn into a completed
# invoice workflow. This also protects historic rows imported before
# attachment byte retrieval was made reliable.
attachment_state = execute_query(
"""SELECT has_attachments,
EXISTS(SELECT 1 FROM email_attachments WHERE email_id = %s) AS has_saved_attachment
FROM email_messages WHERE id = %s""",
(email_id, email_id),
) if email_id else []
if attachment_state:
state = attachment_state[0]
if state.get('has_attachments') and not state.get('has_saved_attachment'):
await self._set_awaiting_user_action(email_id, reason='missing_attachment_content')
stats['awaiting_user_action'] = True
logger.warning("🛑 Email %s was not processed because its attachment is missing", email_id)
return stats
# Step 2.5: Detect and transcribe audio attachments # Step 2.5: Detect and transcribe audio attachments
# This is done BEFORE classification so the AI can "read" the voice note # This is done BEFORE classification so the AI can "read" the voice note
if settings.WHISPER_ENABLED: if settings.WHISPER_ENABLED:

View File

@ -34,7 +34,6 @@ from aiohttp import ClientSession, BasicAuth
import msal import msal
from app.core.config import settings from app.core.config import settings
from app.services.system_event_logger import log_system_event
from app.core.database import execute_query, execute_insert, execute_update from app.core.database import execute_query, execute_insert, execute_update
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -448,11 +447,6 @@ class EmailService:
) )
parsed_email['attachments'] = attachments parsed_email['attachments'] = attachments
parsed_email['attachment_count'] = len(attachments) parsed_email['attachment_count'] = len(attachments)
# `hasAttachments` is Graph metadata, not a guarantee that
# contentBytes was included in the list response. Keep this
# fact so the processor can never silently mark an invoice as
# completed without its actual file.
parsed_email['attachment_fetch_failed'] = not bool(attachments)
else: else:
parsed_email['attachments'] = [] parsed_email['attachments'] = []
@ -897,24 +891,7 @@ class EmailService:
import base64 import base64
content = base64.b64decode(content_bytes) content = base64.b64decode(content_bytes)
else: else:
# Graph may omit contentBytes for larger fileAttachment
# objects. Fetch the attachment stream explicitly instead
# of creating an empty, unusable attachment record.
attachment_id = att.get('id')
content = b'' content = b''
if attachment_id:
value_url = (
f"https://graph.microsoft.com/v1.0/users/{user_email}"
f"/messages/{message_id}/attachments/{attachment_id}/$value"
)
async with session.get(value_url, headers=headers) as value_response:
if value_response.status == 200:
content = await value_response.read()
else:
logger.warning(
"⚠️ Failed to download attachment bytes for %s/%s: %s",
message_id, attachment_id, value_response.status,
)
# Handle missing filenames for audio (FALLBACK) # Handle missing filenames for audio (FALLBACK)
filename = att.get('name') filename = att.get('name')
@ -928,10 +905,6 @@ class EmailService:
filename = f"audio_attachment{ext}" filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}") logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
if not content:
logger.warning("⚠️ Skipping empty Graph attachment %s", filename or attachment_id)
continue
attachments.append({ attachments.append({
'filename': filename or 'unknown', 'filename': filename or 'unknown',
'content': content, 'content': content,
@ -946,90 +919,6 @@ class EmailService:
return attachments return attachments
async def recover_graph_attachments(self, email_id: int) -> Dict[str, Any]:
"""Recover missing attachments for an already imported Graph email.
We store the internet message-id rather than Graph's opaque message id,
so first resolve it through Graph and then download the real bytes.
This is deliberately limited to a single known email; it never creates a
new email record or changes customer allocations.
"""
row = execute_query(
"""SELECT id, message_id, subject, has_attachments
FROM email_messages WHERE id = %s AND deleted_at IS NULL""",
(email_id,),
)
if not row:
return {"success": False, "reason": "Email not found"}
email_row = row[0]
if not email_row.get("has_attachments"):
return {"success": False, "reason": "Email has no declared attachments"}
if not self.use_graph or not self._graph_send_available():
return {"success": False, "reason": "Microsoft Graph is not configured"}
access_token = await self._get_graph_access_token()
if not access_token:
return {"success": False, "reason": "Could not authenticate to Microsoft Graph"}
user_email = self.graph_config["user_email"]
message_id = str(email_row.get("message_id") or "")
if not message_id:
return {"success": False, "reason": "Email has no message id"}
headers = {"Authorization": f"Bearer {access_token}"}
try:
async with ClientSession() as session:
url = f"https://graph.microsoft.com/v1.0/users/{user_email}/messages"
clean_message_id = message_id.strip().strip('<>')
message_id_candidates = list(dict.fromkeys((message_id.strip(), f"<{clean_message_id}>")))
matches = []
for candidate in message_id_candidates:
params = {
"$filter": "internetMessageId eq '{}'".format(candidate.replace("'", "''")),
"$select": "id,subject,hasAttachments,internetMessageId",
"$top": 2,
}
async with session.get(url, params=params, headers=headers) as response:
if response.status != 200:
detail = await response.text()
logger.warning("⚠️ Could not resolve Graph email %s using %s: %s %s", email_id, candidate, response.status, detail)
continue
matches = (await response.json()).get("value", [])
if matches:
break
if not matches:
return {"success": False, "reason": "Email is no longer available in Microsoft Graph"}
graph_message = matches[0]
attachments = await self._fetch_graph_attachments(
user_email, graph_message["id"], access_token, session
)
if not attachments:
return {"success": False, "reason": "Graph returned no downloadable attachments"}
await self._save_attachments(email_id, attachments)
saved = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
)
saved_count = int((saved[0] if saved else {}).get("count") or 0)
if not saved_count:
return {"success": False, "reason": "Attachment could not be saved"}
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
return {"success": True, "attachments_recovered": len(attachments), "attachments_saved": saved_count}
except Exception as exc:
logger.exception("❌ Failed recovering Graph attachments for email %s", email_id)
return {"success": False, "reason": str(exc)}
def _decode_header(self, header: str) -> str: def _decode_header(self, header: str) -> str:
"""Decode email header (handles MIME encoding)""" """Decode email header (handles MIME encoding)"""
if not header: if not header:
@ -1278,20 +1167,6 @@ class EmailService:
if email_data.get('attachments'): if email_data.get('attachments'):
await self._save_attachments(email_id, email_data['attachments']) await self._save_attachments(email_id, email_data['attachments'])
# A mail that Graph says has attachments, but where no file could be
# retrieved, must be visible for review and must not enter automatic
# invoice processing as a successful/empty mail.
if email_data.get('has_attachments') and not email_data.get('attachments'):
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
email_data['attachment_fetch_failed'] = True
logger.warning("⚠️ Email %s has declared attachments but none were saved", email_id)
return email_id return email_id
except Exception as e: except Exception as e:
@ -1602,47 +1477,9 @@ class EmailService:
existing = execute_query(check_query, (email_data["message_id"],)) existing = execute_query(check_query, (email_data["message_id"],))
if existing: if existing:
# A user may upload the original .eml again specifically to
# restore an attachment that was missing in an old Graph import.
# Treat that as a repair, not as a dead duplicate.
email_id = int(existing[0]["id"])
incoming_attachments = email_data.get("attachments") or []
if not incoming_attachments:
logger.info(f"⏭️ Email already exists: {email_data['message_id']}") logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
return None return None
saved_rows = execute_query(
"SELECT filename FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_names = {str(row.get("filename") or "") for row in saved_rows}
missing_attachments = [
item for item in incoming_attachments
if str(item.get("filename") or "") not in saved_names
]
if missing_attachments:
await self._save_attachments(email_id, missing_attachments)
count_row = execute_query(
"SELECT COUNT(*) AS count FROM email_attachments WHERE email_id = %s",
(email_id,),
) or []
saved_count = int((count_row[0] if count_row else {}).get("count") or 0)
if not saved_count:
logger.warning("⚠️ Existing upload %s still has no saved attachments", email_id)
return None
execute_update(
"""UPDATE email_messages
SET has_attachments = true, attachment_count = %s,
status = 'new', auto_processed = false, processed_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(saved_count, email_id),
)
logger.info("✅ Restored %s attachment(s) on existing email %s", len(missing_attachments), email_id)
return email_id
# Insert email # Insert email
thread_key = self._derive_thread_key(email_data) thread_key = self._derive_thread_key(email_data)
try: try:
@ -1772,11 +1609,6 @@ class EmailService:
) )
if graph_ok: if graph_ok:
logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject) logger.info("✅ Email sent via Graph to %s recipient(s): %s", len(to_addresses), subject)
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt",
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
)
return True, graph_message return True, graph_message
graph_failure_message = graph_message graph_failure_message = graph_message
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message) logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
@ -1837,21 +1669,11 @@ class EmailService:
) )
logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}") logger.info(f"✅ Email sent successfully to {len(to_addresses)} recipient(s): {subject}")
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt",
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or [])},
)
return True, f"Email sent to {len(to_addresses)} recipient(s)" return True, f"Email sent to {len(to_addresses)} recipient(s)"
except Exception as e: except Exception as e:
error_msg = f"❌ SMTP send error: {str(e)}" error_msg = f"❌ SMTP send error: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
log_system_event(
source="email", event_type="email.send_failed", status="error",
summary=subject or "E-mail kunne ikke sendes",
details={"provider": "smtp", "to": to_addresses, "error": str(e)},
)
if graph_failure_message: if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}" return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
return False, error_msg return False, error_msg
@ -1913,11 +1735,6 @@ class EmailService:
subject, subject,
provider_thread_key, provider_thread_key,
) )
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
details={"provider": "graph", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
)
return True, graph_message, generated_message_id, provider_thread_key return True, graph_message, generated_message_id, provider_thread_key
graph_failure_message = graph_message graph_failure_message = graph_message
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message) logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
@ -1997,21 +1814,11 @@ class EmailService:
len(to_addresses), len(to_addresses),
subject, subject,
) )
log_system_event(
source="email", event_type="email.sent", status="success",
summary=subject or "E-mail sendt", correlation_id=generated_message_id,
details={"provider": "smtp", "to": to_addresses, "cc": cc or [], "bcc_count": len(bcc or []), "attachments": len(attachments or [])},
)
return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key return True, f"Email sent to {len(to_addresses)} recipient(s)", generated_message_id, provider_thread_key
except Exception as e: except Exception as e:
error_msg = f"❌ SMTP send error (attachments): {str(e)}" error_msg = f"❌ SMTP send error (attachments): {str(e)}"
logger.error(error_msg) logger.error(error_msg)
log_system_event(
source="email", event_type="email.send_failed", status="error",
summary=subject or "E-mail kunne ikke sendes", correlation_id=generated_message_id,
details={"provider": "smtp", "to": to_addresses, "attachments": len(attachments or []), "error": str(e)},
)
if graph_failure_message: if graph_failure_message:
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}", generated_message_id, provider_thread_key
return False, error_msg, generated_message_id, provider_thread_key return False, error_msg, generated_message_id, provider_thread_key

View File

@ -1370,7 +1370,6 @@ class EmailWorkflowService:
steps = workflow['workflow_steps'] steps = workflow['workflow_steps']
steps_completed = 0 steps_completed = 0
step_results = [] step_results = []
has_failed_step = False
try: try:
# Execute each step # Execute each step
@ -1378,18 +1377,6 @@ class EmailWorkflowService:
action = step.get('action') action = step.get('action')
params = step.get('params', {}) params = step.get('params', {})
# A final "mark as processed" must never conceal a failed
# invoice extraction (or any other earlier workflow failure).
if action == 'mark_as_processed' and has_failed_step:
step_results.append({
'step': idx + 1,
'action': action,
'status': 'skipped',
'result': None,
'error': 'Skipped because an earlier workflow step failed',
})
continue
logger.info(f" ➡️ Step {idx + 1}/{len(steps)}: {action}") logger.info(f" ➡️ Step {idx + 1}/{len(steps)}: {action}")
step_result = await self._execute_action(action, params, email_data) step_result = await self._execute_action(action, params, email_data)
@ -1402,7 +1389,6 @@ class EmailWorkflowService:
}) })
if step_result['status'] == 'failed': if step_result['status'] == 'failed':
has_failed_step = True
logger.error(f" ❌ Step failed: {step_result.get('error')}") logger.error(f" ❌ Step failed: {step_result.get('error')}")
# Continue to next step even on failure (configurable later) # Continue to next step even on failure (configurable later)
else: else:
@ -1410,49 +1396,37 @@ class EmailWorkflowService:
steps_completed += 1 steps_completed += 1
# Preserve a real failure in the execution record instead of # Mark execution as completed
# reporting a green workflow with red individual steps.
completed_at = datetime.now() completed_at = datetime.now()
execution_time_ms = int((completed_at - started_at).total_seconds() * 1000) execution_time_ms = int((completed_at - started_at).total_seconds() * 1000)
execution_status = 'failed' if has_failed_step else 'completed'
execute_update( execute_update(
"""UPDATE email_workflow_executions """UPDATE email_workflow_executions
SET status = %s, steps_completed = %s, SET status = 'completed', steps_completed = %s,
result_json = %s, completed_at = CURRENT_TIMESTAMP, result_json = %s, completed_at = CURRENT_TIMESTAMP,
execution_time_ms = %s execution_time_ms = %s
WHERE id = %s""", WHERE id = %s""",
(execution_status, steps_completed, json.dumps(step_results), execution_time_ms, execution_id) (steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
) )
# Update workflow statistics # Update workflow statistics
execute_update( execute_update(
"""UPDATE email_workflows """UPDATE email_workflows
SET execution_count = execution_count + 1, SET execution_count = execution_count + 1,
success_count = success_count + %s, success_count = success_count + 1,
failure_count = failure_count + %s,
last_executed_at = CURRENT_TIMESTAMP last_executed_at = CURRENT_TIMESTAMP
WHERE id = %s""", WHERE id = %s""",
(0 if has_failed_step else 1, 1 if has_failed_step else 0, workflow_id) (workflow_id,)
) )
if has_failed_step: logger.info(f"✅ Workflow '{workflow_name}' completed ({execution_time_ms}ms)")
execute_update(
"""UPDATE email_messages
SET status = 'awaiting_user_action', auto_processed = false,
processed_at = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(email_id,),
)
logger.info("%s Workflow '%s' %s (%sms)", "" if not has_failed_step else "⚠️", workflow_name, execution_status, execution_time_ms)
# Log: Workflow execution completed # Log: Workflow execution completed
await email_activity_logger.log_workflow_executed( await email_activity_logger.log_workflow_executed(
email_id=email_id, email_id=email_id,
workflow_id=workflow_id, workflow_id=workflow_id,
workflow_name=workflow_name, workflow_name=workflow_name,
status=execution_status, status='completed',
steps_completed=steps_completed, steps_completed=steps_completed,
execution_time_ms=execution_time_ms execution_time_ms=execution_time_ms
) )
@ -1461,7 +1435,7 @@ class EmailWorkflowService:
'workflow_id': workflow_id, 'workflow_id': workflow_id,
'workflow_name': workflow_name, 'workflow_name': workflow_name,
'execution_id': execution_id, 'execution_id': execution_id,
'status': execution_status, 'status': 'completed',
'steps_completed': steps_completed, 'steps_completed': steps_completed,
'steps_total': len(steps), 'steps_total': len(steps),
'execution_time_ms': execution_time_ms, 'execution_time_ms': execution_time_ms,
@ -1541,12 +1515,6 @@ class EmailWorkflowService:
} }
result = await handler(params, email_data) result = await handler(params, email_data)
if isinstance(result, dict) and result.get('success') is False:
return {
'status': 'failed',
'result': result,
'error': result.get('note') or result.get('reason') or f"Action {action} did not complete",
}
return { return {
'status': 'success', 'status': 'success',
'result': result 'result': result
@ -1849,9 +1817,7 @@ class EmailWorkflowService:
attachments = execute_query( attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type """SELECT filename, file_path, size_bytes, content_type
FROM email_attachments FROM email_attachments
WHERE email_id = %s WHERE email_id = %s AND content_type = 'application/pdf'""",
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,) (email_id,)
) )
@ -1860,29 +1826,6 @@ class EmailWorkflowService:
elif not isinstance(attachments, list): elif not isinstance(attachments, list):
attachments = [attachments] attachments = [attachments]
# Older/manual email imports may declare attachments without having saved
# their bytes. Recover the attachment before declaring the workflow failed.
if not attachments:
email_state = execute_query(
"SELECT has_attachments FROM email_messages WHERE id = %s",
(email_id,),
)
if email_state and email_state[0].get("has_attachments"):
try:
from app.services.email_service import EmailService
recovery = await EmailService().recover_graph_attachments(int(email_id))
if recovery.get("success"):
attachments = execute_query(
"""SELECT filename, file_path, size_bytes, content_type
FROM email_attachments
WHERE email_id = %s
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
OR LOWER(filename) LIKE '%%.pdf')""",
(email_id,),
) or []
except Exception as recovery_error:
logger.warning("Could not recover attachments for email %s: %s", email_id, recovery_error)
if not attachments: if not attachments:
logger.warning(f"⚠️ No PDF attachments found for email {email_id}") logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
return { return {
@ -1927,17 +1870,11 @@ class EmailWorkflowService:
# Check if file already exists # Check if file already exists
existing = execute_query( existing = execute_query(
"SELECT file_id, status FROM incoming_files WHERE checksum = %s", "SELECT file_id FROM incoming_files WHERE checksum = %s",
(checksum,)) (checksum,))
if existing: if existing:
logger.info(f"⚠️ File already exists: {attachment['filename']}") logger.info(f"⚠️ File already exists: {attachment['filename']}")
uploaded_files.append({
'file_id': existing[0]['file_id'],
'filename': attachment['filename'],
'existing': True,
'status': existing[0].get('status'),
})
continue continue
# Create uploads directory if it doesn't exist # Create uploads directory if it doesn't exist
@ -1982,43 +1919,12 @@ class EmailWorkflowService:
continue continue
if uploaded_files: if uploaded_files:
processing_results = []
for uploaded in uploaded_files:
if uploaded.get('existing') and uploaded.get('status') in {'ai_extracted', 'processed'}:
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'already_processed',
})
continue
try:
# Lazy import avoids coupling router import order to email startup.
from app.billing.backend.supplier_invoices import reprocess_uploaded_file
processing_results.append(await reprocess_uploaded_file(int(uploaded['file_id'])))
except Exception as processing_error:
logger.exception("Immediate invoice processing failed for file %s", uploaded['file_id'])
processing_results.append({
'file_id': uploaded['file_id'],
'status': 'failed',
'error': str(getattr(processing_error, 'detail', processing_error)),
})
failed_processing = [item for item in processing_results if item.get('status') == 'failed']
if failed_processing:
return {
'action': 'extract_invoice_data',
'success': False,
'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files],
'processing_results': processing_results,
'note': failed_processing[0].get('error') or 'Fakturaudtrækning fejlede',
}
return { return {
'action': 'extract_invoice_data', 'action': 'extract_invoice_data',
'success': True, 'success': True,
'files_uploaded': len(uploaded_files), 'files_uploaded': len(uploaded_files),
'file_ids': [f['file_id'] for f in uploaded_files], 'file_ids': [f['file_id'] for f in uploaded_files],
'processing_results': processing_results, 'note': f"{len(uploaded_files)} PDF(er) gemt i 'Mangler Behandling'"
'note': f"{len(uploaded_files)} PDF(er) gemt og behandlet"
} }
else: else:
return { return {

View File

@ -413,7 +413,6 @@ REGLER FOR title:
"model": self.model, "model": self.model,
"prompt": prompt, "prompt": prompt,
"stream": False, "stream": False,
"format": "json",
"options": { "options": {
"temperature": 0.1, "temperature": 0.1,
"top_p": 0.9, "top_p": 0.9,
@ -460,18 +459,6 @@ REGLER FOR title:
# Parse JSON from response # Parse JSON from response
extraction = self._parse_json_response(raw_response) extraction = self._parse_json_response(raw_response)
# Models do not always respect the numeric JSON schema and may
# return Danish/English amount strings. Normalize them before
# applying sign rules so comparisons with zero are type-safe.
for amount_key in ('total_amount', 'vat_amount'):
extraction[amount_key] = self._normalize_amount_value(extraction.get(amount_key))
if isinstance(extraction.get('lines'), list):
for line in extraction['lines']:
if not isinstance(line, dict):
continue
for amount_key in ('unit_price', 'line_total', 'vat_amount'):
line[amount_key] = self._normalize_amount_value(line.get(amount_key))
# CRITICAL: Fix amount signs based on document_type # CRITICAL: Fix amount signs based on document_type
# LLM sometimes returns negative amounts for invoices - fix this! # LLM sometimes returns negative amounts for invoices - fix this!
document_type = extraction.get('document_type', 'invoice') document_type = extraction.get('document_type', 'invoice')
@ -540,39 +527,6 @@ REGLER FOR title:
"confidence": 0.0 "confidence": 0.0
} }
@staticmethod
def _normalize_amount_value(value):
"""Convert common Danish/English amount representations to numbers."""
if value is None or value == '':
return None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return value
if not isinstance(value, str):
return None
cleaned = re.sub(r'[^0-9,.-]', '', value.strip()).strip('.,')
if not cleaned or cleaned in {'-', '.', ',', '-.', '-,'}:
return None
if ',' in cleaned and '.' in cleaned:
if cleaned.rfind(',') > cleaned.rfind('.'):
cleaned = cleaned.replace('.', '').replace(',', '.')
else:
cleaned = cleaned.replace(',', '')
elif ',' in cleaned:
cleaned = cleaned.replace('.', '').replace(',', '.')
elif cleaned.count('.') > 1:
cleaned = cleaned.replace('.', '')
elif '.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3:
cleaned = cleaned.replace('.', '')
try:
return float(cleaned)
except ValueError:
return None
def _parse_json_response(self, response: str) -> Dict: def _parse_json_response(self, response: str) -> Dict:
"""Parse JSON from LLM response with aggressive fallback strategies""" """Parse JSON from LLM response with aggressive fallback strategies"""
logger.info(f"🔍 Response length: {len(response)}, preview: {response[:200]}") logger.info(f"🔍 Response length: {len(response)}, preview: {response[:200]}")

View File

@ -112,22 +112,7 @@ def billing_date_for_period(
target = period_start - relativedelta(months=max(0, int(lead_months or 0))) target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
if schedule_type == "interval_anchor": if schedule_type == "interval_anchor":
return target return target
resolved = resolve_month_date(target.year, target.month, schedule_type, billing_day) return resolve_month_date(target.year, target.month, schedule_type, billing_day)
# With no billing lead, an arbitrary period start (for example 31 August)
# must never yield an invoice date that has already passed (1 August).
# In that situation the first valid scheduled invoice date is in the next
# calendar month. A positive lead intentionally permits a date before the
# coverage period and is left unchanged.
if int(lead_months or 0) == 0 and resolved < period_start:
following_month = target + relativedelta(months=1)
resolved = resolve_month_date(
following_month.year,
following_month.month,
schedule_type,
billing_day,
)
return resolved
def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date: def advance_billing_periods(value: date, interval: str, periods: int = 1) -> date:

View File

@ -1,22 +0,0 @@
import json
import logging
from datetime import datetime
from typing import Any
from app.core.database import execute_query
logger = logging.getLogger(__name__)
def log_system_event(*, source: str, event_type: str, summary: str, status: str = "info",
correlation_id: str | None = None, user_id: int | None = None,
details: dict[str, Any] | None = None, occurred_at: datetime | None = None) -> None:
"""Best-effort operational event logging; logging must never break the source flow."""
try:
execute_query(
"""INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
VALUES(%s,%s,%s,%s,%s,%s,%s::jsonb,COALESCE(%s,NOW()))""",
(source,event_type,status,summary,correlation_id,user_id,json.dumps(details or {},default=str),occurred_at),
)
except Exception as exc:
logger.warning("Could not persist system event %s: %s", event_type, exc)

View File

@ -7,7 +7,6 @@ import json
import re import re
import html as html_lib import html as html_lib
import aiohttp import aiohttp
import base64
from decimal import Decimal from decimal import Decimal
from typing import List, Dict, Optional, Any from typing import List, Dict, Optional, Any
from app.core.config import settings from app.core.config import settings
@ -166,40 +165,6 @@ class VTigerService:
logger.error(f"❌ vTiger query error: {e}") logger.error(f"❌ vTiger query error: {e}")
return [] return []
async def retrieve_file(self, resource_id: str) -> Optional[Dict[str, Any]]:
"""Retrieve the original bytes for a vTiger file resource."""
safe_id = self._sanitize_vtiger_id(resource_id)
if not safe_id or not self.rest_endpoint:
raise ValueError("Ugyldigt vTiger fil-id eller manglende VTIGER_URL")
self.last_query_status = None
self.last_query_error = None
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.rest_endpoint}/files_retrieve",
params={"id": safe_id},
auth=self._get_auth(),
) as response:
self.last_query_status = response.status
data = await response.json(content_type=None)
if response.status != 200 or not data.get("success"):
self.last_query_error = data.get("error") or {"message": f"HTTP {response.status}"}
return None
result = data.get("result") or {}
if isinstance(result, list):
result = result[0] if result else {}
encoded = result.get("filecontents") or ""
try:
content = base64.b64decode(encoded, validate=True)
except Exception as exc:
self.last_query_error = {"message": "Ugyldigt base64-filindhold"}
raise ValueError("vTiger returnerede ugyldigt filindhold") from exc
return {**result, "content": content}
except Exception as exc:
if not self.last_query_error:
self.last_query_error = {"message": str(exc)}
return None
async def get_account_by_id(self, account_id: str) -> Optional[Dict]: async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
""" """
Fetch a single account by ID from vTiger Fetch a single account by ID from vTiger

View File

@ -1,278 +0,0 @@
"""Persistent Ollama model benchmark used by the Settings UI."""
from __future__ import annotations
import json
import logging
import re
import time
from typing import Any, Callable
import httpx
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
Check = tuple[str, Callable[[dict], bool]]
def _digits(value: Any) -> str:
return re.sub(r"\D", "", str(value or ""))
def _text(result: dict, key: str) -> str:
return str(result.get(key) or "")
BENCHMARK_TESTS: list[dict] = [
{
"key": "contact_signature", "name": "Mailsignatur → kontakt", "category": "CRM",
"description": "Finder navn, korrekt titel, firma, mobil og e-mail uden at bruge hilsenen som titel.",
"expected": {"name": "Ida Gundersen", "title": "Technical Advisor & Co-owner", "company": "Createx", "mobile": "+45 42 25 59 08", "email": "ida@createx-onstage.com"},
"prompt": """Udtræk kontaktdata fra mailen som JSON med præcis nøglerne name, title, company, mobile, email. Brug null hvis feltet mangler. 'Kind regards' er en hilsen og ikke en titel.\n\nKind regards\nIda Gundersen\nTechnical Advisor & Co-owner\nMobile: +45 42 25 59 08 DK: +45 55 86 05 00\nEmail: ida@createx-onstage.com\nWeb: createx-onstage.com\nCreatex\nStoregade 4C | 4780 Stege, DK""",
"checks": [
("Navn", lambda r: "ida gundersen" in _text(r, "name").lower()),
("Titel", lambda r: "technical advisor" in _text(r, "title").lower() and "kind regards" not in _text(r, "title").lower()),
("Firma", lambda r: "createx" in _text(r, "company").lower()),
("Mobil", lambda r: "42255908" in _digits(r.get("mobile"))),
("E-mail", lambda r: _text(r, "email").lower() == "ida@createx-onstage.com"),
],
},
{
"key": "internet_invoice", "name": "Internetfaktura", "category": "Økonomi",
"description": "Skelner fakturanummer, kredsløbsreference, adresse, IP-ranges og indkøbspris.",
"expected": {"reference": "NKA-027964", "address": "Firskovvej 36", "postal_code": "2800", "city": "Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "purchase_price_dkk": 2495},
"prompt": """Udtræk linjen som JSON med præcis nøglerne reference, address, postal_code, city, ip_ranges, purchase_price_dkk. ip_ranges er en liste og purchase_price_dkk et tal.\n\nGlobalConnect faktura 3018657\nNKA-027964 | Firskovvej 36 | 2800 Kongens Lyngby | Internet 1 Gbit\nIP: 217.74.219.56/30 og 152.115.61.32/27 | Månedlig kostpris: 2.495,00 kr.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Adresse", lambda r: "firskovvej 36" in _text(r, "address").lower()),
("Postnummer", lambda r: str(r.get("postal_code")) == "2800"),
("By", lambda r: "lyngby" in _text(r, "city").lower()),
("IP-ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Indkøbspris", lambda r: abs(float(r.get("purchase_price_dkk")) - 2495) < .01),
],
},
{
"key": "support_solution", "name": "Løsningsforslag", "category": "Support",
"description": "Diagnosticerer DNS uden at påstå, at problemet allerede er løst.",
"expected": {"category": "DNS/netværk", "probable_cause": "Forkert DNS-konfiguration", "diagnostic_steps": ["Kontrollér DNS-adresser", "Test navneopslag", "Sammenlign eller rul ændringen tilbage"], "customer_reply": "Må ikke påstå at fejlen er løst"},
"prompt": """Analysér supportsagen som JSON med nøglerne category, probable_cause, diagnostic_steps, customer_reply. diagnostic_steps er en liste. Påstå ikke at fejlen er løst.\n\nInternet virker via IP-adresser, men websites åbner ikke via navn på alle PC'er. Routeren svarer, og 8.8.8.8 svarer på ping. Fejlen begyndte efter ændring af DNS i morges.""",
"checks": [
("DNS-årsag", lambda r: "dns" in json.dumps(r, ensure_ascii=False).lower()),
("Mindst to trin", lambda r: isinstance(r.get("diagnostic_steps"), list) and len(r["diagnostic_steps"]) >= 2),
("Ingen falsk løsning", lambda r: not any(x in _text(r, "customer_reply").lower() for x in ("løst", "resolved", "fixed"))),
],
},
{
"key": "danish_rewrite", "name": "Dansk omskrivning", "category": "Kommunikation",
"description": "Forbedrer sproget uden at ændre reference, adresse, tider eller leverandør.",
"expected": {"text": "Professionel dansk tekst, som bevarer NKA-027964, Firskovvej 36, 08:15, 10:00 og GlobalConnect uden at opfinde en løsning."},
"prompt": """Omskriv til en kort professionel dansk kundemail. Bevar alle fakta og opfind ikke en løsning. Returnér JSON med nøglen text.\n\nhej vi kan se jeres forbindelse NKA-027964 på Firskovvej 36 har været nede siden kl 08:15. vi undersøger det hos globalconnect og vender tilbage senest kl 10:00.""",
"checks": [
("Reference", lambda r: "NKA-027964" in _text(r, "text")),
("Adresse", lambda r: "Firskovvej 36" in _text(r, "text")),
("Tider", lambda r: "08:15" in _text(r, "text") and "10:00" in _text(r, "text")),
("Leverandør", lambda r: "globalconnect" in _text(r, "text").lower()),
],
},
{
"key": "ticket_triage", "name": "Sagsklassificering", "category": "Support",
"description": "Finder netværkskategori og kritisk prioritet ved driftsstop for mange brugere.",
"expected": {"category": "Netværk", "priority": "Kritisk", "affected_scope": "Alle 34 medarbejdere", "suggested_actions": ["Kontrollér fiber/LOS", "Eskalér forbindelsesfejlen til leverandøren"]},
"prompt": """Klassificér som JSON med nøglerne category, priority, affected_scope, suggested_actions.\n\nAlle 34 medarbejdere mistede internet og telefoni kl. 09:02. Fiberboksen har rødt LOS-lys, og virksomheden kan ikke ekspedere ordrer.""",
"checks": [
("Netværk", lambda r: any(x in _text(r, "category").lower() for x in ("netværk", "network"))),
("Kritisk", lambda r: any(x in _text(r, "priority").lower() for x in ("kritisk", "critical", "urgent"))),
("Omfang", lambda r: "34" in _text(r, "affected_scope") or "alle" in _text(r, "affected_scope").lower()),
("Handlinger", lambda r: isinstance(r.get("suggested_actions"), list) and len(r["suggested_actions"]) >= 2),
],
},
{
"key": "sentiment", "name": "Kundestemning", "category": "CRM",
"description": "Registrerer frustration og høj risiko uden at kalde beskeden positiv.",
"expected": {"sentiment": "Frustreret eller vred", "urgency": "Høj/kritisk", "risk_score": "7-10"},
"prompt": """Analysér tonen som JSON med nøglerne sentiment, urgency, risk_score. risk_score er 0-10.\n\nDet er tredje gang på en uge systemet går ned. Vi mister salg, og hvis det fortsætter, finder vi en anden leverandør. Ring straks.""",
"checks": [
("Frustration", lambda r: any(x in _text(r, "sentiment").lower() for x in ("frustr", "vred", "angry", "negative"))),
("Haster", lambda r: any(x in _text(r, "urgency").lower() for x in ("høj", "high", "krit", "urgent"))),
("Høj risiko", lambda r: 7 <= float(r.get("risk_score")) <= 10),
],
},
{
"key": "meeting_actions", "name": "Møde → opgaver", "category": "Produktivitet",
"description": "Udtrækker ansvarlige og deadlines fra dansk mødetekst.",
"expected": {"tasks": [{"action": "Opdatér firewallen", "owner": "Christian", "deadline": "fredag 4. september"}, {"action": "Send status til kunden", "owner": "Anna", "deadline": "mandag"}], "must_not_include": "Peter/kaffesnak"},
"prompt": """Udtræk kun konkrete opgaver som JSON med nøglen tasks. Hver opgave har action, owner, deadline.\n\nVi talte om printere. Christian opdaterer firewallen fredag 4. september. Anna sender status til kunden mandag. Peter synes kaffen var god.""",
"checks": [
("To opgaver", lambda r: isinstance(r.get("tasks"), list) and len(r["tasks"]) == 2),
("Christian", lambda r: "christian" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Anna", lambda r: "anna" in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
("Ingen Peter-opgave", lambda r: "peter" not in json.dumps(r.get("tasks"), ensure_ascii=False).lower()),
],
},
{
"key": "knowledge_anonymize", "name": "Vidensartikel", "category": "Viden",
"description": "Omdanner en løsning til generel viden og fjerner kunde, bruger og offentlig IP.",
"expected": {"title": "Generel DNS-fejlsøgning", "problem": "Navneopslag virker ikke på en klient", "solution_steps": ["Sæt korrekt/automatisk DNS", "Kør ipconfig /flushdns", "Test igen"], "must_not_include": ["Garant", "Bo Rasmussen", "Ida", "217.74.219.58"]},
"prompt": """Lav en kort vidensartikel som JSON med nøglerne title, problem, solution_steps. Fjern alle kundespecifikke oplysninger og persondata.\n\nHos Garant v/ Bo Rasmussen kunne bruger Ida ikke åbne intranettet. DNS på PC 217.74.219.58 pegede forkert. Teknikeren satte automatisk DNS og kørte ipconfig /flushdns; derefter virkede det.""",
"checks": [
("Løsningstrin", lambda r: isinstance(r.get("solution_steps"), list) and len(r["solution_steps"]) >= 2),
("Ingen kunde", lambda r: "garant" not in json.dumps(r, ensure_ascii=False).lower()),
("Ingen person", lambda r: all(x not in json.dumps(r, ensure_ascii=False).lower() for x in ("bo rasmussen", "ida"))),
("Ingen IP", lambda r: "217.74.219.58" not in json.dumps(r, ensure_ascii=False)),
("Teknisk fakta", lambda r: "flushdns" in json.dumps(r, ensure_ascii=False).lower()),
],
},
{
"key": "billing_summary", "name": "Fakturatekst", "category": "Økonomi",
"description": "Opsummerer udført arbejde og medtager ikke intern fejlsøgning, der ikke blev udført.",
"expected": {"text": "Kontrol af MFA, geninstallation af VPN-profil og efterfølgende test med kunden. Firewall må ikke nævnes som udført arbejde."},
"prompt": """Lav professionel dansk fakturatekst som JSON med nøglen text. Beskriv kun udført arbejde.\n\nSag: VPN virkede ikke. 09:10 kontrolleret brugerens MFA. 09:18 geninstalleret VPN-profil. 09:25 testet login sammen med kunden, nu OK. Overvejede firewallændring, men udførte den ikke.""",
"checks": [
("VPN-profil", lambda r: "vpn" in _text(r, "text").lower() and any(x in _text(r, "text").lower() for x in ("geninstall", "installer"))),
("MFA", lambda r: "mfa" in _text(r, "text").lower()),
("Test", lambda r: "test" in _text(r, "text").lower()),
("Ingen firewallændring", lambda r: "firewall" not in _text(r, "text").lower()),
],
},
{
"key": "ip_reference_safety", "name": "IP-reference sikkerhed", "category": "Internet",
"description": "Holder IP-ranges på den eksplicit angivne kredsløbsreference og undgår forkert adressekobling.",
"expected": {"reference": "NKA-027964", "service_address": "Firskovvej 36, 2800 Kongens Lyngby", "ip_ranges": ["217.74.219.56/30", "152.115.61.32/27"], "conflicting_address": "Mileparken 22 (tilhører NKA-024219 og må ikke kobles)"},
"prompt": """Returnér JSON med nøglerne reference, service_address, ip_ranges, conflicting_address. Brug kun eksplicitte relationer.\n\nNKA-027964, serviceadresse Firskovvej 36, 2800 Kongens Lyngby. Tilknyttede ranges: 217.74.219.56/30 og 152.115.61.32/27. Teksten 'Mileparken 22' står i en uvedkommende fakturalinje for NKA-024219 og må ikke kobles hertil.""",
"checks": [
("Reference", lambda r: "027964" in _text(r, "reference")),
("Korrekt adresse", lambda r: "firskovvej" in _text(r, "service_address").lower()),
("Begge ranges", lambda r: set(r.get("ip_ranges") or []) == {"217.74.219.56/30", "152.115.61.32/27"}),
("Konflikt markeret", lambda r: "mileparken" in _text(r, "conflicting_address").lower()),
],
},
]
def public_tests() -> list[dict]:
return [{key: test[key] for key in ("key", "name", "category", "description")} | {"max_score": len(test["checks"])} for test in BENCHMARK_TESTS]
def _parse_json(content: str) -> dict:
cleaned = (content or "").strip()
cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned, flags=re.I)
cleaned = re.sub(r"\s*```$", "", cleaned)
value = json.loads(cleaned)
if not isinstance(value, dict):
raise ValueError("Svaret var ikke et JSON-objekt")
return value
async def fetch_models(endpoint: str) -> list[dict]:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.get(f"{endpoint.rstrip('/')}/api/tags")
response.raise_for_status()
return [
{
"name": item.get("name"), "size": item.get("size"),
"parameters": (item.get("details") or {}).get("parameter_size"),
"quantization": (item.get("details") or {}).get("quantization_level"),
}
for item in response.json().get("models", []) if item.get("name")
]
async def _run_case(endpoint: str, model: str, test: dict) -> dict:
payload = {
"model": model, "stream": False, "think": False, "format": "json",
"messages": [
{"role": "system", "content": "Returnér kun gyldig JSON og følg det ønskede schema præcist."},
{"role": "user", "content": test["prompt"]},
],
"options": {"temperature": 0, "num_ctx": 8192, "num_predict": 700},
}
started = time.perf_counter()
async with httpx.AsyncClient(timeout=httpx.Timeout(240, connect=15)) as client:
response = await client.post(f"{endpoint.rstrip('/')}/api/chat", json=payload)
response.raise_for_status()
elapsed_ms = round((time.perf_counter() - started) * 1000)
envelope = response.json()
raw = str((envelope.get("message") or {}).get("content") or "")
result = _parse_json(raw)
passed, failed = [], []
for label, check in test["checks"]:
try:
(passed if check(result) else failed).append(label)
except (AttributeError, KeyError, TypeError, ValueError):
failed.append(label)
eval_count = int(envelope.get("eval_count") or 0)
eval_duration = int(envelope.get("eval_duration") or 0)
tokens_per_second = round(eval_count / (eval_duration / 1_000_000_000), 2) if eval_count and eval_duration else None
return {
"score": len(passed), "max_score": len(test["checks"]), "duration_ms": elapsed_ms,
"prompt_tokens": envelope.get("prompt_eval_count"), "response_tokens": eval_count or None,
"tokens_per_second": tokens_per_second, "passed": passed, "failed": failed,
"response_json": result, "response_text": raw, "error": None,
}
async def execute_run(run_id: int, endpoint: str, models: list[str], test_keys: list[str]) -> None:
selected = [test for test in BENCHMARK_TESTS if test["key"] in test_keys]
execute_query("UPDATE ai_benchmark_runs SET status='running', started_at=NOW() WHERE id=%s", (run_id,))
try:
for model in models:
for test in selected:
try:
result = await _run_case(endpoint, model, test)
except Exception as exc:
logger.warning("AI benchmark failed run=%s model=%s test=%s: %s", run_id, model, test["key"], exc)
result = {
"score": 0, "max_score": len(test["checks"]), "duration_ms": 0,
"prompt_tokens": None, "response_tokens": None, "tokens_per_second": None,
"passed": [], "failed": [label for label, _ in test["checks"]],
"response_json": None, "response_text": None, "error": str(exc),
}
execute_query(
"""INSERT INTO ai_benchmark_results
(run_id, model, test_key, test_name, category, score, max_score, duration_ms,
prompt_tokens, response_tokens, tokens_per_second, passed_checks, failed_checks,
response_json, response_text, error_text, expected_json)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s::jsonb,%s,%s,%s::jsonb)
ON CONFLICT (run_id, model, test_key) DO NOTHING""",
(run_id, model, test["key"], test["name"], test["category"], result["score"],
result["max_score"], result["duration_ms"], result["prompt_tokens"],
result["response_tokens"], result["tokens_per_second"], json.dumps(result["passed"]),
json.dumps(result["failed"]), json.dumps(result["response_json"]) if result["response_json"] is not None else None,
result["response_text"], result["error"], json.dumps(test["expected"], ensure_ascii=False)),
)
execute_query("UPDATE ai_benchmark_runs SET completed_cases=completed_cases+1 WHERE id=%s", (run_id,))
execute_query("UPDATE ai_benchmark_runs SET status='completed', completed_at=NOW() WHERE id=%s", (run_id,))
except Exception as exc:
logger.exception("AI benchmark run %s crashed", run_id)
execute_query("UPDATE ai_benchmark_runs SET status='failed', completed_at=NOW(), error_text=%s WHERE id=%s", (str(exc), run_id))
def get_run(run_id: int) -> dict | None:
run = execute_query_single(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by WHERE r.id=%s""", (run_id,)
)
if not run:
return None
results = execute_query("SELECT * FROM ai_benchmark_results WHERE run_id=%s ORDER BY model, test_key", (run_id,)) or []
expected_by_key = {test["key"]: test["expected"] for test in BENCHMARK_TESTS}
for result in results:
if result.get("expected_json") is None:
result["expected_json"] = expected_by_key.get(result.get("test_key"))
run["results"] = results
return run
def list_runs(limit: int = 30) -> list[dict]:
return execute_query(
"""SELECT r.*, COALESCE(u.full_name, u.username) AS created_by_name,
COALESCE((SELECT jsonb_agg(summary ORDER BY model) FROM (
SELECT model, SUM(score) AS score, SUM(max_score) AS max_score,
SUM(duration_ms) AS duration_ms, ROUND(AVG(tokens_per_second), 2) AS tokens_per_second
FROM ai_benchmark_results br WHERE br.run_id=r.id GROUP BY model
) summary), '[]'::jsonb) AS model_summaries
FROM ai_benchmark_runs r LEFT JOIN users u ON u.user_id=r.created_by
ORDER BY r.created_at DESC LIMIT %s""", (limit,)
) or []

View File

@ -2,13 +2,13 @@
Settings and User Management API Router Settings and User Management API Router
""" """
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query from fastapi import APIRouter, HTTPException, Request, Depends
from typing import List, Optional, Dict from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, timezone from datetime import datetime
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query
from app.core.config import settings from app.core.config import settings
from app.core.auth_dependencies import require_any_permission, require_superadmin from app.core.auth_dependencies import require_superadmin
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
import argparse import argparse
import asyncio import asyncio
@ -70,11 +70,6 @@ class SagTestRunRequest(BaseModel):
customer_id: Optional[int] = None customer_id: Optional[int] = None
class AIBenchmarkRunRequest(BaseModel):
models: List[str]
test_keys: Optional[List[str]] = None
MATTERMOST_SETTING_DEFAULTS = ( MATTERMOST_SETTING_DEFAULTS = (
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"), ("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"), ("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
@ -187,91 +182,6 @@ async def run_sag_test(
_sag_test_lock.release() _sag_test_lock.release()
@router.get("/settings/ai-benchmarks/catalog", tags=["AI Benchmarks"])
async def get_ai_benchmark_catalog(
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import fetch_models, public_tests
try:
models = await fetch_models(settings.OLLAMA_ENDPOINT)
model_error = None
except Exception as exc:
logger.warning("Could not load Ollama models for benchmark: %s", exc)
models = []
model_error = str(exc)
return {
"active_model": settings.OLLAMA_MODEL,
"endpoint": settings.OLLAMA_ENDPOINT,
"models": models,
"tests": public_tests(),
"model_error": model_error,
}
@router.get("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def get_ai_benchmark_runs(
limit: int = 30,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import list_runs
return {"items": list_runs(max(1, min(limit, 100)))}
@router.get("/settings/ai-benchmarks/runs/{run_id}", tags=["AI Benchmarks"])
async def get_ai_benchmark_run(
run_id: int,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import get_run
run = get_run(run_id)
if not run:
raise HTTPException(status_code=404, detail="Testkørslen findes ikke")
return run
@router.post("/settings/ai-benchmarks/runs", tags=["AI Benchmarks"])
async def create_ai_benchmark_run(
payload: AIBenchmarkRunRequest,
background_tasks: BackgroundTasks,
current_user: dict = Depends(require_superadmin),
):
from app.settings.backend.ai_benchmark import BENCHMARK_TESTS, execute_run, fetch_models
models = list(dict.fromkeys(model.strip() for model in payload.models if model.strip()))
if not 1 <= len(models) <= 4:
raise HTTPException(status_code=400, detail="Vælg mellem 1 og 4 modeller")
installed = {item["name"] for item in await fetch_models(settings.OLLAMA_ENDPOINT)}
missing = [model for model in models if model not in installed]
if missing:
raise HTTPException(status_code=400, detail=f"Modellen er ikke installeret: {', '.join(missing)}")
available_keys = {test["key"] for test in BENCHMARK_TESTS}
test_keys = list(dict.fromkeys(payload.test_keys or [test["key"] for test in BENCHMARK_TESTS]))
unknown = [key for key in test_keys if key not in available_keys]
if unknown or not test_keys:
raise HTTPException(status_code=400, detail="Vælg mindst én gyldig test")
running = execute_query_single(
"SELECT id FROM ai_benchmark_runs WHERE status IN ('queued', 'running') ORDER BY id DESC LIMIT 1"
)
if running:
raise HTTPException(status_code=409, detail=f"Testkørsel #{running['id']} kører allerede")
user_id = current_user.get("id") or current_user.get("user_id")
rows = execute_query(
"""INSERT INTO ai_benchmark_runs
(status, models, test_keys, total_cases, completed_cases, created_by)
VALUES ('queued', %s::jsonb, %s::jsonb, %s, 0, %s) RETURNING *""",
(json.dumps(models), json.dumps(test_keys), len(models) * len(test_keys), user_id),
)
run = rows[0]
background_tasks.add_task(execute_run, run["id"], settings.OLLAMA_ENDPOINT, models, test_keys)
return run
# Settings Endpoints # Settings Endpoints
@router.get("/settings", response_model=List[Setting], tags=["Settings"]) @router.get("/settings", response_model=List[Setting], tags=["Settings"])
async def get_settings(category: Optional[str] = None): async def get_settings(category: Optional[str] = None):
@ -363,7 +273,7 @@ async def get_setting(key: str):
query = "SELECT * FROM settings WHERE key = %s" query = "SELECT * FROM settings WHERE key = %s"
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses", "time_multiplier_presets"}: if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
seed_query = """ seed_query = """
INSERT INTO settings (key, value, category, description, value_type, is_public) INSERT INTO settings (key, value, category, description, value_type, is_public)
VALUES (%s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
@ -415,19 +325,6 @@ async def get_setting(key: str):
) )
) )
if key == "time_multiplier_presets":
execute_query(
seed_query,
(
"time_multiplier_presets",
'[{"label":"Haster","text":"Haster","multiplier":3},{"label":"Avanceret netværk","text":"Avanceret netværk","multiplier":2},{"label":"Haster + ava. network","text":"Haster + ava. network","multiplier":6}]',
"system",
"Valgbare multiplikator presets til tidsregistrering",
"json",
True,
),
)
result = execute_query(query, (key,)) result = execute_query(query, (key,))
if not result and key == "email_default_signature_template": if not result and key == "email_default_signature_template":
@ -1288,76 +1185,3 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}") logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
err = str(e) or e.__class__.__name__ err = str(e) or e.__class__.__name__
raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}") raise HTTPException(status_code=500, detail=f"Kunne ikke teste AI prompt: {err}")
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
def _event_sort_value(event: Dict) -> float:
"""Return one comparable timestamp for naive and timezone-aware DB values."""
value = event.get("occurred_at")
if not value:
return 0.0
if isinstance(value, datetime):
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.timestamp()
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except (TypeError, ValueError):
return 0.0
@router.get("/settings/system-events/list", dependencies=[Depends(system_event_log_access)], tags=["Settings"])
async def list_system_events(
source: Optional[str] = Query(None), event_type: Optional[str] = Query(None),
status: Optional[str] = Query(None), q: Optional[str] = Query(None),
date_from: Optional[datetime] = Query(None), date_to: Optional[datetime] = Query(None),
user_id: Optional[int] = Query(None), limit: int = Query(250, ge=1, le=2000),
):
"""Unified operational log over persisted system, VoIP and email events."""
clauses, params = [], []
if source: clauses.append("l.source=%s"); params.append(source)
if event_type: clauses.append("l.event_type=%s"); params.append(event_type)
if status: clauses.append("l.status=%s"); params.append(status)
if user_id: clauses.append("l.user_id=%s"); params.append(user_id)
if date_from: clauses.append("l.occurred_at>=%s"); params.append(date_from)
if date_to: clauses.append("l.occurred_at<=%s"); params.append(date_to)
if q:
clauses.append("(l.summary ILIKE %s OR l.correlation_id ILIKE %s OR l.details::text ILIKE %s)")
params.extend([f"%{q}%"] * 3)
where = "WHERE " + " AND ".join(clauses) if clauses else ""
events = execute_query(
f"""SELECT l.id,l.source,l.event_type,l.status,l.summary,l.correlation_id,l.user_id,
l.details,l.occurred_at,COALESCE(u.full_name,u.username) AS user_name
FROM system_event_log l LEFT JOIN users u ON u.user_id=l.user_id {where}
ORDER BY l.occurred_at DESC LIMIT %s""", tuple(params + [limit]),
) or []
# Existing mail records remain searchable even before producers adopt system_event_log.
if source in (None, "email"):
email_clauses, email_params = [], []
if date_from: email_clauses.append("em.received_date>=%s"); email_params.append(date_from)
if date_to: email_clauses.append("em.received_date<=%s"); email_params.append(date_to)
if q:
email_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.recipient_email ILIKE %s)")
email_params.extend([f"%{q}%"] * 3)
email_where = "WHERE " + " AND ".join(email_clauses) if email_clauses else ""
emails = execute_query(
f"""SELECT em.id,'email' AS source,
CASE WHEN LOWER(COALESCE(em.folder,'')) LIKE '%%sent%%' THEN 'email.sent' ELSE 'email.received' END AS event_type,
'success'::text AS status,COALESCE(em.subject,'(intet emne)') AS summary,
em.message_id AS correlation_id,NULL::integer AS user_id,
jsonb_build_object('from',em.sender_email,'to',em.recipient_email,'folder',em.folder) AS details,
em.received_date AS occurred_at,NULL::text AS user_name
FROM email_messages em {email_where} ORDER BY em.received_date DESC LIMIT %s""",
tuple(email_params + [limit]),
) or []
if event_type: emails = [x for x in emails if x["event_type"] == event_type]
if status: emails = [x for x in emails if x["status"] == status]
events.extend(emails)
events.sort(key=_event_sort_value, reverse=True)
return {"items": events[:limit], "sources": ["voip", "email", "system"], "count": len(events[:limit])}

View File

@ -18,7 +18,6 @@ from app.core.auth_dependencies import require_any_permission
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
sql_console_access = require_any_permission("users.manage", "system.admin") sql_console_access = require_any_permission("users.manage", "system.admin")
system_event_log_access = require_any_permission("system.event_log.view", "users.manage", "system.admin")
CREATE_TABLE_RE = re.compile( CREATE_TABLE_RE = re.compile(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(", r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
@ -266,11 +265,6 @@ async def settings_page(request: Request):
}) })
@router.get("/settings/event-log", response_class=HTMLResponse, tags=["Frontend"], dependencies=[Depends(system_event_log_access)])
async def system_event_log_page(request: Request):
return templates.TemplateResponse("settings/frontend/event_log.html", {"request": request, "title": "Systemlog"})
@router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"]) @router.get("/settings/migrations", response_class=HTMLResponse, tags=["Frontend"])
async def migrations_page(request: Request): async def migrations_page(request: Request):
"""Render database migrations page""" """Render database migrations page"""

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