Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee274a939a | ||
|
|
b95815de0d | ||
|
|
dbdb1c554a | ||
|
|
85fd99c5d8 | ||
|
|
4971f9721c | ||
|
|
a523bc4ac3 | ||
|
|
2a71e9285a | ||
|
|
b3059dced1 | ||
|
|
87932f8e16 | ||
|
|
3e8e5ba1cf | ||
|
|
631fc6cdca | ||
|
|
13f36a36c0 | ||
|
|
7c1a748012 | ||
|
|
3d4a7c8421 | ||
|
|
f251bfebc4 | ||
|
|
056ce7b871 | ||
|
|
4bf592cf0c | ||
|
|
9ed709c9e8 | ||
|
|
9ca562745a | ||
|
|
1cfe5aee76 | ||
|
|
adc4fb5876 |
@ -3,6 +3,7 @@
|
||||
# =====================================================
|
||||
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
|
||||
HUB_BASE_URL=https://hub.bmcnetworks.dk
|
||||
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
|
||||
|
||||
# Database credentials (bruges af docker-compose)
|
||||
POSTGRES_USER=bmc_hub
|
||||
|
||||
18
MDfile/RELEASE_NOTES_v2.8.0.md
Normal file
18
MDfile/RELEASE_NOTES_v2.8.0.md
Normal file
@ -0,0 +1,18 @@
|
||||
# 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`.
|
||||
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
39
MDfile/RELEASE_NOTES_v2.8.1.md
Normal file
@ -0,0 +1,39 @@
|
||||
# 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.
|
||||
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.10.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal file
6
MDfile/RELEASE_NOTES_v2.8.11.md
Normal file
@ -0,0 +1,6 @@
|
||||
# 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.
|
||||
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.12.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.14.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal file
11
MDfile/RELEASE_NOTES_v2.8.15.md
Normal file
@ -0,0 +1,11 @@
|
||||
# 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.
|
||||
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal file
9
MDfile/RELEASE_NOTES_v2.8.16.md
Normal file
@ -0,0 +1,9 @@
|
||||
# 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.2.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.2.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal file
38
MDfile/RELEASE_NOTES_v2.8.3.md
Normal file
@ -0,0 +1,38 @@
|
||||
# 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.4.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.4.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.5.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.5.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
13
MDfile/RELEASE_NOTES_v2.8.6.md
Normal file
13
MDfile/RELEASE_NOTES_v2.8.6.md
Normal file
@ -0,0 +1,13 @@
|
||||
# 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.
|
||||
21
MDfile/RELEASE_NOTES_v2.8.7.md
Normal file
21
MDfile/RELEASE_NOTES_v2.8.7.md
Normal file
@ -0,0 +1,21 @@
|
||||
# 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.
|
||||
17
MDfile/RELEASE_NOTES_v2.8.8.md
Normal file
17
MDfile/RELEASE_NOTES_v2.8.8.md
Normal file
@ -0,0 +1,17 @@
|
||||
# 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.
|
||||
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal file
14
MDfile/RELEASE_NOTES_v2.8.9.md
Normal file
@ -0,0 +1,14 @@
|
||||
# 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.
|
||||
1
app/admin/__init__.py
Normal file
1
app/admin/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Restricted administrative reporting modules."""
|
||||
254
app/admin/archive_bundle.py
Normal file
254
app/admin/archive_bundle.py
Normal file
@ -0,0 +1,254 @@
|
||||
"""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)
|
||||
60
app/admin/hub_impact.html
Normal file
60
app/admin/hub_impact.html
Normal file
@ -0,0 +1,60 @@
|
||||
{% 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
||||
235
app/admin/hub_impact.py
Normal file
235
app/admin/hub_impact.py
Normal file
@ -0,0 +1,235 @@
|
||||
"""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),
|
||||
))
|
||||
344
app/admin/router.py
Normal file
344
app/admin/router.py
Normal file
@ -0,0 +1,344 @@
|
||||
"""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"'},
|
||||
)
|
||||
13
app/admin/views.py
Normal file
13
app/admin/views.py
Normal file
@ -0,0 +1,13 @@
|
||||
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})
|
||||
378
app/admin/vtiger_archive.py
Normal file
378
app/admin/vtiger_archive.py
Normal file
@ -0,0 +1,378 @@
|
||||
"""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}
|
||||
@ -87,6 +87,11 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const requestedNext = new URLSearchParams(window.location.search).get('next');
|
||||
const loginRedirectTarget = requestedNext && requestedNext.startsWith('/') && !requestedNext.startsWith('//')
|
||||
? requestedNext
|
||||
: '/';
|
||||
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -131,8 +136,7 @@ document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Show error
|
||||
errorText.textContent = data.detail || 'Login fejlede. Tjek brugernavn og adgangskode.';
|
||||
@ -169,8 +173,7 @@ if (token) {
|
||||
d.setTime(d.getTime() + (24*60*60*1000));
|
||||
document.cookie = `access_token=${token};expires=${d.toUTCString()};path=/;SameSite=Lax`;
|
||||
|
||||
// Redirect to dashboard
|
||||
window.location.href = '/';
|
||||
window.location.href = loginRedirectTarget;
|
||||
} else {
|
||||
// Token invalid, clear storage
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
@ -16,6 +16,7 @@ from app.services.economic_service import get_economic_service
|
||||
from app.services.ollama_service import ollama_service
|
||||
from app.services.template_service import template_service
|
||||
from app.services.invoice2data_service import get_invoice2data_service
|
||||
from app.modules.internet_connections.backend.change_case_service import ensure_external_change_case
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
@ -26,15 +27,11 @@ router = APIRouter()
|
||||
|
||||
_PURCHASE_CASE_TYPE = "indkøb"
|
||||
_INTERNET_CASE_RELEVANT_CHANGE_FIELDS = {
|
||||
"address",
|
||||
"service_address",
|
||||
"monthly_cost",
|
||||
"technology",
|
||||
"connection_type",
|
||||
"circuit_number",
|
||||
"speed_mbps",
|
||||
"download_mbps",
|
||||
"upload_mbps",
|
||||
"address", "service_address", "monthly_cost", "sales_price", "technology",
|
||||
"connection_type", "circuit_number", "provider_reference", "provider", "vendor_id",
|
||||
"speed_mbps", "download_mbps", "upload_mbps", "status", "sla_subscription_id",
|
||||
"sla_price", "sla_status", "cidr", "contract_number", "range_added", "range_removed",
|
||||
"range_monthly_cost", "range_sales_price",
|
||||
}
|
||||
|
||||
SUPPLIER_STATUS_V2 = ("modtaget", "godkendt", "betalt", "afvist")
|
||||
@ -267,90 +264,19 @@ def _ensure_internet_change_case(
|
||||
owner_customer_id: Optional[int],
|
||||
changes: Dict[str, Dict[str, object]],
|
||||
) -> Optional[int]:
|
||||
# Customer ownership, initial activation and internal classification are
|
||||
# bookkeeping outcomes of a successful import, not operational incidents.
|
||||
# Only create cases for changes that can affect delivery or billing.
|
||||
relevant_changes = {
|
||||
field: change
|
||||
for field, change in (changes or {}).items()
|
||||
if field in _INTERNET_CASE_RELEVANT_CHANGE_FIELDS
|
||||
}
|
||||
if not relevant_changes:
|
||||
return None
|
||||
|
||||
title = f"Internet ændring {reference or connection_name} - faktura {invoice_number}"
|
||||
existing = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM sag_sager
|
||||
WHERE deleted_at IS NULL
|
||||
AND titel = %s
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(title,),
|
||||
outcome = ensure_external_change_case(
|
||||
connection_id=connection_id,
|
||||
source_type="globalconnect_invoice",
|
||||
source_key=str(invoice_number),
|
||||
source_label=f"GlobalConnect faktura {invoice_number}",
|
||||
source_url="/billing/supplier-invoices",
|
||||
reference=reference,
|
||||
connection_name=connection_name,
|
||||
provider="GlobalConnect",
|
||||
owner_customer_id=owner_customer_id,
|
||||
changes=changes,
|
||||
)
|
||||
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
|
||||
return outcome.get("case_id")
|
||||
|
||||
|
||||
def _ensure_case_for_supplier_invoice(
|
||||
@ -705,6 +631,82 @@ def _normalize_provider_reference(value: Optional[str]) -> str:
|
||||
return re.sub(r"[^A-Z0-9]", "", raw)
|
||||
|
||||
|
||||
def _provider_reference_match_keys(value: Optional[str]) -> set[str]:
|
||||
normalized = _normalize_provider_reference(value)
|
||||
if not normalized:
|
||||
return set()
|
||||
keys = {normalized}
|
||||
if normalized.startswith("DSLEB"):
|
||||
keys.add(normalized[3:])
|
||||
elif normalized.startswith("EB"):
|
||||
keys.add(f"DSL{normalized}")
|
||||
return keys
|
||||
|
||||
|
||||
def _find_unique_globalconnect_connection_by_reference(reference: Optional[str]) -> Optional[int]:
|
||||
target_keys = _provider_reference_match_keys(reference)
|
||||
if not target_keys:
|
||||
return None
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id, circuit_number
|
||||
FROM internet_connections_connections
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider ILIKE 'GlobalConnect%%'
|
||||
AND NULLIF(BTRIM(circuit_number), '') IS NOT NULL
|
||||
ORDER BY id
|
||||
"""
|
||||
) or []
|
||||
matches = [row for row in rows if target_keys & _provider_reference_match_keys(row.get("circuit_number"))]
|
||||
return int(matches[0]["id"]) if len(matches) == 1 else None
|
||||
|
||||
|
||||
def _create_pending_connection_for_ip_reference(line: Dict, invoice_number: str) -> Optional[int]:
|
||||
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
||||
normalized_reference = _normalize_provider_reference(display_reference)
|
||||
if not normalized_reference:
|
||||
return None
|
||||
existing_id = _find_unique_globalconnect_connection_by_reference(display_reference)
|
||||
if existing_id:
|
||||
return existing_id
|
||||
service_address = _build_service_address(line)
|
||||
connection_id = execute_insert(
|
||||
"""
|
||||
INSERT INTO internet_connections_connections (
|
||||
name, provider, customer_id, address, status, monthly_cost, sales_price,
|
||||
technology, connection_type, circuit_number, notes, allocation_model,
|
||||
value_type, value_label
|
||||
)
|
||||
VALUES (%s, %s, NULL, %s, 'pending', 0, 0, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"Afventer mapping · {display_reference}",
|
||||
"GlobalConnect A/S",
|
||||
service_address,
|
||||
"Internet",
|
||||
"Internet",
|
||||
display_reference,
|
||||
f"Oprettet fra IP-range på faktura {invoice_number}. Kunde tildeles aldrig automatisk. Serviceadresse kræver manuel kontrol.",
|
||||
"dedicated",
|
||||
"other",
|
||||
"Afventer manuel klassifikation",
|
||||
),
|
||||
)
|
||||
return int(connection_id) if connection_id else None
|
||||
|
||||
|
||||
def _canonical_ip_network(value: Optional[str]) -> str:
|
||||
"""Canonicalize invoice IP/CIDR values before matching or persistence."""
|
||||
raw = re.sub(r"\s+", "", str(value or "").strip())
|
||||
if not raw:
|
||||
return ""
|
||||
try:
|
||||
return str(ipaddress.ip_network(raw, strict=False))
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_mapping_note(end_customer_name: str, service_address: Optional[str], reference: str) -> str:
|
||||
parts = [f"Afventer mapping for {reference}."]
|
||||
if end_customer_name:
|
||||
@ -757,11 +759,9 @@ def _should_assign_internal_bmc_owner(
|
||||
return False
|
||||
if any(str(line.get("end_customer_name") or "").strip() for line in lines):
|
||||
return False
|
||||
if any(_looks_like_ip_range_line(line) for line in lines):
|
||||
return True
|
||||
if len(lines) > 1:
|
||||
return True
|
||||
return bool(service_address)
|
||||
description = " ".join(str(line.get("description") or "").lower() for line in lines)
|
||||
explicit_shared_markers = ("delefiber", "shared", "delt forbindelse", "delt transit", "backbone", "carrier transit")
|
||||
return any(marker in description for marker in explicit_shared_markers)
|
||||
|
||||
|
||||
def _shared_connection_value_type(internal_owner: Optional[Dict], matched_customer: Optional[Dict]) -> str:
|
||||
@ -1068,17 +1068,21 @@ def _merge_globalconnect_duplicate_connections(connection_ids: List[int], canoni
|
||||
|
||||
|
||||
def _merge_globalconnect_duplicate_ip_ranges(connection_id: int, cidr: str) -> Optional[int]:
|
||||
matches = execute_query(
|
||||
canonical_cidr = _canonical_ip_network(cidr)
|
||||
candidates = execute_query(
|
||||
"""
|
||||
SELECT id
|
||||
SELECT id, cidr
|
||||
FROM internet_connections_ip_ranges
|
||||
WHERE connection_id = %s
|
||||
AND cidr = %s
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY id
|
||||
""",
|
||||
(connection_id, cidr),
|
||||
)
|
||||
(connection_id,),
|
||||
) or []
|
||||
matches = [
|
||||
row for row in candidates
|
||||
if canonical_cidr and _canonical_ip_network(row.get("cidr")) == canonical_cidr
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
@ -1116,18 +1120,19 @@ def _normalize_service_address_for_match(value: Optional[str]) -> str:
|
||||
def _get_globalconnect_connections_by_reference(reference: str) -> List[Dict]:
|
||||
if not reference:
|
||||
return []
|
||||
match_keys = sorted(_provider_reference_match_keys(reference))
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT id, customer_id, address, monthly_cost, technology, connection_type,
|
||||
circuit_number, speed_mbps, download_mbps, upload_mbps, status,
|
||||
allocation_model, value_type, value_label
|
||||
allocation_model, value_type, value_label, is_manual_shared
|
||||
FROM internet_connections_connections
|
||||
WHERE deleted_at IS NULL
|
||||
AND provider ILIKE 'GlobalConnect%%'
|
||||
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = %s
|
||||
AND regexp_replace(UPPER(COALESCE(circuit_number, '')), '[^A-Z0-9]', '', 'g') = ANY(%s)
|
||||
ORDER BY id
|
||||
""",
|
||||
(reference,),
|
||||
(match_keys,),
|
||||
) or []
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
@ -1216,44 +1221,30 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
if existing and merge_ids:
|
||||
_merge_globalconnect_duplicate_connections([int(existing["id"])] + merge_ids, int(existing["id"]))
|
||||
|
||||
matched_customer = _match_customer_for_globalconnect_line(primary_line, customers)
|
||||
if not matched_customer and existing and existing.get("customer_id"):
|
||||
# Preserve a previously reviewed owner when the new invoice has no
|
||||
# unambiguous customer name/address instead of replacing it with BMC.
|
||||
matched_customer = next(
|
||||
(customer for customer in customers if int(customer.get("id") or 0) == int(existing["customer_id"])),
|
||||
None,
|
||||
)
|
||||
# Supplier data may suggest a customer name, but customer ownership is
|
||||
# always a manual CRM decision. Existing manually selected owners survive
|
||||
# because update SQL uses COALESCE(NULL, customer_id); new records stay NULL.
|
||||
matched_customer = None
|
||||
description = str(primary_line.get("description") or reference)
|
||||
end_customer_name = str(primary_line.get("end_customer_name") or "").strip()
|
||||
# Internal BMC ownership is only a default for a newly discovered
|
||||
# connection. An existing connection with no customer may deliberately be
|
||||
# unassigned and must not gain an owner merely because a later invoice is
|
||||
# ambiguous.
|
||||
internal_owner = (
|
||||
_resolve_internal_bmc_customer()
|
||||
if not existing and _should_assign_internal_bmc_owner(lines, matched_customer, service_address)
|
||||
else None
|
||||
)
|
||||
owner_customer = matched_customer or internal_owner
|
||||
is_confident = _has_confident_globalconnect_mapping(matched_customer, service_address)
|
||||
connection_name = (
|
||||
end_customer_name or service_address or f"GlobalConnect {reference}"
|
||||
if is_confident
|
||||
else (f"{internal_owner['name']} · {display_reference}" if internal_owner else f"Afventer mapping · {display_reference}")
|
||||
)
|
||||
is_shared_candidate = _should_assign_internal_bmc_owner(lines, None, service_address)
|
||||
internal_owner = None
|
||||
owner_customer = None
|
||||
is_confident = False
|
||||
connection_name = end_customer_name or service_address or f"Afventer mapping · {display_reference}"
|
||||
monthly_cost = sum((_line_monthly_cost(line) for line in lines), Decimal("0"))
|
||||
note_lines = ", ".join(dict.fromkeys(str(line.get("description") or "").strip() for line in lines if line.get("description")))
|
||||
base_note = f"Synced fra GlobalConnect faktura {invoice_number}. Komponenter: {note_lines}"
|
||||
mapping_note = _build_mapping_note(end_customer_name, service_address, display_reference)
|
||||
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}"
|
||||
note_text = f"{base_note} {mapping_note} Kunde tildeles aldrig automatisk."
|
||||
|
||||
download_mbps, upload_mbps, speed_mbps = _infer_speed_profile(description)
|
||||
target_status = "active" if (is_confident or internal_owner) else "pending"
|
||||
shared_value_type = _shared_connection_value_type(internal_owner, matched_customer)
|
||||
target_status = "pending"
|
||||
shared_value_type = "delefiber" if is_shared_candidate else "other"
|
||||
payload = (
|
||||
connection_name,
|
||||
"GlobalConnect A/S",
|
||||
@ -1267,14 +1258,27 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
upload_mbps,
|
||||
download_mbps,
|
||||
note_text,
|
||||
"shared" if internal_owner and not matched_customer else "dedicated",
|
||||
"shared" if is_shared_candidate else "dedicated",
|
||||
shared_value_type,
|
||||
None,
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Delefiber/BMCnet classification is internal operational data. A
|
||||
# supplier invoice may update speed and cost, but must never undo a
|
||||
# deliberate manual shared/delefiber designation.
|
||||
preserve_manual_classification = bool(existing.get("is_manual_shared"))
|
||||
resolved_allocation_model = (
|
||||
existing.get("allocation_model") if preserve_manual_classification
|
||||
else ("shared" if is_shared_candidate else "dedicated")
|
||||
)
|
||||
resolved_value_type = (
|
||||
existing.get("value_type") if preserve_manual_classification
|
||||
else shared_value_type
|
||||
)
|
||||
resolved_value_label = existing.get("value_label") if preserve_manual_classification else None
|
||||
updated_snapshot = {
|
||||
"customer_id": owner_customer["id"] if owner_customer else None,
|
||||
"customer_id": existing.get("customer_id"),
|
||||
"address": service_address,
|
||||
"monthly_cost": monthly_cost,
|
||||
"technology": _infer_technology(description),
|
||||
@ -1284,9 +1288,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
"download_mbps": download_mbps,
|
||||
"upload_mbps": upload_mbps,
|
||||
"status": target_status,
|
||||
"allocation_model": "shared" if internal_owner and not matched_customer else "dedicated",
|
||||
"value_type": shared_value_type,
|
||||
"value_label": None,
|
||||
"allocation_model": resolved_allocation_model,
|
||||
"value_type": resolved_value_type,
|
||||
"value_label": resolved_value_label,
|
||||
}
|
||||
update_payload = (
|
||||
connection_name,
|
||||
@ -1301,9 +1305,9 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
download_mbps,
|
||||
upload_mbps,
|
||||
note_text,
|
||||
"shared" if internal_owner and not matched_customer else "dedicated",
|
||||
shared_value_type,
|
||||
None,
|
||||
resolved_allocation_model,
|
||||
resolved_value_type,
|
||||
resolved_value_label,
|
||||
existing["id"],
|
||||
)
|
||||
execute_update(
|
||||
@ -1333,7 +1337,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
update_payload[1],
|
||||
update_payload[2],
|
||||
update_payload[3],
|
||||
"active" if (is_confident or internal_owner) else "pending",
|
||||
target_status,
|
||||
update_payload[4],
|
||||
update_payload[5],
|
||||
update_payload[6],
|
||||
@ -1398,7 +1402,7 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
payload[1],
|
||||
payload[2],
|
||||
payload[3],
|
||||
"active" if (is_confident or internal_owner) else "pending",
|
||||
target_status,
|
||||
payload[4],
|
||||
payload[5],
|
||||
payload[6],
|
||||
@ -1429,13 +1433,14 @@ def _upsert_globalconnect_connection(reference: str, lines: List[Dict], invoice_
|
||||
|
||||
|
||||
def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_number: str):
|
||||
cidr = str(line.get("ip_address") or "").strip()
|
||||
cidr = _canonical_ip_network(line.get("ip_address"))
|
||||
if not connection_id or not cidr:
|
||||
return None
|
||||
|
||||
display_reference = str(line.get("provider_reference") or line.get("circuit_id") or "").strip()
|
||||
service_address = _build_service_address(line)
|
||||
matched_customer = _match_customer_for_globalconnect_line(line, _load_active_customers_for_matching())
|
||||
# Never infer range ownership from invoice text. A user must select it.
|
||||
matched_customer = None
|
||||
canonical_range_id = _merge_globalconnect_duplicate_ip_ranges(connection_id, cidr)
|
||||
existing = execute_query_single(
|
||||
"""
|
||||
@ -1507,12 +1512,16 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
|
||||
{"cidr": cidr, "changes": changes},
|
||||
)
|
||||
reference = display_reference or cidr
|
||||
connection_owner = execute_query_single(
|
||||
"SELECT customer_id FROM internet_connections_connections WHERE id=%s",
|
||||
(connection_id,),
|
||||
) or {}
|
||||
_ensure_internet_change_case(
|
||||
connection_id=connection_id,
|
||||
invoice_number=invoice_number,
|
||||
reference=reference,
|
||||
connection_name=f"IP-range {cidr}",
|
||||
owner_customer_id=matched_customer["id"] if matched_customer else None,
|
||||
owner_customer_id=connection_owner.get("customer_id"),
|
||||
changes=changes,
|
||||
)
|
||||
return range_id
|
||||
@ -1557,6 +1566,28 @@ def _upsert_globalconnect_ip_range(connection_id: int, line: Dict, invoice_numbe
|
||||
f"IP-range {cidr} oprettet fra faktura {invoice_number}",
|
||||
{"cidr": cidr, "provider_reference": params[1], "contract_number": params[2]},
|
||||
)
|
||||
# A range added to an existing connection is a commercial change. When the
|
||||
# connection itself was created by this invoice, it is merely initial data.
|
||||
created_with_invoice = execute_query_single(
|
||||
"""SELECT 1 FROM internet_connections_history
|
||||
WHERE connection_id=%s AND event_type='connection_created_from_supplier_invoice'
|
||||
AND details->>'invoice_number'=%s LIMIT 1""",
|
||||
(connection_id, invoice_number),
|
||||
)
|
||||
if not created_with_invoice:
|
||||
connection = execute_query_single(
|
||||
"""SELECT name, circuit_number, customer_id, provider
|
||||
FROM internet_connections_connections WHERE id=%s""",
|
||||
(connection_id,),
|
||||
) or {}
|
||||
_ensure_internet_change_case(
|
||||
connection_id=connection_id,
|
||||
invoice_number=invoice_number,
|
||||
reference=str(connection.get("circuit_number") or display_reference or cidr),
|
||||
connection_name=str(connection.get("name") or f"IP-range {cidr}"),
|
||||
owner_customer_id=connection.get("customer_id"),
|
||||
changes={"range_added": {"from": None, "to": cidr}},
|
||||
)
|
||||
return range_id
|
||||
|
||||
|
||||
@ -1593,25 +1624,26 @@ def _connection_can_host_ip_range(connection_id: Optional[int], service_address:
|
||||
|
||||
def _resolve_existing_ip_range_connection(line: Dict) -> Dict[str, object]:
|
||||
"""Use an existing CIDR as the strongest key, but never cross service addresses."""
|
||||
cidr = str(line.get("ip_address") or "").strip()
|
||||
cidr = _canonical_ip_network(line.get("ip_address"))
|
||||
if not cidr:
|
||||
return {"connection_id": None, "conflict_reason": None}
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
service_address = _build_service_address(line)
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT range.connection_id, range.service_address, range.provider_reference,
|
||||
SELECT range.connection_id, range.cidr, range.service_address, range.provider_reference,
|
||||
connection.address AS connection_address,
|
||||
connection.circuit_number AS connection_reference
|
||||
FROM internet_connections_ip_ranges range
|
||||
JOIN internet_connections_connections connection ON connection.id = range.connection_id
|
||||
WHERE range.cidr = %s
|
||||
AND range.deleted_at IS NULL
|
||||
WHERE range.deleted_at IS NULL
|
||||
AND connection.deleted_at IS NULL
|
||||
AND connection.provider ILIKE 'GlobalConnect%%'
|
||||
ORDER BY range.id
|
||||
""",
|
||||
(cidr,),
|
||||
(),
|
||||
) or []
|
||||
rows = [row for row in rows if _canonical_ip_network(row.get("cidr")) == cidr]
|
||||
if reference:
|
||||
matching_reference = [
|
||||
row for row in rows
|
||||
@ -1829,24 +1861,51 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
|
||||
for audit_index, line in ip_range_lines:
|
||||
reference = _normalize_provider_reference(line.get("provider_reference") or line.get("circuit_id"))
|
||||
service_address = _build_service_address(line)
|
||||
reference_connection_id = _find_unique_globalconnect_connection_by_reference(reference)
|
||||
existing_range_resolution = _resolve_existing_ip_range_connection(line)
|
||||
if existing_range_resolution.get("conflict_reason"):
|
||||
if existing_range_resolution.get("conflict_reason") and not reference_connection_id:
|
||||
skipped_orphan_ip_ranges += 1
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = existing_range_resolution["conflict_reason"]
|
||||
continue
|
||||
connection_id = existing_range_resolution.get("connection_id") or connection_map.get(reference)
|
||||
mapped_reference_connection_id = connection_map.get(reference)
|
||||
# An existing CIDR on the same service address is stronger evidence than
|
||||
# a supplier reference. OCR/extraction can accidentally carry a circuit
|
||||
# number from a neighbouring invoice line.
|
||||
address_range_connection_id = existing_range_resolution.get("connection_id")
|
||||
connection_id = address_range_connection_id or reference_connection_id or mapped_reference_connection_id
|
||||
resolved_from_existing = False
|
||||
if existing_range_resolution.get("connection_id"):
|
||||
matched_by_reference = bool(
|
||||
not address_range_connection_id
|
||||
and (reference_connection_id or mapped_reference_connection_id)
|
||||
)
|
||||
if reference_connection_id or address_range_connection_id:
|
||||
resolved_from_existing = True
|
||||
corrected_service_address = None
|
||||
if connection_id and not _connection_can_host_ip_range(connection_id, service_address):
|
||||
if matched_by_reference:
|
||||
authoritative_connection = execute_query_single(
|
||||
"""
|
||||
SELECT address
|
||||
FROM internet_connections_connections
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(connection_id,),
|
||||
) or {}
|
||||
corrected_service_address = str(authoritative_connection.get("address") or "").strip() or None
|
||||
if not corrected_service_address:
|
||||
connection_id = None
|
||||
if not connection_id and reference:
|
||||
if not service_address:
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = "Mangler serviceadresse til IP-range"
|
||||
else:
|
||||
connection_id = None
|
||||
if not connection_id:
|
||||
matched_by_reference = False
|
||||
skipped_orphan_ip_ranges += 1
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = "Kredsløbsreferencen findes på en anden serviceadresse"
|
||||
continue
|
||||
if not connection_id and reference:
|
||||
connection_id, connection_conflict_reason = (None, None)
|
||||
if service_address:
|
||||
connection_id, connection_conflict_reason = _find_existing_globalconnect_connection_id(reference, service_address)
|
||||
if connection_id:
|
||||
connection_map[reference] = connection_id
|
||||
@ -1856,8 +1915,29 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
|
||||
line_audit[audit_index]["status"] = "skipped"
|
||||
line_audit[audit_index]["reason"] = connection_conflict_reason
|
||||
continue
|
||||
elif not simulate and not reference.startswith("EB"):
|
||||
connection_id = _create_pending_connection_for_ip_reference(line, invoice_number)
|
||||
if connection_id:
|
||||
connection_map[reference] = connection_id
|
||||
created_or_updated_connections += 1
|
||||
created_connections += 1
|
||||
line_audit[audit_index]["created_pending_connection"] = True
|
||||
elif simulate and not reference.startswith("EB"):
|
||||
connection_id = -(len(connection_map) + 1)
|
||||
|
||||
if matched_by_reference:
|
||||
line_audit[audit_index]["matched_by"] = "unique_circuit_reference"
|
||||
if service_address and not _connection_can_host_ip_range(connection_id, service_address):
|
||||
line_audit[audit_index]["address_warning"] = "IP-linjens serviceadresse afviger fra forbindelsen; kredsløbsnummer blev brugt"
|
||||
|
||||
sync_line = dict(line)
|
||||
if corrected_service_address:
|
||||
sync_line["service_address"] = corrected_service_address
|
||||
line_audit[audit_index]["address_warning"] = (
|
||||
f"Fakturaadressen '{service_address}' blev erstattet med kredsløbets adresse "
|
||||
f"'{corrected_service_address}'"
|
||||
)
|
||||
line_audit[audit_index]["service_address_corrected"] = True
|
||||
if existing_range_resolution.get("canonical_reference"):
|
||||
sync_line["provider_reference"] = existing_range_resolution["canonical_reference"]
|
||||
sync_line["circuit_id"] = existing_range_resolution["canonical_reference"]
|
||||
@ -1883,6 +1963,22 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
|
||||
connection_groups=len(grouped_connections),
|
||||
ip_range_candidates=len(ip_range_lines),
|
||||
)
|
||||
case_creation_errors = []
|
||||
if not simulate:
|
||||
try:
|
||||
case_creation_errors = execute_query(
|
||||
"""SELECT connection_id, last_error AS error
|
||||
FROM internet_connection_change_cases
|
||||
WHERE source_type='globalconnect_invoice' AND source_key=%s
|
||||
AND last_error IS NOT NULL
|
||||
ORDER BY connection_id""",
|
||||
(str(invoice_number),),
|
||||
) or []
|
||||
except Exception as exc:
|
||||
# The invoice still completes during staggered migration rollout.
|
||||
logger.warning("Could not load internet change-case control report: %s", exc)
|
||||
verification["change_case_errors"] = case_creation_errors
|
||||
verification["requires_manual_review"] = bool(case_creation_errors) or bool(verification.get("requires_manual_review"))
|
||||
|
||||
return {
|
||||
"skipped": False,
|
||||
@ -1897,6 +1993,7 @@ def _sync_globalconnect_extraction_to_internet_impl(extraction_row: Dict, simula
|
||||
"line_audit": line_audit,
|
||||
"skipped_items": [entry for entry in line_audit if entry["status"] == "skipped"],
|
||||
"verification": verification,
|
||||
"change_case_errors": case_creation_errors,
|
||||
}
|
||||
|
||||
|
||||
@ -2147,6 +2244,29 @@ def _smart_extract_lines(text: str) -> List[Dict]:
|
||||
while i < len(lines_arr):
|
||||
line = lines_arr[i].strip()
|
||||
|
||||
# Attach common continuation metadata to the preceding product row.
|
||||
if items:
|
||||
ean_match = re.match(r'^EAN\s*:\s*(\d{8,14})\s*$', line, re.IGNORECASE)
|
||||
kn8_match = re.match(r'^KN8\s*:\s*(\d{6,10})\s*$', line, re.IGNORECASE)
|
||||
serial_match = re.match(r'^S/N\s*:\s*(\S+)\s*$', line, re.IGNORECASE)
|
||||
if ean_match:
|
||||
items[-1]['ean'] = ean_match.group(1)
|
||||
i += 1
|
||||
continue
|
||||
if kn8_match:
|
||||
items[-1]['kn8'] = kn8_match.group(1)
|
||||
i += 1
|
||||
continue
|
||||
if serial_match:
|
||||
items[-1].setdefault('serial_numbers', []).append(serial_match.group(1))
|
||||
i += 1
|
||||
continue
|
||||
if re.match(r'^Omvendt betalingspligt', line, re.IGNORECASE):
|
||||
items[-1]['reverse_charge'] = True
|
||||
items[-1]['vat_rate'] = 0.0
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Skip empty or header lines
|
||||
if not line or re.search(r'(Position|Varenr|Beskrivelse|Antal|Pris|Total|Model)', line, re.IGNORECASE):
|
||||
i += 1
|
||||
@ -2155,16 +2275,25 @@ def _smart_extract_lines(text: str) -> List[Dict]:
|
||||
# Pattern 1: pdfplumber layout mode - " 1 95006 Betalingsmetode... 1 41,20 41,20"
|
||||
# Whitespace-separated columns: position item_number description quantity unit_price total_price
|
||||
# Most specific pattern - try first!
|
||||
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)
|
||||
amount_pattern = r'-?(?:\d{1,3}(?:[. ]\d{3})+|\d+),\d{2}|-?(?:\d{1,3}(?:,\d{3})+|\d+)\.\d{2}'
|
||||
layout_match = re.match(
|
||||
rf'^\s*(\d{{1,3}})\s+([A-Z0-9][A-Z0-9._/-]{{2,29}})\s+(.+?)\s+(\d{{1,3}}(?:[.,]\d+)?)\s+({amount_pattern})\s+({amount_pattern})\s*$',
|
||||
line,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if layout_match:
|
||||
unit_price = _parse_invoice_number(layout_match.group(5))
|
||||
line_total = _parse_invoice_number(layout_match.group(6))
|
||||
items.append({
|
||||
'line_number': len(items) + 1,
|
||||
'position': layout_match.group(1),
|
||||
'item_number': layout_match.group(2),
|
||||
'sku': layout_match.group(2),
|
||||
'description': layout_match.group(3).strip(),
|
||||
'quantity': layout_match.group(4),
|
||||
'unit_price': layout_match.group(5).replace(' ', '').replace(',', '.'),
|
||||
'total_price': layout_match.group(6).replace(' ', '').replace(',', '.'),
|
||||
'quantity': _parse_invoice_number(layout_match.group(4)),
|
||||
'unit_price': unit_price,
|
||||
'line_total': line_total,
|
||||
'total_price': line_total,
|
||||
'raw_text': line
|
||||
})
|
||||
logger.info(f"✅ pdfplumber layout: {layout_match.group(2)} - {layout_match.group(3)[:30]}...")
|
||||
@ -2269,6 +2398,130 @@ def _smart_extract_lines(text: str) -> List[Dict]:
|
||||
return items
|
||||
|
||||
|
||||
def _parse_invoice_number(value):
|
||||
"""Parse common Danish and English invoice number formats safely."""
|
||||
if value is None or value == '':
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float, Decimal)):
|
||||
return float(value)
|
||||
cleaned = re.sub(r'[^0-9,.-]', '', str(value)).strip('.,')
|
||||
if not cleaned:
|
||||
return None
|
||||
if ',' in cleaned and '.' in cleaned:
|
||||
cleaned = cleaned.replace('.', '').replace(',', '.') if cleaned.rfind(',') > cleaned.rfind('.') else cleaned.replace(',', '')
|
||||
elif ',' in cleaned:
|
||||
cleaned = cleaned.replace(',', '.')
|
||||
elif cleaned.count('.') > 1 or ('.' in cleaned and len(cleaned.rsplit('.', 1)[1]) == 3):
|
||||
cleaned = cleaned.replace('.', '')
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_invoice_totals(text: str) -> Dict:
|
||||
"""Find explicitly labelled totals without guessing from unrelated amounts."""
|
||||
labels = {
|
||||
'subtotal': r'(?:Varebeløb|Subtotal|Netto(?:beløb)?)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
|
||||
'vat_basis': r'(?:Momsgrundlag|VAT basis)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
|
||||
'vat_amount': r'(?:Moms(?:beløb)?|VAT amount)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
|
||||
'total_amount': r'(?:Totalbeløb(?:\s+DKK)?|I alt(?:\s+DKK)?|Amount due)\s+(?:DKK\s*)?(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})',
|
||||
}
|
||||
result = {}
|
||||
compact = re.sub(r'[ \t]+', ' ', text or '')
|
||||
summary_row = re.search(
|
||||
r'Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*'
|
||||
r'(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+(-?[\d.\s]+,\d{2})\s+'
|
||||
r'[\d.,]+%\s+(-?[\d.\s]+,\d{2})',
|
||||
compact,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if summary_row:
|
||||
result.update({
|
||||
'subtotal': _parse_invoice_number(summary_row.group(1)),
|
||||
'vat_basis': _parse_invoice_number(summary_row.group(2)),
|
||||
'vat_amount': _parse_invoice_number(summary_row.group(3)),
|
||||
'total_amount': _parse_invoice_number(summary_row.group(4)),
|
||||
})
|
||||
for key, pattern in labels.items():
|
||||
if key in result:
|
||||
continue
|
||||
matches = list(re.finditer(pattern, compact, re.IGNORECASE))
|
||||
if matches:
|
||||
result[key] = _parse_invoice_number(matches[-1].group(1))
|
||||
return result
|
||||
|
||||
|
||||
def _hybrid_validate_invoice(extracted: Dict, text: str) -> Dict:
|
||||
"""Cross-check parser and AI output and attach actionable review metadata."""
|
||||
result = dict(extracted or {})
|
||||
ai_lines = result.get('lines') if isinstance(result.get('lines'), list) else []
|
||||
parsed_lines = _smart_extract_lines(text or '')
|
||||
if len(parsed_lines) > len(ai_lines):
|
||||
result['lines'] = parsed_lines
|
||||
selected_lines = parsed_lines
|
||||
line_source = 'layout_text'
|
||||
else:
|
||||
selected_lines = ai_lines
|
||||
line_source = 'ai_or_template'
|
||||
|
||||
totals = _extract_invoice_totals(text or '')
|
||||
for key in ('total_amount', 'vat_amount'):
|
||||
current = _parse_invoice_number(result.get(key))
|
||||
if current is None and totals.get(key) is not None:
|
||||
result[key] = totals[key]
|
||||
elif current is not None:
|
||||
result[key] = current
|
||||
|
||||
warnings = []
|
||||
invalid_rows = []
|
||||
line_sum = 0.0
|
||||
for index, line in enumerate(selected_lines, start=1):
|
||||
if not isinstance(line, dict):
|
||||
invalid_rows.append(index)
|
||||
continue
|
||||
quantity = _parse_invoice_number(line.get('quantity'))
|
||||
unit_price = _parse_invoice_number(line.get('unit_price'))
|
||||
line_total = _parse_invoice_number(line.get('line_total', line.get('total_price')))
|
||||
if line_total is not None:
|
||||
line_sum += line_total
|
||||
if quantity is not None and unit_price is not None and line_total is not None:
|
||||
if abs((quantity * unit_price) - line_total) > 0.05:
|
||||
invalid_rows.append(index)
|
||||
|
||||
subtotal = totals.get('subtotal')
|
||||
vat_basis = totals.get('vat_basis')
|
||||
vat_amount = totals.get('vat_amount', _parse_invoice_number(result.get('vat_amount')))
|
||||
total_amount = totals.get('total_amount', _parse_invoice_number(result.get('total_amount')))
|
||||
if invalid_rows:
|
||||
warnings.append(f"Kontrollér beregningen på varelinje(r): {', '.join(map(str, invalid_rows))}")
|
||||
if selected_lines and subtotal is not None and abs(line_sum - subtotal) > 0.05:
|
||||
warnings.append(f"Varelinjer summerer til {line_sum:.2f}, men varebeløbet er {subtotal:.2f}")
|
||||
if subtotal is not None and vat_amount is not None and total_amount is not None and abs((subtotal + vat_amount) - total_amount) > 0.05:
|
||||
warnings.append(f"Varebeløb + moms stemmer ikke med totalen ({subtotal:.2f} + {vat_amount:.2f} != {total_amount:.2f})")
|
||||
if vat_basis is not None and vat_amount is not None and abs((vat_basis * 0.25) - vat_amount) > 0.05:
|
||||
warnings.append(f"Moms {vat_amount:.2f} stemmer ikke med 25% af momsgrundlaget {vat_basis:.2f}")
|
||||
if not selected_lines:
|
||||
warnings.append("Ingen sikre varelinjer fundet")
|
||||
|
||||
result['_hybrid_validation'] = {
|
||||
'status': 'needs_review' if warnings else 'validated',
|
||||
'requires_review': bool(warnings),
|
||||
'line_source': line_source,
|
||||
'line_count': len(selected_lines),
|
||||
'line_sum': round(line_sum, 2),
|
||||
'document_totals': totals,
|
||||
'warnings': warnings,
|
||||
'methods': ['layout_text', 'ai'] if ai_lines else ['layout_text', 'template'],
|
||||
}
|
||||
if warnings:
|
||||
result['_validation_warning'] = ' · '.join(warnings)
|
||||
result['_validation_details'] = result['_hybrid_validation']
|
||||
return result
|
||||
|
||||
|
||||
# ========== CRUD OPERATIONS ==========
|
||||
|
||||
@router.get("/supplier-invoices")
|
||||
@ -2741,6 +2994,7 @@ async def get_file_extracted_data(file_id: int):
|
||||
"_validation_warning": llm_json_data.get('_validation_warning'),
|
||||
"_vat_warning": llm_json_data.get('_vat_warning'),
|
||||
"_validation_details": llm_json_data.get('_validation_details'),
|
||||
"_hybrid_validation": llm_json_data.get('_hybrid_validation'),
|
||||
}
|
||||
elif extraction:
|
||||
# Fallback to extraction table columns if no LLM JSON
|
||||
@ -3540,6 +3794,93 @@ async def search_vendor_by_info(request: Dict):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
_OWN_COMPANY_CVRS = {"29522790", "14416285"}
|
||||
_OWN_COMPANY_MARKERS = ("bmc denmark", "lejrvej 39", "3500 værløse")
|
||||
|
||||
|
||||
def _pattern_matches_value(pattern: str, text: str, expected) -> bool:
|
||||
try:
|
||||
match = re.search(pattern, text or "", re.IGNORECASE | re.MULTILINE)
|
||||
if not match or not match.groups():
|
||||
return False
|
||||
actual = str(match.group(1)).strip()
|
||||
if isinstance(expected, (int, float, Decimal)):
|
||||
parsed = _parse_invoice_number(actual)
|
||||
return parsed is not None and abs(parsed - float(expected)) < 0.01
|
||||
return re.sub(r"\D", "", actual) == re.sub(r"\D", "", str(expected)) if re.search(r"\d", str(expected)) else actual == str(expected)
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
|
||||
def _build_template_builder_result(pdf_text: str, ai_result: Optional[Dict] = None, vendor: Optional[Dict] = None) -> Dict:
|
||||
"""Build safe template suggestions; AI may suggest, but text evidence wins."""
|
||||
text = pdf_text or ""
|
||||
result = dict(ai_result or {})
|
||||
|
||||
invoice_pattern = r"(?:Faktura(?:nr\.?|nummer)?|Nummer)\s*[:#]?\s*(\d{4,})"
|
||||
invoice_match = re.search(invoice_pattern, text, re.IGNORECASE | re.MULTILINE)
|
||||
if invoice_match:
|
||||
result["invoice_number"] = {"value": invoice_match.group(1), "pattern": invoice_pattern}
|
||||
|
||||
date_pattern = r"(?:Fakturadato|Dato)\s*[:#]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4})"
|
||||
date_match = re.search(date_pattern, text, re.IGNORECASE | re.MULTILINE)
|
||||
if date_match:
|
||||
result["invoice_date"] = {"value": date_match.group(1), "pattern": date_pattern}
|
||||
|
||||
totals = _extract_invoice_totals(text)
|
||||
total = totals.get("total_amount")
|
||||
total_patterns = [
|
||||
(r"Varebeløb\s+Momsgrundlag\s+Moms\s+Momssats\s+Totalbeløb(?:\s+DKK)?\s*\n\s*"
|
||||
r"-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+-?[\d.\s]+,\d{2}\s+[\d.,]+%\s+(-?[\d.\s]+,\d{2})"),
|
||||
r"(?:Totalbeløb|I alt|Amount due)\s*(?:DKK)?\s*:?\s*(-?[\d.\s]+,\d{2}|-?[\d,]+\.\d{2})",
|
||||
]
|
||||
if total is not None:
|
||||
for pattern in total_patterns:
|
||||
if _pattern_matches_value(pattern, text, total):
|
||||
result["total_amount"] = {"value": f"{total:.2f}", "pattern": pattern}
|
||||
break
|
||||
|
||||
own_cvrs = set(_OWN_COMPANY_CVRS)
|
||||
configured_own_cvr = re.sub(r"\D", "", str(getattr(settings, "OWN_CVR", "")))
|
||||
if configured_own_cvr:
|
||||
own_cvrs.add(configured_own_cvr)
|
||||
vendor_cvr = re.sub(r"\D", "", str((vendor or {}).get("cvr_number") or ""))
|
||||
cvr_candidates = re.findall(r"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*(\d{8})", text, re.IGNORECASE)
|
||||
safe_cvrs = [cvr for cvr in cvr_candidates if cvr not in own_cvrs]
|
||||
selected_cvr = vendor_cvr if vendor_cvr and vendor_cvr in safe_cvrs else (safe_cvrs[0] if safe_cvrs else "")
|
||||
if selected_cvr:
|
||||
cvr_pattern = rf"(?:CVR(?:-?nr\.?)?|VAT(?:\s+no\.?)?)\s*[:#]?\s*(?:DK)?\s*({re.escape(selected_cvr)})"
|
||||
if _pattern_matches_value(cvr_pattern, text, selected_cvr):
|
||||
result["cvr"] = {"value": selected_cvr, "pattern": cvr_pattern}
|
||||
else:
|
||||
result.pop("cvr", None)
|
||||
result.pop("vendor_cvr", None)
|
||||
|
||||
candidates = []
|
||||
vendor_name = str((vendor or {}).get("name") or "").strip()
|
||||
if vendor_name and vendor_name.lower() in text.lower():
|
||||
candidates.append(vendor_name)
|
||||
if selected_cvr:
|
||||
candidates.append(selected_cvr)
|
||||
candidates.extend(re.findall(r"\b[\w.+-]+@([\w.-]+\.[A-Za-z]{2,})\b", text))
|
||||
candidates.extend(re.findall(r"\b(?:www\.)?([A-Za-z0-9-]+\.(?:dk|com|eu|net))\b", text, re.IGNORECASE))
|
||||
safe_patterns = []
|
||||
for candidate in candidates:
|
||||
candidate = str(candidate).strip()
|
||||
lowered = candidate.lower()
|
||||
if not candidate or any(marker in lowered for marker in _OWN_COMPANY_MARKERS) or re.sub(r"\D", "", candidate) in own_cvrs:
|
||||
continue
|
||||
if candidate.lower() not in {item.lower() for item in safe_patterns}:
|
||||
safe_patterns.append(candidate)
|
||||
result["detection_patterns"] = safe_patterns[:5]
|
||||
result["lines_start"] = {"pattern": r"Nr\.?\s+Varenr\.?\s+Tekst"}
|
||||
result["lines_end"] = {"pattern": r"(?:Varebeløb|Subtotal|Totalbeløb)"}
|
||||
line_items = _smart_extract_lines(text)
|
||||
result["line_count"] = len(line_items)
|
||||
result["line_items"] = line_items
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/supplier-invoices/ai/analyze")
|
||||
async def ai_analyze_invoice(request: Dict):
|
||||
"""Brug AI til at analysere faktura og foreslå template felter"""
|
||||
@ -3553,6 +3894,7 @@ async def ai_analyze_invoice(request: Dict):
|
||||
# Build enhanced PDF text with instruction
|
||||
from app.core.config import settings
|
||||
|
||||
excerpt = pdf_text if len(pdf_text) <= 14000 else f"{pdf_text[:7000]}\n[...midt udeladt...]\n{pdf_text[-7000:]}"
|
||||
enhanced_text = f"""OPGAVE: Analyser denne danske faktura og udtræk information til template-generering.
|
||||
|
||||
RETURNER KUN VALID JSON - ingen forklaring, ingen markdown, kun ren JSON!
|
||||
@ -3562,7 +3904,7 @@ REQUIRED STRUKTUR (alle felter skal med):
|
||||
"invoice_number": "5082481",
|
||||
"invoice_date": "24/10-25",
|
||||
"total_amount": "1471.20",
|
||||
"cvr": "29522790",
|
||||
"cvr": "26686091",
|
||||
"detection_patterns": ["DCS ApS", "WWW.DCS.DK", "Høgemosevænget"],
|
||||
"lines_start": "Nr.VarenrTekst",
|
||||
"lines_end": "Subtotal"
|
||||
@ -3593,7 +3935,7 @@ VIGTIGT:
|
||||
- LAD VÆRE med at lave patterns eller line_item regex - kun udtræk rå data
|
||||
|
||||
PDF TEKST:
|
||||
{pdf_text[:2000]}
|
||||
{excerpt}
|
||||
|
||||
RETURNER KUN JSON - intet andet!"""
|
||||
|
||||
@ -3603,10 +3945,21 @@ RETURNER KUN JSON - intet andet!"""
|
||||
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="AI kunne ikke analysere fakturaen")
|
||||
if result.get("error"):
|
||||
# extract_from_text returns operational Ollama failures as a dict.
|
||||
# Do not expose that dict as a successful analysis to the browser.
|
||||
raise HTTPException(status_code=502, detail=str(result["error"]))
|
||||
|
||||
logger.info(f"✅ AI analyse gennemført: {result}")
|
||||
return result
|
||||
vendor = None
|
||||
if vendor_id:
|
||||
rows = execute_query("SELECT id, name, cvr_number FROM vendors WHERE id = %s LIMIT 1", (vendor_id,))
|
||||
vendor = rows[0] if rows else None
|
||||
safe_result = _build_template_builder_result(pdf_text, result, vendor)
|
||||
logger.info("✅ AI analyse gennemført og valideret mod fakturateksten")
|
||||
return safe_result
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ AI analyse fejlede: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI analyse fejlede: {str(e)}")
|
||||
@ -4813,6 +5166,7 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
if template_id and confidence >= 0.5:
|
||||
logger.info(f"✅ Matched template {template_id} ({confidence:.0%})")
|
||||
extracted_fields = template_service.extract_fields(text, template_id)
|
||||
extracted_fields = _hybrid_validate_invoice(extracted_fields, text)
|
||||
|
||||
# Check if this is an invoice2data template (ID -1)
|
||||
is_invoice2data = (template_id == -1)
|
||||
@ -4889,7 +5243,7 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
extracted_fields.get('currency', 'DKK'),
|
||||
1.0, # invoice2data always 100% confidence
|
||||
json.dumps(extracted_fields), # llm_response_json
|
||||
'extracted') # status
|
||||
'needs_review' if extracted_fields.get('_hybrid_validation', {}).get('requires_review') else 'validated')
|
||||
)
|
||||
|
||||
# Insert line items if extracted
|
||||
@ -4964,7 +5318,8 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
|
||||
raise HTTPException(status_code=500, detail=f"AI extraction fejlede: {error_msg}")
|
||||
|
||||
extracted_fields = llm_result
|
||||
extracted_fields = _hybrid_validate_invoice(llm_result, text)
|
||||
llm_result = extracted_fields
|
||||
confidence = llm_result.get('confidence', 0.75)
|
||||
|
||||
# Post-process: clear own CVR(s) if AI mistakenly returned them
|
||||
@ -5025,7 +5380,7 @@ async def reprocess_uploaded_file(file_id: int):
|
||||
llm_result.get('document_type', 'invoice'),
|
||||
confidence,
|
||||
json.dumps(llm_result),
|
||||
'extracted')
|
||||
'needs_review' if llm_result.get('_hybrid_validation', {}).get('requires_review') else 'validated')
|
||||
)
|
||||
|
||||
# Insert line items if extracted
|
||||
|
||||
@ -2916,7 +2916,9 @@ async function reviewExtractedData(fileId) {
|
||||
let aiData = null;
|
||||
if (ext.llm_response_json) {
|
||||
try {
|
||||
aiData = JSON.parse(ext.llm_response_json);
|
||||
aiData = typeof ext.llm_response_json === 'string'
|
||||
? JSON.parse(ext.llm_response_json)
|
||||
: ext.llm_response_json;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse llm_response_json:', e);
|
||||
}
|
||||
@ -2929,8 +2931,14 @@ async function reviewExtractedData(fileId) {
|
||||
.trim();
|
||||
|
||||
// Check for validation warnings
|
||||
const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
|
||||
const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
|
||||
const hybridValidation = aiData?._hybrid_validation || null;
|
||||
const hybridWarnings = hybridValidation?.warnings || [];
|
||||
const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning;
|
||||
const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0);
|
||||
const sourceLabels = {
|
||||
layout_text: 'PDF-layout',
|
||||
ai_or_template: 'AI/template'
|
||||
};
|
||||
|
||||
// Build modal content
|
||||
let modalContent = `
|
||||
@ -2946,14 +2954,15 @@ async function reviewExtractedData(fileId) {
|
||||
|
||||
${hasValidationIssues ? `
|
||||
<div class="alert alert-warning">
|
||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
|
||||
${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
|
||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6>
|
||||
${hybridWarnings.map(warning => `<p class="mb-1">⚠️ ${escapeHtml(warning)}</p>`).join('')}
|
||||
${!hybridWarnings.length && aiData._validation_warning ? `<p class="mb-1">⚠️ ${escapeHtml(aiData._validation_warning)}</p>` : ''}
|
||||
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
|
||||
</div>
|
||||
` : allValidationsPassed ? `
|
||||
<div class="alert alert-success">
|
||||
<h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
|
||||
<p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
|
||||
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6>
|
||||
<p class="mb-0">${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
|
||||
@ -207,6 +207,8 @@
|
||||
3. Systemet laver automatisk patterns!
|
||||
</div>
|
||||
|
||||
<div id="aiLineItemsResult" class="d-none mb-3"></div>
|
||||
|
||||
<button class="btn btn-success w-100 mb-3" onclick="autoGenerateTemplate()" id="aiGenerateBtn">
|
||||
<i class="bi bi-magic me-2"></i>🤖 AI Auto-generer Template
|
||||
</button>
|
||||
@ -1370,10 +1372,20 @@ async function autoGenerateTemplate() {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('AI analyse fejlede');
|
||||
let detail = `AI analyse fejlede (HTTP ${response.status})`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
if (payload?.detail) detail = payload.detail;
|
||||
} catch (_) {
|
||||
// Keep the HTTP status when the server did not return JSON.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
// Helper to extract value (handles both nested {value, pattern} and flat formats)
|
||||
const getValue = (field) => field?.value || field;
|
||||
@ -1456,6 +1468,7 @@ async function autoGenerateTemplate() {
|
||||
const linesEnd = result.lines_end?.pattern || result.lines_end;
|
||||
document.getElementById('linesEndPattern').value = linesEnd;
|
||||
}
|
||||
renderAiLineItems(result.line_items || [], result.line_count || 0);
|
||||
// Note: Intentionally NOT setting line_pattern - multi-line extraction handles it
|
||||
|
||||
btn.innerHTML = '<i class="bi bi-check-circle me-2"></i>✅ AI analyse færdig!';
|
||||
@ -1468,10 +1481,61 @@ async function autoGenerateTemplate() {
|
||||
console.error('AI generation failed:', error);
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
alert('AI analyse fejlede. Prøv manuel indtastning.');
|
||||
alert(`AI analyse fejlede: ${error.message || 'Ukendt fejl'}`);
|
||||
}
|
||||
}
|
||||
|
||||
function templateEscapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderAiLineItems(lines, reportedCount) {
|
||||
const container = document.getElementById('aiLineItemsResult');
|
||||
if (!container) return;
|
||||
|
||||
const count = Array.isArray(lines) ? lines.length : Number(reportedCount || 0);
|
||||
if (!count) {
|
||||
container.className = 'alert alert-warning mb-3';
|
||||
container.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i><strong>Ingen varelinjer fundet.</strong> Kontrollér teksten eller angiv et linjemønster manuelt.';
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = {
|
||||
line_number: 'Linje', position: 'Position', item_number: 'Varenummer', sku: 'SKU',
|
||||
description: 'Beskrivelse / model', quantity: 'Antal', unit_price: 'Stykpris',
|
||||
line_total: 'Linjebeløb', total_price: 'Samlet beløb', ean: 'EAN', kn8: 'KN8',
|
||||
serial_numbers: 'Serienummer', reverse_charge: 'Omvendt betalingspligt',
|
||||
vat_rate: 'Momssats', raw_text: 'Rå tekst'
|
||||
};
|
||||
const itemCards = (lines || []).map((line, index) => {
|
||||
const fields = Object.entries(line)
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
.map(([key, value]) => {
|
||||
let displayValue = Array.isArray(value) ? value.join(', ') : value;
|
||||
if (typeof value === 'boolean') displayValue = value ? 'Ja' : 'Nej';
|
||||
return `<div class="col-md-6 col-xl-4 mb-2">
|
||||
<div class="small text-muted">${templateEscapeHtml(labels[key] || key)}</div>
|
||||
<div class="text-break">${templateEscapeHtml(displayValue)}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const heading = line.item_number || line.sku || `Linje ${index + 1}`;
|
||||
return `<div class="card mb-2">
|
||||
<div class="card-header py-2 fw-semibold">Vare ${index + 1}: ${templateEscapeHtml(heading)}</div>
|
||||
<div class="card-body py-2"><div class="row">${fields}</div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.className = 'alert alert-success mb-3 p-3';
|
||||
container.innerHTML = `
|
||||
<div class="fw-bold mb-2"><i class="bi bi-check-circle me-2"></i>AI fandt ${count} varelinjer</div>
|
||||
<div>${itemCards}</div>`;
|
||||
}
|
||||
|
||||
async function saveTemplate() {
|
||||
const vendorId = document.getElementById('vendorSelect').value;
|
||||
const templateName = document.getElementById('templateName').value;
|
||||
|
||||
@ -94,17 +94,18 @@ def _rate_limit(user_id: int) -> None:
|
||||
|
||||
|
||||
def _resolve_customer_id() -> int:
|
||||
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
||||
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,))
|
||||
if configured_row:
|
||||
return int(configured_row["id"])
|
||||
|
||||
# Resolve our own company by identity, never by an arbitrary/old numeric ID.
|
||||
# Customer IDs differ between environments and ID 1 may belong to a supplier.
|
||||
named_row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE LOWER(name) = LOWER(%s)
|
||||
ORDER BY id ASC
|
||||
SELECT c.id
|
||||
FROM customers c
|
||||
LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
|
||||
WHERE LOWER(TRIM(c.name)) = LOWER(%s)
|
||||
AND c.deleted_at IS NULL
|
||||
AND COALESCE(c.is_active, TRUE) = TRUE
|
||||
GROUP BY c.id
|
||||
ORDER BY COUNT(s.id) DESC, c.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
("BMC Networks",),
|
||||
@ -112,11 +113,23 @@ def _resolve_customer_id() -> int:
|
||||
if named_row:
|
||||
return int(named_row["id"])
|
||||
|
||||
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1")
|
||||
if fallback:
|
||||
return int(fallback["id"])
|
||||
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
|
||||
configured_row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
AND (
|
||||
LOWER(name) LIKE '%%bmc networks%%'
|
||||
OR REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ('29522790', '14416285')
|
||||
)
|
||||
""",
|
||||
(configured_id,),
|
||||
)
|
||||
if configured_row:
|
||||
return int(configured_row["id"])
|
||||
|
||||
raise HTTPException(status_code=400, detail="No customers available for bug report case creation")
|
||||
raise HTTPException(status_code=500, detail="BMC Networks-kunden blev ikke fundet; bugrapporten blev ikke oprettet")
|
||||
|
||||
|
||||
@router.post("/bug-reports", response_model=BugReportResult)
|
||||
@ -176,8 +189,10 @@ async def create_bug_report(payload: BugReportPayload, request: Request):
|
||||
if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
|
||||
raise HTTPException(status_code=400, detail="Screenshot too large")
|
||||
|
||||
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png")
|
||||
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name)
|
||||
extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
|
||||
filename = f"screenshot.{extension}"
|
||||
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.{extension}")
|
||||
_create_sag_file_record(sag_id, filename, content_type, size, stored_name)
|
||||
|
||||
# Attach logs as json file
|
||||
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")
|
||||
|
||||
@ -37,6 +37,10 @@ class Settings(BaseSettings):
|
||||
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
||||
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
|
||||
|
||||
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
|
||||
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
|
||||
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
|
||||
|
||||
# Elnet supplier lookup
|
||||
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
|
||||
ELNET_TIMEOUT_SECONDS: int = 12
|
||||
@ -119,6 +123,9 @@ class Settings(BaseSettings):
|
||||
# Ollama LLM
|
||||
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
|
||||
OLLAMA_MODEL: str = "llama3.2"
|
||||
# Primary company CVR used to prevent AI invoice extraction from treating
|
||||
# the buyer (BMC) as the supplier.
|
||||
OWN_CVR: str = "29522790"
|
||||
|
||||
# Email System Configuration
|
||||
# IMAP Settings
|
||||
@ -225,6 +232,8 @@ class Settings(BaseSettings):
|
||||
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
|
||||
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
|
||||
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
|
||||
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
|
||||
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
|
||||
|
||||
# Backup System Configuration
|
||||
BACKUP_ENABLED: bool = True
|
||||
|
||||
59
app/core/economic_write_policy.py
Normal file
59
app/core/economic_write_policy.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""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',
|
||||
)
|
||||
@ -891,7 +891,7 @@
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
||||
<i class="bi bi-chat-left-text"></i>Kontakt
|
||||
<i class="bi bi-chat-left-text"></i>Kommunikation
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
@ -1241,11 +1241,14 @@
|
||||
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Kontakt Tab -->
|
||||
<!-- Kommunikation Tab -->
|
||||
<div class="tab-pane fade" id="kontakt">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">Kontakt historik</h5>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0">Kommunikationshistorik</h5>
|
||||
<small class="text-muted">Opkald og SMS’er 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" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
|
||||
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>
|
||||
|
||||
@ -488,6 +488,11 @@
|
||||
|
||||
const fetchJson = async (url, options = {}) => {
|
||||
const res = await fetch(url, options);
|
||||
if (res.status === 401) {
|
||||
const next = `${window.location.pathname}${window.location.search}`;
|
||||
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
|
||||
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
let message = text || `HTTP ${res.status}`;
|
||||
@ -525,7 +530,7 @@
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
const config = await fetchJson('/api/v1/also/config');
|
||||
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' });
|
||||
const label = !config.enabled
|
||||
? 'ALSO deaktiveret'
|
||||
: config.read_only
|
||||
@ -667,10 +672,27 @@
|
||||
renderJobLines();
|
||||
};
|
||||
|
||||
const renderCustomerSearchResults = (lineId) => {
|
||||
const results = state.lineCustomerSearchResults[lineId] || [];
|
||||
const query = state.lineCustomerSearchQueries[lineId] || '';
|
||||
return results.length
|
||||
? results.map(customer => `
|
||||
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
|
||||
<strong>${escapeHtml(customer.name)}</strong>
|
||||
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
|
||||
</button>
|
||||
`).join('')
|
||||
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
|
||||
};
|
||||
|
||||
const updateCustomerSearchResults = (lineId) => {
|
||||
const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`);
|
||||
if (host) host.innerHTML = renderCustomerSearchResults(lineId);
|
||||
};
|
||||
|
||||
const renderMapEditor = (line) => {
|
||||
const lineId = Number(line.id);
|
||||
const selected = state.lineCustomerSelections[lineId];
|
||||
const results = state.lineCustomerSearchResults[lineId] || [];
|
||||
const query = state.lineCustomerSearchQueries[lineId] || '';
|
||||
|
||||
return `
|
||||
@ -694,16 +716,8 @@
|
||||
` : `
|
||||
<div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
|
||||
`}
|
||||
<div class="map-results mb-2">
|
||||
${results.length
|
||||
? results.map(customer => `
|
||||
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
|
||||
<strong>${escapeHtml(customer.name)}</strong>
|
||||
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
|
||||
</button>
|
||||
`).join('')
|
||||
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
|
||||
}
|
||||
<div class="map-results mb-2" data-company-results-line-id="${lineId}">
|
||||
${renderCustomerSearchResults(lineId)}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
|
||||
@ -715,10 +729,27 @@
|
||||
`;
|
||||
};
|
||||
|
||||
const renderProductSearchResults = (lineId) => {
|
||||
const results = state.lineProductSearchResults[lineId] || [];
|
||||
const query = state.lineProductSearchQueries[lineId] || '';
|
||||
return results.length
|
||||
? results.map(product => `
|
||||
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
|
||||
<strong>${escapeHtml(product.name)}</strong>
|
||||
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
|
||||
</button>
|
||||
`).join('')
|
||||
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
|
||||
};
|
||||
|
||||
const updateProductSearchResults = (lineId) => {
|
||||
const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`);
|
||||
if (host) host.innerHTML = renderProductSearchResults(lineId);
|
||||
};
|
||||
|
||||
const renderProductMapEditor = (line) => {
|
||||
const lineId = Number(line.id);
|
||||
const selected = state.lineProductSelections[lineId];
|
||||
const results = state.lineProductSearchResults[lineId] || [];
|
||||
const query = state.lineProductSearchQueries[lineId] || '';
|
||||
|
||||
return `
|
||||
@ -742,16 +773,8 @@
|
||||
` : `
|
||||
<div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
|
||||
`}
|
||||
<div class="map-results mb-2">
|
||||
${results.length
|
||||
? results.map(product => `
|
||||
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
|
||||
<strong>${escapeHtml(product.name)}</strong>
|
||||
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
|
||||
</button>
|
||||
`).join('')
|
||||
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
|
||||
}
|
||||
<div class="map-results mb-2" data-product-results-line-id="${lineId}">
|
||||
${renderProductSearchResults(lineId)}
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
|
||||
@ -819,13 +842,14 @@
|
||||
state.lineCustomerSearchQueries[lineId] = query;
|
||||
if ((query || '').trim().length < 2) {
|
||||
state.lineCustomerSearchResults[lineId] = [];
|
||||
renderJobLines();
|
||||
updateCustomerSearchResults(lineId);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
|
||||
if (state.lineCustomerSearchQueries[lineId] !== query) return;
|
||||
state.lineCustomerSearchResults[lineId] = rows || [];
|
||||
renderJobLines();
|
||||
updateCustomerSearchResults(lineId);
|
||||
};
|
||||
|
||||
const saveCompanyMapping = async (lineId) => {
|
||||
@ -868,13 +892,14 @@
|
||||
state.lineProductSearchQueries[lineId] = query;
|
||||
if ((query || '').trim().length < 2) {
|
||||
state.lineProductSearchResults[lineId] = [];
|
||||
renderJobLines();
|
||||
updateProductSearchResults(lineId);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
|
||||
if (state.lineProductSearchQueries[lineId] !== query) return;
|
||||
state.lineProductSearchResults[lineId] = rows || [];
|
||||
renderJobLines();
|
||||
updateProductSearchResults(lineId);
|
||||
};
|
||||
|
||||
const saveProductMapping = async (lineId) => {
|
||||
|
||||
@ -15,6 +15,7 @@ from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
|
||||
from app.utils.safe_html import sanitize_safe_html
|
||||
from app.services.email_processor_service import EmailProcessorService
|
||||
from app.services.email_service import EmailService
|
||||
from app.services.email_workflow_service import email_workflow_service
|
||||
from app.services.ollama_service import ollama_service
|
||||
from app.services.simple_classifier import simple_classifier
|
||||
@ -2262,6 +2263,37 @@ async def reprocess_email(email_id: int):
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/{email_id}/recover-attachments")
|
||||
async def recover_email_attachments(email_id: int):
|
||||
"""Recover a missing Graph attachment, then run the normal email workflow.
|
||||
|
||||
Intended for the old metadata-only attachment imports. It is safe to retry:
|
||||
the normal invoice checksum and workflow safeguards remain in force.
|
||||
"""
|
||||
try:
|
||||
recovery = await EmailService().recover_graph_attachments(email_id)
|
||||
if not recovery.get("success"):
|
||||
raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
|
||||
|
||||
email_rows = execute_query(
|
||||
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
|
||||
)
|
||||
if not email_rows:
|
||||
raise HTTPException(status_code=404, detail="Email not found after recovery")
|
||||
processing = await EmailProcessorService().process_single_email(email_rows[0])
|
||||
return {
|
||||
"success": True,
|
||||
**recovery,
|
||||
"workflows_executed": processing.get("workflows_executed", 0),
|
||||
"awaiting_user_action": processing.get("awaiting_user_action", False),
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("❌ Error recovering email attachments for %s", email_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/emails/process")
|
||||
async def process_emails(
|
||||
limit: Optional[int] = Query(default=None, ge=1, le=500),
|
||||
@ -2379,10 +2411,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
|
||||
logger.info(f"💾 Saved to database with ID: {email_id}")
|
||||
|
||||
# Log activity
|
||||
activity_logger.log_fetched(
|
||||
await activity_logger.log_fetched(
|
||||
email_id=email_id,
|
||||
source="manual_upload",
|
||||
metadata={"filename": file.filename}
|
||||
message_id=email_data.get("message_id", file.filename)
|
||||
)
|
||||
|
||||
# Auto-classify
|
||||
|
||||
@ -77,6 +77,79 @@
|
||||
background: color-mix(in srgb, var(--accent, #0f4c75) 5%, var(--bg-card));
|
||||
}
|
||||
|
||||
.emails-v2-flow-picker {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.emails-v2-flow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(150px, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice {
|
||||
position: relative;
|
||||
min-height: 74px;
|
||||
padding: 0.72rem 0.8rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: #52606d;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
box-shadow: 0 2px 5px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice:hover:not(:disabled) {
|
||||
filter: brightness(1.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice.recommended {
|
||||
border: 3px solid #fff;
|
||||
outline: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice.flow-support { background: #1769aa; }
|
||||
.emails-v2-flow-choice.flow-invoice { background: #18864b; }
|
||||
.emails-v2-flow-choice.flow-accounting { background: #d97706; }
|
||||
.emails-v2-flow-choice.flow-archive { background: #59636e; }
|
||||
|
||||
.emails-v2-flow-choice:disabled {
|
||||
opacity: 0.42;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice .flow-title {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice .flow-description {
|
||||
display: block;
|
||||
margin-top: 0.22rem;
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.emails-v2-flow-choice .flow-badge {
|
||||
position: absolute;
|
||||
top: -0.55rem;
|
||||
right: 0.45rem;
|
||||
padding: 0.12rem 0.42rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 1500px) {
|
||||
.emails-v2-flow-grid {
|
||||
grid-template-columns: repeat(2, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.email-ai-decision {
|
||||
border: 2px solid color-mix(in srgb, var(--accent, #0f4c75) 42%, var(--border-color));
|
||||
border-radius: 12px;
|
||||
@ -1074,7 +1147,19 @@
|
||||
? 'Høj sikkerhed'
|
||||
: confidencePercent >= 55 ? 'Middel sikkerhed' : 'Lav sikkerhed – kontrollér manuelt';
|
||||
|
||||
const email = state.selectedEmail || {};
|
||||
const isApprovedSupplierInvoice = classification === 'invoice' && Boolean(email.supplier_id);
|
||||
|
||||
const effects = [];
|
||||
if (isApprovedSupplierInvoice) {
|
||||
effects.push(
|
||||
'Beholder tilknytningen til den valgte leverandør',
|
||||
'Henter PDF-bilaget og sender det til Leverandørfakturaer',
|
||||
'Aflæser fakturanummer, beløb og varelinjer',
|
||||
'Opdaterer Internetforbindelser automatisk, når fakturaen er fra GlobalConnect',
|
||||
'Markerer emailen som behandlet'
|
||||
);
|
||||
} else {
|
||||
(preview.system_matches || []).filter((row) => row.matches).forEach((row) => {
|
||||
if (row.effect) effects.push(row.effect);
|
||||
});
|
||||
@ -1084,42 +1169,36 @@
|
||||
WORKFLOW_ACTION_LABELS[action] || `Kører handlingen “${action}”`
|
||||
));
|
||||
});
|
||||
}
|
||||
const uniqueEffects = [...new Set(effects)];
|
||||
const hasActions = uniqueEffects.length > 0;
|
||||
const automaticExecution = Boolean(preview.automatic_execution);
|
||||
|
||||
host.innerHTML = `
|
||||
<div class="small text-uppercase fw-bold text-muted mb-1">Systemets vurdering</div>
|
||||
<div class="small text-uppercase fw-bold text-muted mb-1">Anbefalet behandling</div>
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<div class="email-ai-type">${escapeHtml(typeLabel)}</div>
|
||||
<div class="d-flex justify-content-between small mt-2 mb-1">
|
||||
<span>${escapeHtml(confidenceLabel)}</span>
|
||||
<strong>${confidencePercent}%</strong>
|
||||
<span class="badge ${confidencePercent >= 80 ? 'bg-success' : confidencePercent >= 55 ? 'bg-warning text-dark' : 'bg-secondary'}">${confidencePercent}%</span>
|
||||
</div>
|
||||
<div class="email-ai-confidence mb-3"><span style="width:${confidencePercent}%"></span></div>
|
||||
<div class="small text-muted mt-1">${escapeHtml(confidenceLabel)}. Vælg flowet øverst på siden.</div>
|
||||
<div class="mt-3">
|
||||
<div class="small fw-semibold mb-2">Vurdering og handlinger</div>
|
||||
<div class="mt-3">
|
||||
${renderIdentityAssessment()}
|
||||
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Når du godkender, gør systemet dette:'}</div>
|
||||
<div class="fw-semibold small mb-2">${automaticExecution ? 'Dette konkursflow køres automatisk:' : 'Flowet udfører:'}</div>
|
||||
${hasActions ? `
|
||||
<ol class="email-ai-actions mb-3">
|
||||
<ol class="email-ai-actions mb-2">
|
||||
${uniqueEffects.map((effect) => `<li>${escapeHtml(effect)}</li>`).join('')}
|
||||
</ol>
|
||||
${automaticExecution ? `
|
||||
<div class="alert alert-danger py-2 small mb-0">
|
||||
<i class="bi bi-lightning-charge-fill me-1"></i>
|
||||
Automatisk sikkerhedsflow. Der handles kun ved eksakt CVR-match.
|
||||
</div>` : `
|
||||
<button id="v2ApproveActions" class="btn btn-success w-100">
|
||||
<i class="bi bi-check-circle me-2"></i>Godkend og udfør
|
||||
</button>`}
|
||||
` : `
|
||||
<div class="alert alert-secondary py-2 small mb-0">
|
||||
Ingen automatiske handlinger foreslås. Vælg selv “Opret sag” eller tilknyt en eksisterende sag nedenfor.
|
||||
</div>` : ''}
|
||||
` : '<div class="small text-muted">Ingen automatiske handlinger foreslås.</div>'}
|
||||
</div>
|
||||
`}
|
||||
<div class="small text-muted mt-2 ${automaticExecution ? 'd-none' : ''}">
|
||||
Godkend-knappen udfører kun de handlinger, der er vist ovenfor.
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('v2ApproveActions')?.addEventListener('click', approveSuggestedActions);
|
||||
bindIdentityDecisionActions();
|
||||
}
|
||||
|
||||
@ -1129,7 +1208,12 @@
|
||||
button.disabled = true;
|
||||
button.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Udfører…';
|
||||
try {
|
||||
const classification = String(state.workflowPreview?.email?.classification || '').toLowerCase();
|
||||
if (classification === 'invoice' && state.selectedEmail?.supplier_id) {
|
||||
await runQuickAction('create_supplier_invoice', button);
|
||||
} else {
|
||||
await executeWorkflowsCurrent();
|
||||
}
|
||||
} finally {
|
||||
if (document.body.contains(button)) {
|
||||
button.disabled = false;
|
||||
@ -1158,6 +1242,8 @@
|
||||
const matching = Array.isArray(preview.matching_workflows) ? preview.matching_workflows : [];
|
||||
const system = Array.isArray(preview.system_matches) ? preview.system_matches : [];
|
||||
const emailMeta = preview.email || {};
|
||||
const usesSupplierApprovalFlow = String(emailMeta.classification || '').toLowerCase() === 'invoice'
|
||||
&& Boolean(state.selectedEmail?.supplier_id);
|
||||
|
||||
const systemHtml = system.map((row) => {
|
||||
const badge = row.matches ? '<span class="badge bg-success">Matcher</span>' : '<span class="badge bg-secondary">Springes over</span>';
|
||||
@ -1169,7 +1255,15 @@
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const matchingHtml = matching.length
|
||||
const matchingHtml = usesSupplierApprovalFlow
|
||||
? `<div class="emails-v2-kv">
|
||||
<div class="k">Godkendelsesflow</div>
|
||||
<div class="v">
|
||||
<span class="badge bg-success">Leverandørfaktura</span>
|
||||
<span class="small text-muted">PDF → fakturaudtræk → evt. GlobalConnect Internetforbindelser → behandlet</span>
|
||||
</div>
|
||||
</div>`
|
||||
: matching.length
|
||||
? matching.map((wf) => `
|
||||
<div class="emails-v2-kv">
|
||||
<div class="k">#${Number(wf.id)} ${escapeHtml(wf.name || '')}</div>
|
||||
@ -1604,26 +1698,48 @@
|
||||
const hasCustomer = Boolean(email.customer_id);
|
||||
const hasSupplier = Boolean(email.supplier_id);
|
||||
const hasIdentity = hasCustomer || hasSupplier;
|
||||
const supplierIdentity = `${email.supplier_name || ''} ${email.sender_email || ''}`.toLowerCase();
|
||||
const isGlobalConnect = supplierIdentity.includes('globalconnect');
|
||||
const classification = String(email.classification || '').toLowerCase();
|
||||
const recommendedFlow = classification === 'invoice'
|
||||
? 'create_supplier_invoice'
|
||||
: (classification === 'customer_email' ? 'create_customer_case' : '');
|
||||
const recommendationBadge = (flow) => recommendedFlow === flow
|
||||
? '<span class="flow-badge">ANBEFALET</span>'
|
||||
: '';
|
||||
const recommendationClass = (flow) => recommendedFlow === flow ? ' recommended' : '';
|
||||
|
||||
quickActions.className = 'emails-v2-quick-toolbar';
|
||||
quickActions.innerHTML = email.linked_case_id ? `
|
||||
<a class="btn btn-sm btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||
</a>
|
||||
` : `
|
||||
<div class="emails-v2-quick-grid" aria-label="Hurtig behandling">
|
||||
<button class="btn btn-sm btn-outline-primary text-start" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
|
||||
<i class="bi bi-person-workspace me-1"></i>Kundesag
|
||||
<div class="emails-v2-flow-picker">
|
||||
<div class="fw-semibold mb-2">Vælg hvordan mailen skal behandles</div>
|
||||
<div class="emails-v2-flow-grid" aria-label="Vælg mailflow">
|
||||
<button class="emails-v2-flow-choice flow-support${recommendationClass('create_customer_case')}" data-v2-quick-action="create_customer_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter en almindelig kundesag' : 'Fastslå kunden først'}">
|
||||
${recommendationBadge('create_customer_case')}
|
||||
<span class="flow-title"><i class="bi bi-headset me-1"></i>Support</span>
|
||||
<span class="flow-description">Opret en almindelig kundesag</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-success text-start" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
|
||||
<i class="bi bi-receipt me-1"></i>Leverandørfaktura
|
||||
<button class="emails-v2-flow-choice flow-invoice${recommendationClass('create_supplier_invoice')}" data-v2-quick-action="create_supplier_invoice" ${hasSupplier ? '' : 'disabled'} title="${hasSupplier ? 'Sender fakturabilaget til behandling' : 'Fastslå leverandøren først'}">
|
||||
${recommendationBadge('create_supplier_invoice')}
|
||||
<span class="flow-title"><i class="bi bi-receipt me-1"></i>Leverandørfaktura</span>
|
||||
<span class="flow-description">${isGlobalConnect ? 'Internetfaktura genkendt · opdaterer Internetforbindelser' : 'Aflæs PDF og kør leverandørflowet'}</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-warning text-start" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
|
||||
<i class="bi bi-calculator me-1"></i>Kundesag → Bogholderi
|
||||
<button class="emails-v2-flow-choice flow-accounting${recommendationClass('create_accounting_case')}" data-v2-quick-action="create_accounting_case" ${hasCustomer ? '' : 'disabled'} title="${hasCustomer ? 'Opretter sag og tildeler Bogholderi' : 'Fastslå kunden først'}">
|
||||
${recommendationBadge('create_accounting_case')}
|
||||
<span class="flow-title"><i class="bi bi-calculator me-1"></i>Bogholderi</span>
|
||||
<span class="flow-description">Opret kundesag hos Bogholderi</span>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary text-start" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
|
||||
<i class="bi bi-archive me-1"></i>Arkivér mail
|
||||
<button class="emails-v2-flow-choice flow-archive${recommendationClass('archive_entity_mail')}" data-v2-quick-action="archive_entity_mail" ${hasIdentity ? '' : 'disabled'} title="${hasIdentity ? 'Arkiverer kun mailen; opretter intet' : 'Fastslå kunde eller leverandør først'}">
|
||||
${recommendationBadge('archive_entity_mail')}
|
||||
<span class="flow-title"><i class="bi bi-archive me-1"></i>Kun arkivér</span>
|
||||
<span class="flow-description">Ingen sag eller anden behandling</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
mailBody.className = 'emails-v2-mail-body';
|
||||
@ -1661,8 +1777,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<summary class="fw-semibold" style="cursor:pointer;">
|
||||
<i class="bi bi-three-dots me-1"></i>Flere handlinger
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
|
||||
${email.linked_case_id ? `
|
||||
<div class="emails-v2-card emails-primary-action">
|
||||
<div class="emails-primary-action rounded p-3 mb-3">
|
||||
<h6>Allerede knyttet til sag</h6>
|
||||
<a class="btn btn-primary w-100" href="/sag/${Number(email.linked_case_id)}/v3">
|
||||
<i class="bi bi-box-arrow-up-right me-2"></i>Åbn SAG #${Number(email.linked_case_id)}
|
||||
@ -1671,8 +1793,9 @@
|
||||
<i class="bi bi-check2 me-1"></i>Markér mailen behandlet
|
||||
</button>
|
||||
</div>` : `
|
||||
<div class="emails-v2-card emails-primary-action">
|
||||
<h6>Opret sag fra denne email</h6>
|
||||
<details class="border rounded p-3 mb-3">
|
||||
<summary class="fw-semibold" style="cursor:pointer;">Opret en anden type sag</summary>
|
||||
<div class="mt-3">
|
||||
<input id="v2CaseTitle" class="form-control form-control-sm mb-2" value="${escapeHtml(email.subject || '')}" placeholder="Sagens titel">
|
||||
<div class="d-flex gap-2">
|
||||
<select id="v2CaseType" class="form-select form-select-sm">
|
||||
@ -1686,15 +1809,16 @@
|
||||
<i class="bi bi-plus-lg me-1"></i>Opret
|
||||
</button>
|
||||
</div>
|
||||
</div>`}
|
||||
</div>
|
||||
</details>`}
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<div class="border rounded p-3 mb-3">
|
||||
<h6>${email.linked_case_id ? 'Skift sagstilknytning' : 'Eller knyt til eksisterende sag'}</h6>
|
||||
<input id="v2SagSearch" class="form-control form-control-sm mb-2" placeholder="Skriv sagsnummer eller titel…">
|
||||
<div id="v2SagResults" class="emails-v2-sag-results"></div>
|
||||
</div>
|
||||
|
||||
<div class="emails-v2-card">
|
||||
<div class="border rounded p-3 mb-3">
|
||||
<h6>Hurtige mailhandlinger</h6>
|
||||
<div class="emails-v2-actions mb-0">
|
||||
<button id="v2ReadToggle" class="btn btn-sm btn-outline-secondary">
|
||||
@ -1710,7 +1834,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<details class="border rounded p-3 mb-3">
|
||||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret behandling</summary>
|
||||
<div class="emails-v2-actions mt-3 mb-2">
|
||||
<button id="v2Reprocess" class="btn btn-sm btn-outline-warning">Genbehandl</button>
|
||||
@ -1721,7 +1845,7 @@
|
||||
<div id="v2WorkflowPreview"><div class="small text-muted">Henter workflow-preview…</div></div>
|
||||
</details>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<details class="border rounded p-3 mb-3">
|
||||
<summary class="small fw-semibold" style="cursor:pointer;">Leverandør og kundematch</summary>
|
||||
<h6 class="mt-3">Leverandør faktura</h6>
|
||||
<div class="emails-v2-kv"><div class="k">Leverandør</div><div class="v">${escapeHtml(email.extracted_vendor_name || '-')}</div></div>
|
||||
@ -1743,7 +1867,7 @@
|
||||
<div id="v2DomainStatus" class="emails-v2-inline-status"></div>
|
||||
</details>
|
||||
|
||||
<details class="emails-v2-card">
|
||||
<details class="border rounded p-3">
|
||||
<summary class="small fw-semibold" style="cursor:pointer;">Avanceret metadata</summary>
|
||||
<pre class="small mb-0 mt-2" style="white-space: pre-wrap;">${escapeHtml(JSON.stringify({
|
||||
email_id: email.id,
|
||||
@ -1755,6 +1879,8 @@
|
||||
linked_case_id: email.linked_case_id,
|
||||
}, null, 2))}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
|
||||
document.getElementById('v2ReadToggle')?.addEventListener('click', () => patchReadState(!Boolean(email.is_read)));
|
||||
|
||||
@ -29,6 +29,8 @@ async def check_reminders():
|
||||
|
||||
try:
|
||||
logger.info("🔔 Checking for pending reminders...")
|
||||
from app.modules.sag.backend.reminders import process_task_list_rules
|
||||
await process_task_list_rules()
|
||||
|
||||
# Step 1: Process queued trigger events (status changes)
|
||||
queue_count = await _process_reminder_queue()
|
||||
|
||||
@ -8,6 +8,7 @@ Runs daily at 04:00
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
from typing import Optional, Sequence
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.core.database import execute_query, get_db_connection
|
||||
@ -20,7 +21,7 @@ from app.services.subscription_billing_calendar import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_subscriptions():
|
||||
async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None):
|
||||
"""
|
||||
Main job: Process subscriptions due for invoicing.
|
||||
- Find active subscriptions where next_invoice_date <= today
|
||||
@ -108,10 +109,19 @@ async def process_subscriptions():
|
||||
SELECT 1 FROM subscription_billing_runs br
|
||||
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
|
||||
)
|
||||
"""
|
||||
params = []
|
||||
if subscription_ids is not None:
|
||||
selected_ids = sorted({int(item) for item in subscription_ids})
|
||||
if not selected_ids:
|
||||
return
|
||||
query += " AND s.id = ANY(%s)"
|
||||
params.append(selected_ids)
|
||||
query += """
|
||||
ORDER BY s.next_invoice_date, s.id
|
||||
"""
|
||||
|
||||
subscriptions = execute_query(query)
|
||||
subscriptions = execute_query(query, tuple(params))
|
||||
|
||||
if not subscriptions:
|
||||
logger.info("✅ No subscriptions due for invoicing")
|
||||
|
||||
20
app/jobs/project_ct_archive_sync.py
Normal file
20
app/jobs/project_ct_archive_sync.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""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")
|
||||
@ -3,7 +3,7 @@ Pydantic Models and Schemas
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime, date
|
||||
|
||||
@ -89,6 +89,7 @@ class VendorBase(BaseModel):
|
||||
priority: Optional[int] = 100
|
||||
notes: Optional[str] = None
|
||||
is_active: bool = True
|
||||
is_internet_provider: bool = False
|
||||
|
||||
|
||||
class VendorCreate(VendorBase):
|
||||
@ -103,10 +104,16 @@ class VendorUpdate(BaseModel):
|
||||
domain: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
economic_supplier_number: Optional[int] = None
|
||||
contact_person: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
is_internet_provider: Optional[bool] = None
|
||||
|
||||
|
||||
class Vendor(VendorBase):
|
||||
@ -159,6 +166,15 @@ class SolutionBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
|
||||
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
|
||||
problem: Optional[str] = None
|
||||
root_cause: Optional[str] = None
|
||||
investigation: Optional[str] = None
|
||||
workaround: Optional[str] = None
|
||||
visibility: str = "internal"
|
||||
approval_status: str = "draft"
|
||||
is_final: bool = True
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
products: list[str] = Field(default_factory=list)
|
||||
|
||||
class SolutionCreate(SolutionBase):
|
||||
"""Schema for creating a solution"""
|
||||
@ -171,6 +187,16 @@ class SolutionUpdate(BaseModel):
|
||||
description: Optional[str] = None
|
||||
solution_type: Optional[str] = None
|
||||
result: Optional[str] = None
|
||||
problem: Optional[str] = None
|
||||
root_cause: Optional[str] = None
|
||||
investigation: Optional[str] = None
|
||||
workaround: Optional[str] = None
|
||||
visibility: Optional[str] = None
|
||||
approval_status: Optional[str] = None
|
||||
is_final: Optional[bool] = None
|
||||
tags: Optional[list[str]] = None
|
||||
products: Optional[list[str]] = None
|
||||
change_note: Optional[str] = None
|
||||
|
||||
class Solution(SolutionBase):
|
||||
"""Full solution schema"""
|
||||
@ -179,6 +205,9 @@ class Solution(SolutionBase):
|
||||
created_by_user_id: Optional[int] = None
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
updated_by_user_id: Optional[int] = None
|
||||
approved_by_user_id: Optional[int] = None
|
||||
approved_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, Query, Request, UploadFile
|
||||
from fastapi import APIRouter, File, Query, Request, Response, UploadFile
|
||||
|
||||
from app.modules.also.backend.service import also_service
|
||||
from app.modules.also.models.schemas import (
|
||||
@ -34,7 +34,8 @@ def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
|
||||
|
||||
@router.get("/also/config")
|
||||
async def also_config() -> dict:
|
||||
async def also_config(response: Response) -> dict:
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return also_service.get_config()
|
||||
|
||||
|
||||
|
||||
@ -275,6 +275,21 @@ def _extract_period_start(value: Any) -> Optional[str]:
|
||||
return _parse_date_candidate(match.group(1))
|
||||
|
||||
|
||||
def _format_charge_period(value: Any) -> Optional[str]:
|
||||
"""Return an explicit ALSO service period without inferring a missing end date."""
|
||||
text = _normalized_text(value)
|
||||
if not text:
|
||||
return None
|
||||
match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text)
|
||||
if not match:
|
||||
return None
|
||||
start = _parse_date_candidate(match.group(1))
|
||||
end = _parse_date_candidate(match.group(2))
|
||||
if not start or not end:
|
||||
return None
|
||||
return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}"
|
||||
|
||||
|
||||
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
|
||||
product_name = _normalize_match_key(line.get("product_name"))
|
||||
if "microsoftorganizationtenant" not in product_name:
|
||||
@ -319,7 +334,8 @@ class AlsoService:
|
||||
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
|
||||
"currency": ["currency", "valuta"],
|
||||
"billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"],
|
||||
"charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
|
||||
# The CSV's Actual Charge Interval is the authoritative service period.
|
||||
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
|
||||
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"],
|
||||
"billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"],
|
||||
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
|
||||
@ -2008,6 +2024,10 @@ class AlsoService:
|
||||
quantity = Decimal("1")
|
||||
unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
|
||||
amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
|
||||
description = line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement"
|
||||
charge_period = _format_charge_period(line.get("charge_interval"))
|
||||
if charge_period:
|
||||
description = f"{description}\nPeriode: {charge_period}"
|
||||
|
||||
draft_lines.append(
|
||||
{
|
||||
@ -2015,7 +2035,7 @@ class AlsoService:
|
||||
"source_type": "also_cloud",
|
||||
"source_id": int(line["id"]),
|
||||
"reference_id": int(line["import_job_id"]),
|
||||
"description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
|
||||
"description": description,
|
||||
"quantity": float(quantity),
|
||||
"unit": "stk",
|
||||
"unit_price": float(unit_price),
|
||||
@ -2032,6 +2052,7 @@ class AlsoService:
|
||||
"also_material_number": line.get("material_number"),
|
||||
"also_vendor": line.get("vendor"),
|
||||
"also_import_job_id": int(line.get("import_job_id")),
|
||||
"billing_period": charge_period,
|
||||
},
|
||||
}
|
||||
)
|
||||
@ -2067,7 +2088,7 @@ class AlsoService:
|
||||
]
|
||||
execute_query(
|
||||
"""UPDATE ordre_drafts
|
||||
SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(_json_dumps(merged_lines), int(draft["id"])),
|
||||
)
|
||||
@ -2090,10 +2111,10 @@ class AlsoService:
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
f"ALSO Cloud {customer_name} - {period_key}",
|
||||
"Abonnementer",
|
||||
customer_id,
|
||||
_json_dumps(draft_lines),
|
||||
"Genereret fra ALSO Cloud Billing approval",
|
||||
None,
|
||||
1,
|
||||
approved_by_user_id,
|
||||
_json_dumps({"source": "also_cloud_billing"}),
|
||||
|
||||
@ -4,6 +4,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
|
||||
from app.core.auth_service import AuthService
|
||||
from .service import get_active_timer, get_dashboard_status, get_notifications, get_user_messages_summary
|
||||
@ -79,14 +80,14 @@ async def bottom_bar_ws(websocket: WebSocket):
|
||||
initial_status = get_dashboard_status()
|
||||
initial_notifications = get_notifications(user_id, limit=20)
|
||||
initial_messages = get_user_messages_summary(user_id, limit=20)
|
||||
await websocket.send_json({"event": "status_delta", "data": initial_status})
|
||||
await websocket.send_json({
|
||||
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": initial_status}))
|
||||
await websocket.send_json(jsonable_encoder({
|
||||
"event": "notification_delta",
|
||||
"data": {
|
||||
"notifications": initial_notifications,
|
||||
"messages": initial_messages,
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
last_status_json = json.dumps(initial_status, sort_keys=True, default=str)
|
||||
last_notifications_json = json.dumps(initial_notifications, sort_keys=True, default=str)
|
||||
@ -99,7 +100,7 @@ async def bottom_bar_ws(websocket: WebSocket):
|
||||
timer = get_active_timer(user_id)
|
||||
elapsed = int(timer.get("elapsed") or 0)
|
||||
if elapsed != last_timer_elapsed:
|
||||
await websocket.send_json({"event": "timer_tick", "data": timer})
|
||||
await websocket.send_json(jsonable_encoder({"event": "timer_tick", "data": timer}))
|
||||
last_timer_elapsed = elapsed
|
||||
|
||||
status_tick += 1
|
||||
@ -110,19 +111,19 @@ async def bottom_bar_ws(websocket: WebSocket):
|
||||
|
||||
status_json = json.dumps(status, sort_keys=True, default=str)
|
||||
if status_json != last_status_json:
|
||||
await websocket.send_json({"event": "status_delta", "data": status})
|
||||
await websocket.send_json(jsonable_encoder({"event": "status_delta", "data": status}))
|
||||
last_status_json = status_json
|
||||
|
||||
notifications_json = json.dumps(notifications, sort_keys=True, default=str)
|
||||
messages_json = json.dumps(messages, sort_keys=True, default=str)
|
||||
if notifications_json != last_notifications_json or messages_json != last_messages_json:
|
||||
await websocket.send_json({
|
||||
await websocket.send_json(jsonable_encoder({
|
||||
"event": "notification_delta",
|
||||
"data": {
|
||||
"notifications": notifications,
|
||||
"messages": messages,
|
||||
},
|
||||
})
|
||||
}))
|
||||
last_notifications_json = notifications_json
|
||||
last_messages_json = messages_json
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
from typing import Optional
|
||||
from typing import Optional, Literal
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.auth_service import AuthService
|
||||
from app.core.auth_dependencies import get_current_user
|
||||
@ -69,6 +69,10 @@ class BottomBarMessageCreatePayload(BaseModel):
|
||||
message: str
|
||||
recipient_user_id: Optional[int] = None
|
||||
requires_manual_ack: bool = False
|
||||
message_kind: Literal['message', 'phone'] = 'message'
|
||||
contact_id: Optional[int] = Field(default=None, gt=0)
|
||||
caller_name: str = Field(default='', max_length=200)
|
||||
callback_phone: str = Field(default='', max_length=80)
|
||||
|
||||
|
||||
class BottomBarMessageReadPayload(BaseModel):
|
||||
@ -566,13 +570,17 @@ async def send_bottom_bar_message(
|
||||
if recipient_user_id == int(current_user_id):
|
||||
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
|
||||
|
||||
if payload.contact_id is not None:
|
||||
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
|
||||
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
|
||||
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
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)),
|
||||
(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()),
|
||||
) or {}
|
||||
|
||||
return {
|
||||
@ -582,6 +590,10 @@ async def send_bottom_bar_message(
|
||||
"from": _resolve_current_user_display_name(current_user),
|
||||
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
|
||||
"text": row.get("message_text") or message_text,
|
||||
"message_kind": payload.message_kind,
|
||||
"contact_id": payload.contact_id,
|
||||
"caller_name": payload.caller_name.strip(),
|
||||
"callback_phone": payload.callback_phone.strip(),
|
||||
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
||||
"created_at": row.get("created_at"),
|
||||
"is_own": True,
|
||||
|
||||
@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None:
|
||||
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
message_text TEXT NOT NULL,
|
||||
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
|
||||
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
caller_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
|
||||
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
read_at TIMESTAMP NULL
|
||||
@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None:
|
||||
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
|
||||
else:
|
||||
columns = set(_table_columns("bottom_bar_messages"))
|
||||
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
|
||||
execute_query("""ALTER TABLE bottom_bar_messages
|
||||
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
|
||||
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
|
||||
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
|
||||
if "requires_manual_ack" not in columns:
|
||||
execute_query(
|
||||
"""
|
||||
@ -143,6 +153,8 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
m.sender_user_id,
|
||||
m.recipient_user_id,
|
||||
m.message_text,
|
||||
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
|
||||
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
|
||||
m.requires_manual_ack,
|
||||
m.created_at,
|
||||
receipt.read_at,
|
||||
@ -152,9 +164,10 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
FROM bottom_bar_messages m
|
||||
JOIN users sender ON sender.user_id = m.sender_user_id
|
||||
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
|
||||
LEFT JOIN contacts contact ON contact.id = m.contact_id
|
||||
LEFT JOIN bottom_bar_message_receipts receipt
|
||||
ON receipt.message_id = m.id
|
||||
AND receipt.user_id = %s
|
||||
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
|
||||
WHERE m.sender_user_id = %s
|
||||
OR m.recipient_user_id = %s
|
||||
OR (m.recipient_user_id IS NULL AND EXISTS (
|
||||
@ -166,7 +179,7 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
|
||||
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
|
||||
) or []
|
||||
|
||||
unread_row = execute_query_single(
|
||||
@ -200,9 +213,15 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
|
||||
"from": row.get("sender_name") or "Ukendt",
|
||||
"to": recipient_name,
|
||||
"text": row.get("message_text") or "",
|
||||
"message_kind": row.get("message_kind") or "message",
|
||||
"contact_id": row.get("contact_id"),
|
||||
"contact_name": row.get("contact_name"),
|
||||
"caller_name": row.get("caller_name") or "",
|
||||
"callback_phone": row.get("callback_phone") or "",
|
||||
"requires_manual_ack": bool(row.get("requires_manual_ack")),
|
||||
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
|
||||
"is_own": int(row.get("sender_user_id") or 0) == int(user_id),
|
||||
"is_read": row.get("read_at") is not None,
|
||||
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
|
||||
"is_acknowledged": row.get("acknowledged_at") is not None,
|
||||
}
|
||||
@ -971,6 +990,10 @@ def build_bottom_bar_state(
|
||||
unassigned_open_cases = get_unassigned_open_cases(limit=8)
|
||||
recent_cases = _get_recent_cases(user_id, limit=10)
|
||||
notes_summary = get_user_notes_summary(user_id, limit=10)
|
||||
procurement_attention = execute_query_single(
|
||||
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
|
||||
WHERE type = 'purchase' AND status = 'draft'"""
|
||||
) or {"count": 0}
|
||||
|
||||
urgent_cases = execute_query(
|
||||
"""
|
||||
@ -1155,6 +1178,10 @@ def build_bottom_bar_state(
|
||||
"list": unassigned_open_cases.get("items") or [],
|
||||
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
|
||||
},
|
||||
"procurement": {
|
||||
"to_order": int(procurement_attention.get("count") or 0),
|
||||
"route": "/procurement",
|
||||
},
|
||||
"timer": {
|
||||
"active_count": 1 if timer.get("active") else 0,
|
||||
"list": timer_list,
|
||||
|
||||
@ -8,11 +8,164 @@ from psycopg2.extras import Json
|
||||
from datetime import datetime, date
|
||||
import os
|
||||
import uuid
|
||||
import secrets
|
||||
from fastapi import Header, status
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class MobileRecorderProvisionRequest(BaseModel):
|
||||
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
|
||||
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
asset_type: str = "mobile_recorder"
|
||||
manufacturer: str = "Apple"
|
||||
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
|
||||
model: Optional[str] = Field(default=None, max_length=100)
|
||||
device_type: Optional[str] = Field(default=None, max_length=100)
|
||||
serial_number: str = Field(min_length=1, max_length=100)
|
||||
udid: Optional[str] = Field(default=None, max_length=160)
|
||||
ecid: Optional[str] = Field(default=None, max_length=160)
|
||||
imei: Optional[str] = Field(default=None, max_length=40)
|
||||
wifi_mac: Optional[str] = Field(default=None, max_length=40)
|
||||
os: str = "iOS"
|
||||
os_version: Optional[str] = Field(default=None, max_length=40)
|
||||
supervised: bool = False
|
||||
status: str = "ready"
|
||||
|
||||
|
||||
def _provisioning_token_or_401(
|
||||
authorization: Optional[str], x_provisioning_token: Optional[str],
|
||||
) -> None:
|
||||
"""Authenticate a headless provisioning client without accepting user JWTs."""
|
||||
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
|
||||
if not expected:
|
||||
logger.error("Mobile Recorder provisioning was called but no service token is configured")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Provisioning endpoint is disabled: service token is not configured",
|
||||
)
|
||||
bearer = (authorization or "").strip()
|
||||
supplied = (x_provisioning_token or "").strip()
|
||||
if not supplied and bearer.lower().startswith("bearer "):
|
||||
supplied = bearer[7:].strip()
|
||||
if not supplied or not secrets.compare_digest(supplied, expected):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid provisioning token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
|
||||
return str(value).strip() if value is not None and str(value).strip() else None
|
||||
|
||||
|
||||
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
|
||||
async def provision_mobile_recorder(
|
||||
payload: MobileRecorderProvisionRequest,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
x_provisioning_token: Optional[str] = Header(default=None),
|
||||
):
|
||||
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
|
||||
_provisioning_token_or_401(authorization, x_provisioning_token)
|
||||
|
||||
if payload.asset_type != "mobile_recorder":
|
||||
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
|
||||
if payload.status != "ready":
|
||||
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
|
||||
|
||||
serial_number = _clean_provisioning_value(payload.serial_number)
|
||||
if not serial_number:
|
||||
raise HTTPException(status_code=422, detail="serial_number is required")
|
||||
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
|
||||
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
|
||||
recorder_name = _clean_provisioning_value(payload.name)
|
||||
|
||||
mobile_specs = {
|
||||
"recorder_number": payload.recorder_number,
|
||||
"name": recorder_name,
|
||||
"udid": _clean_provisioning_value(payload.udid),
|
||||
"ecid": _clean_provisioning_value(payload.ecid),
|
||||
"imei": _clean_provisioning_value(payload.imei),
|
||||
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
|
||||
"os": _clean_provisioning_value(payload.os) or "iOS",
|
||||
"os_version": _clean_provisioning_value(payload.os_version),
|
||||
"supervised": bool(payload.supervised),
|
||||
"provisioning_status": "ready",
|
||||
"source": "apple_configurator",
|
||||
}
|
||||
|
||||
existing = execute_query(
|
||||
"""SELECT id, hardware_specs FROM hardware_assets
|
||||
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
|
||||
ORDER BY id LIMIT 2""",
|
||||
(serial_number,),
|
||||
) or []
|
||||
if len(existing) > 1:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
|
||||
)
|
||||
|
||||
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
|
||||
if isinstance(prior_specs, str):
|
||||
try:
|
||||
prior_specs = json.loads(prior_specs)
|
||||
except (TypeError, ValueError):
|
||||
prior_specs = {}
|
||||
if not isinstance(prior_specs, dict):
|
||||
prior_specs = {}
|
||||
prior_specs["mobile_recorder"] = mobile_specs
|
||||
|
||||
if existing:
|
||||
asset_id = int(existing[0]["id"])
|
||||
rows = execute_query(
|
||||
"""UPDATE hardware_assets
|
||||
SET asset_type = 'mobile_recorder', brand = %s,
|
||||
model = COALESCE(%s, model), internal_asset_id = %s,
|
||||
recorder_number = %s, status = 'ready', hardware_specs = %s,
|
||||
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
RETURNING id""",
|
||||
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
|
||||
)
|
||||
action = "updated"
|
||||
else:
|
||||
rows = execute_query(
|
||||
"""INSERT INTO hardware_assets
|
||||
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
|
||||
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
|
||||
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id""",
|
||||
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
|
||||
)
|
||||
action = "created"
|
||||
asset_id = int(rows[0]["id"])
|
||||
execute_query(
|
||||
"""INSERT INTO hardware_ownership_history
|
||||
(hardware_id, owner_type, start_date, notes)
|
||||
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
|
||||
(asset_id,),
|
||||
fetch=False,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
|
||||
execute_query(
|
||||
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
|
||||
VALUES (%s, %s, %s)""",
|
||||
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
|
||||
fetch=False,
|
||||
)
|
||||
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
|
||||
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
|
||||
|
||||
|
||||
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
|
||||
if payload is None:
|
||||
return None
|
||||
|
||||
203
app/modules/internet_connections/backend/change_case_service.py
Normal file
203
app/modules/internet_connections/backend/change_case_service.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""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
@ -12,14 +12,30 @@
|
||||
border-radius: 24px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 16px 36px rgba(15, 76, 117, 0.08);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-hero::after { content:""; position:absolute; width:190px; height:190px; right:-65px; bottom:-95px; border-radius:50%; border:28px solid rgba(15,76,117,.06); pointer-events:none; }
|
||||
.detail-title-wrap { display:flex; align-items:flex-start; gap:1rem; position:relative; z-index:1; }
|
||||
.detail-title-icon { width:52px; height:52px; flex:0 0 52px; display:grid; place-items:center; border-radius:16px; color:#fff; background:linear-gradient(135deg,#0f4c75,#3282b8); box-shadow:0 9px 20px rgba(15,76,117,.22); font-size:1.35rem; }
|
||||
.detail-circuit-badge { display:inline-flex; align-items:center; gap:.38rem; padding:.3rem .62rem; margin-top:.55rem; border-radius:999px; background:rgba(15,76,117,.09); color:var(--accent); font-size:.78rem; font-weight:750; letter-spacing:.025em; }
|
||||
.detail-section-nav { display:flex; flex-wrap:wrap; gap:.4rem; margin-top:1rem; position:relative; z-index:1; }
|
||||
.detail-section-nav a { display:inline-flex; align-items:center; gap:.35rem; padding:.38rem .65rem; border-radius:9px; color:var(--text-primary); background:rgba(255,255,255,.58); border:1px solid rgba(15,76,117,.1); text-decoration:none; font-size:.78rem; font-weight:650; }
|
||||
.detail-section-nav a:hover { color:var(--accent); background:#fff; transform:translateY(-1px); }
|
||||
|
||||
.detail-metrics-grid { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:.8rem; }
|
||||
.detail-metrics-grid > [class*="col-"] { width:auto; padding:0; }
|
||||
|
||||
.detail-panel {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid rgba(15, 76, 117, 0.12);
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 14px 32px rgba(15, 76, 117, 0.06);
|
||||
}
|
||||
.allocation-banner { border:1px solid #f0c36a; background:linear-gradient(135deg,#fff8e7,#fffdf7); border-radius:18px; box-shadow:0 10px 28px rgba(120,82,20,.08); }
|
||||
.allocation-suggestion { border:1px solid rgba(120,82,20,.16); background:#fff; border-radius:12px; padding:.7rem .85rem; cursor:pointer; }
|
||||
.allocation-suggestion:hover { border-color:#d39a2c; background:#fffaf0; }
|
||||
|
||||
.detail-metric {
|
||||
background: linear-gradient(180deg, rgba(15, 76, 117, 0.04), rgba(15, 76, 117, 0.01));
|
||||
@ -27,7 +43,10 @@
|
||||
border-radius: 16px;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
position:relative;
|
||||
overflow:hidden;
|
||||
}
|
||||
.detail-metric::after { content:""; position:absolute; width:55px; height:55px; right:-22px; bottom:-24px; border-radius:50%; background:rgba(15,76,117,.07); }
|
||||
|
||||
.detail-metric-label {
|
||||
color: var(--text-secondary);
|
||||
@ -55,6 +74,31 @@
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
#ipRangesList { grid-template-columns: 1fr; }
|
||||
.ip-range-card {
|
||||
border-left:4px solid #3282b8;
|
||||
background:linear-gradient(135deg,rgba(50,130,184,.07),rgba(255,255,255,.45));
|
||||
display:grid;
|
||||
grid-template-columns:minmax(250px,1.35fr) repeat(3,minmax(115px,.65fr)) auto;
|
||||
align-items:center;
|
||||
gap:1rem;
|
||||
padding:1rem 1.1rem;
|
||||
}
|
||||
.ip-range-cidr { display:inline-flex; align-items:center; gap:.42rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:1.03rem; }
|
||||
.ip-range-main,.ip-range-meta { min-width:0; }
|
||||
.ip-range-meta .label { margin-bottom:.15rem; }
|
||||
.ip-range-meta .value { font-size:.92rem; overflow-wrap:anywhere; }
|
||||
.ip-range-actions { justify-self:end; }
|
||||
.ip-range-edit { grid-column:1/-1; border-top:1px solid rgba(15,76,117,.1); padding-top:.9rem; }
|
||||
.ip-range-warning { grid-column:1/-1; display:flex; align-items:flex-start; gap:.75rem; padding:.85rem 1rem; border:1px solid #f4cccc; border-radius:12px; background:#fff8f8; }
|
||||
@media (max-width: 1000px) {
|
||||
.ip-range-card { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.ip-range-main,.ip-range-actions,.ip-range-edit { grid-column:1/-1; }
|
||||
.ip-range-actions { justify-self:start; }
|
||||
}
|
||||
.ip-empty-state { text-align:center; padding:2.5rem 1rem; border:1px dashed rgba(15,76,117,.22); border-radius:16px; background:rgba(15,76,117,.025); }
|
||||
.ip-empty-state i { display:block; color:var(--accent); opacity:.55; font-size:2rem; margin-bottom:.55rem; }
|
||||
|
||||
.detail-grid-card.editing {
|
||||
border-color: rgba(15, 76, 117, 0.28);
|
||||
background: rgba(15, 76, 117, 0.06);
|
||||
@ -235,6 +279,7 @@
|
||||
}
|
||||
|
||||
@media (max-width: 991.98px) {
|
||||
.detail-metrics-grid { grid-template-columns:repeat(2,minmax(0,1fr)); }
|
||||
.detail-read-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@ -245,6 +290,8 @@
|
||||
gap: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575.98px) { .detail-metrics-grid { grid-template-columns:1fr; } .detail-title-icon { display:none; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -252,12 +299,16 @@
|
||||
<div class="container-fluid py-4">
|
||||
<div class="detail-hero mb-4">
|
||||
<div class="d-flex flex-column flex-xl-row justify-content-between align-items-xl-start gap-3">
|
||||
<div class="detail-title-wrap">
|
||||
<div class="detail-title-icon"><i class="bi bi-router"></i></div>
|
||||
<div>
|
||||
<div class="small text-uppercase fw-semibold text-muted mb-2">Internetforbindelse</div>
|
||||
<h2 class="h3 mb-1" id="detailName">Indlæser...</h2>
|
||||
<div class="text-muted" id="detailSubtitle">Henter forbindelsesdata...</div>
|
||||
<div class="detail-circuit-badge" id="detailCircuitBadge"><i class="bi bi-diagram-3"></i><span>Henter kredsløb...</span></div>
|
||||
<div class="small mt-2 text-muted" id="detailSaveFeedback" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a href="/economy/internet-connections" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Tilbage
|
||||
@ -274,11 +325,51 @@
|
||||
<button class="btn btn-primary d-none" id="saveCoreDetailsBtn" type="button" onclick="saveCoreDetails()">
|
||||
<i class="bi bi-save me-1"></i>Gem ændringer
|
||||
</button>
|
||||
<a class="btn btn-outline-primary" id="createCaseForConnectionBtn" href="/sag/new">
|
||||
<i class="bi bi-plus-circle me-1"></i>Opret sag
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="detail-section-nav" aria-label="Sektioner">
|
||||
<a href="#connection-core"><i class="bi bi-info-circle"></i>Grunddata</a>
|
||||
<a href="#connection-ip"><i class="bi bi-hdd-network"></i>IP-ranges og adresser</a>
|
||||
<a href="#connection-pricing"><i class="bi bi-cash-coin"></i>Pris og kontrakt</a>
|
||||
<a href="#connection-cases"><i class="bi bi-kanban"></i>Sager</a>
|
||||
<a href="#connection-history"><i class="bi bi-clock-history"></i>Historik</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="allocation-banner p-4 mb-4 d-none" id="allocationBanner">
|
||||
<div class="d-flex flex-column flex-xl-row justify-content-between gap-3">
|
||||
<div>
|
||||
<div class="fw-bold text-warning-emphasis"><i class="bi bi-exclamation-circle-fill me-2"></i>Forbindelsen er ikke tildelt en kunde</div>
|
||||
<div class="small text-muted mt-1">Vælg virksomheden på installationsadressen. Intet bliver tildelt automatisk.</div>
|
||||
<div class="d-flex flex-wrap gap-2 mt-3" id="allocationSuggestions"></div>
|
||||
</div>
|
||||
<div style="min-width:min(100%,420px);" class="vstack gap-2">
|
||||
<select class="form-select" id="allocationCustomerSelect" onchange="loadAllocationSubscriptions()"><option value="">Vælg kunde…</option></select>
|
||||
<select class="form-select" id="allocationSubscriptionSelect" disabled><option value="">Kun tildel kunde – intet abonnement</option></select>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-warning" id="saveAllocationBtn" type="button" onclick="saveConnectionAllocation()">Tildel kunde</button>
|
||||
<a class="btn btn-outline-secondary d-none" id="allocationCustomerLink" href="#">Åbn kunde</a>
|
||||
</div>
|
||||
<div class="small" id="allocationFeedback" role="status"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="alert mb-4" id="slaBanner" role="status">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3">
|
||||
<div id="slaBannerContent"></div>
|
||||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||||
<select class="form-select" id="slaSubscriptionSelect" style="min-width:300px"></select>
|
||||
<button class="btn btn-primary" id="saveSlaBtn" type="button" onclick="saveSlaAllocation()">Gem SLA</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small mt-2" id="slaFeedback"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-metrics-grid mb-4">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="detail-metric">
|
||||
<div class="detail-metric-label">Salgspris</div>
|
||||
@ -314,7 +405,7 @@
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-xl-8">
|
||||
<div class="detail-panel p-4 mb-4">
|
||||
<div class="detail-panel p-4 mb-4" id="connection-core">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">Grunddata</h5>
|
||||
<span id="detailStatusBadge" class="status-pill inactive">-</span>
|
||||
@ -327,7 +418,7 @@
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Leverandør</label>
|
||||
<input type="text" class="form-control" id="fieldProvider">
|
||||
<select class="form-select" id="fieldVendorId"><option value="">Ingen valgt</option></select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Kredsløb</label>
|
||||
@ -383,6 +474,13 @@
|
||||
<label class="form-label">Overvågning</label>
|
||||
<input type="text" class="form-control" id="fieldMonitoringUrl" placeholder="https://...">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check form-switch border rounded-3 p-3 ps-5 bg-light">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="fieldManualShared" onchange="toggleManualDelefiber()">
|
||||
<label class="form-check-label fw-semibold" for="fieldManualShared">BMC Delefiber</label>
|
||||
<div class="small text-muted">BMC ejer hovedforbindelsen og udstyret og kan dele den ud til flere BMCnet-kunder.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Allokering</label>
|
||||
<select class="form-select" id="fieldAllocationModel">
|
||||
@ -424,7 +522,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4 mb-4">
|
||||
<div class="detail-panel p-4 mb-4" id="connection-ip">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0">IP-ranges</h5>
|
||||
@ -538,16 +636,16 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4 mb-4">
|
||||
<div class="detail-panel p-4 mb-4" id="connection-pricing">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0">Pris og kontrakt</h5>
|
||||
<div class="small text-muted" id="pricingFeedback"></div>
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-3"><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
|
||||
<div class="col-md-3"><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="Indkøbspris"></div>
|
||||
<div class="col-md-3"><input type="number" class="form-control" id="pricingSalesInput" placeholder="Salgspris"></div>
|
||||
<div class="col-md-3"><input type="text" class="form-control" id="pricingNotesInput" placeholder="Note"></div>
|
||||
<div class="col-md-3"><label class="form-label" for="pricingEffectiveDateInput">Gældende fra</label><input type="date" class="form-control" id="pricingEffectiveDateInput"></div>
|
||||
<div class="col-md-3"><label class="form-label" for="pricingPurchaseInput">Indkøbspris</label><input type="number" class="form-control" id="pricingPurchaseInput" placeholder="0 kr." step="0.01"></div>
|
||||
<div class="col-md-3"><label class="form-label" for="pricingSalesInput">Salgspris</label><input type="number" class="form-control" id="pricingSalesInput" placeholder="0 kr." step="0.01"></div>
|
||||
<div class="col-md-3"><label class="form-label" for="pricingNotesInput">Note</label><input type="text" class="form-control" id="pricingNotesInput" placeholder="Valgfri note"></div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<button class="btn btn-primary" type="button" onclick="createPricingEntry()">Gem prislinje</button>
|
||||
@ -556,6 +654,12 @@
|
||||
<div id="pricingHistoryList"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4 mb-4 d-none" id="delefiberProductPricesPanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3"><div><h5 class="mb-0">BMCnet standardpriser</h5><div class="small text-muted">Priser gælder kun for denne delefiber/adresse.</div></div></div>
|
||||
<div class="row g-2 mb-2"><div class="col-md-6"><select class="form-select" id="delefiberPriceProduct"></select></div><div class="col-md-3"><input class="form-control" id="delefiberPriceAmount" type="number" min="0" step="0.01" placeholder="Kr./md."></div><div class="col-md-3"><button class="btn btn-primary w-100" type="button" onclick="saveDelefiberProductPrice()">Gem pris</button></div></div>
|
||||
<div id="delefiberProductPricesList" class="small"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4 mb-4 d-none" id="crossFieldPortsPanel">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
@ -580,7 +684,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4">
|
||||
<div class="detail-panel p-4 mb-4" id="connection-cases">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
|
||||
<div><h5 class="mb-0">Sager på forbindelsen</h5><div class="small text-muted">Kun direkte tilknyttede sager.</div></div>
|
||||
<a class="btn btn-sm btn-primary" id="createCaseForConnectionPanelBtn" href="/sag/new"><i class="bi bi-plus-lg me-1"></i>Opret sag</a>
|
||||
</div>
|
||||
<div id="connectionCasesList"></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-panel p-4" id="connection-history">
|
||||
<h5 class="mb-3">Historik</h5>
|
||||
<div id="historyList"></div>
|
||||
</div>
|
||||
@ -709,6 +821,7 @@
|
||||
<label class="form-label">Noter</label>
|
||||
<textarea class="form-control" id="bmcnetWizardNotes" rows="3" placeholder="Interne noter"></textarea>
|
||||
</div>
|
||||
<div class="col-12"><details class="border rounded-3 p-3"><summary class="fw-semibold">Periode og aftalevilkår <span class="text-muted fw-normal">— valgfrit</span></summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Periode start</label><input type="date" class="form-control" id="bmcnetWizardPeriodStart"></div><div class="col-md-4"><label class="form-label">Slutdato</label><input type="date" class="form-control" id="bmcnetWizardEndDate"></div><div class="col-md-4"><label class="form-label">Opsigelsesvarsel</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardNoticeDays" min="0" value="30"><span class="input-group-text">dage</span></div></div><div class="col-md-4"><label class="form-label">Faktureringsretning</label><select class="form-select" id="bmcnetWizardBillingDirection"><option value="forward">Forud</option><option value="backward">Bagud</option></select></div><div class="col-md-4"><label class="form-label">Perioder pr. faktura</label><input type="number" class="form-control" id="bmcnetWizardAdvanceMonths" min="1" value="1"></div><div class="col-md-4"><label class="form-label">Fakturér før perioden</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardLeadMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div><div class="col-md-4"><label class="form-label">Første faktura</label><select class="form-select" id="bmcnetWizardFirstInvoicePolicy"><option value="start_date">På startdato</option><option value="next_cycle">Ved næste cyklus</option></select></div><div class="col-md-4"><label class="form-label">Binding</label><div class="input-group"><input type="number" class="form-control" id="bmcnetWizardBindingMonths" min="0" value="0"><span class="input-group-text">mdr.</span></div></div></div></details></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -832,6 +945,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showDetailMessage(message, isError = false) {
|
||||
const feedback = document.getElementById('detailSaveFeedback');
|
||||
feedback.className = `small ${isError ? 'text-danger' : 'text-success'}`;
|
||||
feedback.textContent = message;
|
||||
feedback.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function extractErrorMessage(response, fallback) {
|
||||
try {
|
||||
const payload = await response.clone().json();
|
||||
@ -1000,11 +1120,12 @@
|
||||
}
|
||||
|
||||
async function loadLookups() {
|
||||
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse] = await Promise.all([
|
||||
const [customersResponse, connectionsResponse, subscriptionsResponse, productsResponse, vendorsResponse] = await Promise.all([
|
||||
fetch('/api/v1/customers?limit=1000&is_active=true'),
|
||||
fetch('/api/v1/internet-connections'),
|
||||
fetch('/api/v1/internet-connections/subscription-options?status=active'),
|
||||
fetch('/api/v1/products'),
|
||||
fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100'),
|
||||
]);
|
||||
|
||||
const customersPayload = customersResponse.ok ? await customersResponse.json() : { customers: [] };
|
||||
@ -1012,6 +1133,9 @@
|
||||
connectionOptions = connectionsResponse.ok ? await connectionsResponse.json() : [];
|
||||
subscriptionOptions = subscriptionsResponse.ok ? await subscriptionsResponse.json() : [];
|
||||
bmcnetWizardProducts = productsResponse.ok ? await productsResponse.json() : [];
|
||||
const internetVendors = vendorsResponse.ok ? await vendorsResponse.json() : [];
|
||||
document.getElementById('fieldVendorId').innerHTML = '<option value="">Ingen valgt</option>' + internetVendors
|
||||
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
|
||||
|
||||
populateDatalist('customerLookupList', customerOptions);
|
||||
populateDatalist(
|
||||
@ -1057,8 +1181,46 @@
|
||||
)).join('');
|
||||
}
|
||||
|
||||
let delefiberProductPrices = [];
|
||||
|
||||
function isDelefiber(connection = currentConnection) {
|
||||
return Boolean(connection && !connection.parent_id && (connection.is_shared_head || connection.allocation_model === 'shared'));
|
||||
}
|
||||
|
||||
async function loadDelefiberProductPrices() {
|
||||
const panel = document.getElementById('delefiberProductPricesPanel');
|
||||
if (!panel || !isDelefiber()) { panel?.classList.add('d-none'); return; }
|
||||
panel.classList.remove('d-none');
|
||||
const select = document.getElementById('delefiberPriceProduct');
|
||||
const priceProducts = bmcnetWizardProducts.filter((product) => ['internet_access', 'ip_allocation'].includes(parseProductAttributes(product.attributes_json)?.network?.kind));
|
||||
select.innerHTML = '<option value="">Vælg produkt</option>' + priceProducts.map((product) => `<option value="${product.id}">${escapeHtml(product.name || '-')} · global ${formatDKK(product.sales_price || 0)}</option>`).join('');
|
||||
try {
|
||||
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente standardpriser');
|
||||
delefiberProductPrices = await response.json();
|
||||
const list = document.getElementById('delefiberProductPricesList');
|
||||
list.innerHTML = delefiberProductPrices.length ? delefiberProductPrices.map((row) => `<div class="d-flex align-items-center gap-2 border-top py-2"><strong class="flex-grow-1">${escapeHtml(row.product_name || '-')}</strong><span>${formatDKK(row.monthly_price || 0)}/md.</span><button class="btn btn-sm btn-outline-danger" onclick="deleteDelefiberProductPrice(${row.product_id})" title="Fjern"><i class="bi bi-x-lg"></i></button></div>`).join('') : '<div class="text-muted py-2">Ingen lokale standardpriser endnu.</div>';
|
||||
} catch (error) { document.getElementById('delefiberProductPricesList').innerHTML = `<div class="text-danger">${escapeHtml(error.message)}</div>`; }
|
||||
}
|
||||
|
||||
async function saveDelefiberProductPrice() {
|
||||
const productId = Number(document.getElementById('delefiberPriceProduct').value || 0);
|
||||
const amount = document.getElementById('delefiberPriceAmount').value;
|
||||
if (!productId || amount === '') { document.getElementById('detailSaveFeedback').textContent = 'Vælg produkt og pris.'; return; }
|
||||
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({product_id:productId,monthly_price:Number(amount)})});
|
||||
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke gemme standardpris.'); return; }
|
||||
document.getElementById('delefiberPriceAmount').value = '';
|
||||
await loadDelefiberProductPrices();
|
||||
}
|
||||
|
||||
async function deleteDelefiberProductPrice(productId) {
|
||||
const response = await fetch(`/api/v1/internet-connections/${connectionId}/product-prices/${productId}`, {method:'DELETE'});
|
||||
if (!response.ok) { document.getElementById('detailSaveFeedback').textContent = await extractErrorMessage(response, 'Kunne ikke fjerne standardpris.'); return; }
|
||||
await loadDelefiberProductPrices();
|
||||
}
|
||||
|
||||
function getSharedHeadOptions() {
|
||||
return (connectionOptions || []).filter((item) => item?.is_shared_head);
|
||||
return (connectionOptions || []).filter((item) => !item?.parent_id);
|
||||
}
|
||||
|
||||
async function loadRangesForSharedHead(sharedHeadId) {
|
||||
@ -1159,7 +1321,7 @@
|
||||
: await loadRangesForSharedHead(headId);
|
||||
const availableRanges = filterAvailableBmcnetRanges(ranges);
|
||||
rangeSelect.innerHTML = `<option value="">Ingen IP-range endnu</option>${availableRanges.map((range) => `
|
||||
<option value="${range.id}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
|
||||
<option value="${range.id}" data-prefix="${String(range.cidr || '').split('/')[1] || ''}">${range.cidr} · ${range.available_addresses || 0} ledige · ${range.service_address || head?.address || '-'}</option>
|
||||
`).join('')}`;
|
||||
hint.textContent = availableRanges.length
|
||||
? `Der er ${availableRanges.length} helt ledige ranges på den valgte hovedforbindelse.`
|
||||
@ -1169,8 +1331,10 @@
|
||||
}
|
||||
|
||||
function populateBmcnetWizard(connection) {
|
||||
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !connection?.is_shared_head);
|
||||
if (!connection?.is_shared_head) return;
|
||||
const canCreateBmcnet = Boolean(connection && !connection.parent_id);
|
||||
document.getElementById('openBmcnetWizardBtn').classList.toggle('d-none', !canCreateBmcnet);
|
||||
if (!canCreateBmcnet) return;
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
document.getElementById('bmcnetWizardCustomerLookup').value = '';
|
||||
document.getElementById('bmcnetWizardCustomerId').value = '';
|
||||
@ -1179,7 +1343,15 @@
|
||||
document.getElementById('bmcnetWizardAddress').value = connection.address || '';
|
||||
document.getElementById('bmcnetWizardBillingInterval').value = 'monthly';
|
||||
document.getElementById('bmcnetWizardBillingDay').value = '1';
|
||||
document.getElementById('bmcnetWizardStartDate').value = new Date().toISOString().slice(0, 10);
|
||||
document.getElementById('bmcnetWizardPeriodStart').value = today;
|
||||
document.getElementById('bmcnetWizardEndDate').value = '';
|
||||
document.getElementById('bmcnetWizardNoticeDays').value = '30';
|
||||
document.getElementById('bmcnetWizardBillingDirection').value = 'forward';
|
||||
document.getElementById('bmcnetWizardAdvanceMonths').value = '1';
|
||||
document.getElementById('bmcnetWizardLeadMonths').value = '0';
|
||||
document.getElementById('bmcnetWizardFirstInvoicePolicy').value = 'start_date';
|
||||
document.getElementById('bmcnetWizardBindingMonths').value = '0';
|
||||
document.getElementById('bmcnetWizardStartDate').value = today;
|
||||
document.getElementById('bmcnetWizardInternetProduct').value = '';
|
||||
document.getElementById('bmcnetWizardIpProduct').value = '';
|
||||
document.getElementById('bmcnetWizardInternetPrice').value = '';
|
||||
@ -1197,7 +1369,7 @@
|
||||
}
|
||||
|
||||
function openBmcnetWizard() {
|
||||
if (!currentConnection?.is_shared_head) return;
|
||||
if (!currentConnection || currentConnection.parent_id) return;
|
||||
populateBmcnetWizard(currentConnection);
|
||||
if (!bmcnetWizardModal) {
|
||||
bmcnetWizardModal = new bootstrap.Modal(document.getElementById('bmcnetWizardModal'));
|
||||
@ -1214,7 +1386,21 @@
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
input.value = option.dataset.price || '';
|
||||
const local = delefiberProductPrices.find((row) => Number(row.product_id) === Number(option.value));
|
||||
input.value = local ? Number(local.monthly_price || 0) : (option.dataset.price || '');
|
||||
}
|
||||
|
||||
function selectIpProductForPrefix(prefixLength) {
|
||||
const select = document.getElementById('bmcnetWizardIpProduct');
|
||||
if (!select || !prefixLength) return;
|
||||
const product = bmcnetWizardProducts.find((item) => {
|
||||
const network = parseProductAttributes(item.attributes_json)?.network || {};
|
||||
return network.kind === 'ip_allocation' && Number(network.ip_prefix_length) === Number(prefixLength);
|
||||
});
|
||||
if (!product) return;
|
||||
select.value = String(product.id);
|
||||
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
|
||||
refreshBmcnetWizardAllocationMode();
|
||||
}
|
||||
|
||||
async function createBmcnetConnection() {
|
||||
@ -1239,7 +1425,15 @@
|
||||
address: document.getElementById('bmcnetWizardAddress').value.trim() || null,
|
||||
billing_interval: document.getElementById('bmcnetWizardBillingInterval').value || 'monthly',
|
||||
billing_day: Number(document.getElementById('bmcnetWizardBillingDay').value || 1),
|
||||
billing_direction: document.getElementById('bmcnetWizardBillingDirection').value || 'forward',
|
||||
advance_months: Number(document.getElementById('bmcnetWizardAdvanceMonths').value || 1),
|
||||
billing_lead_months: Number(document.getElementById('bmcnetWizardLeadMonths').value || 0),
|
||||
first_invoice_policy: document.getElementById('bmcnetWizardFirstInvoicePolicy').value || 'start_date',
|
||||
start_date: document.getElementById('bmcnetWizardStartDate').value,
|
||||
period_start: document.getElementById('bmcnetWizardPeriodStart').value || null,
|
||||
end_date: document.getElementById('bmcnetWizardEndDate').value || null,
|
||||
notice_period_days: Number(document.getElementById('bmcnetWizardNoticeDays').value || 0),
|
||||
binding_months: Number(document.getElementById('bmcnetWizardBindingMonths').value || 0),
|
||||
internet_product_id: internetProductId,
|
||||
internet_unit_price: document.getElementById('bmcnetWizardInternetPrice').value !== ''
|
||||
? Number(document.getElementById('bmcnetWizardInternetPrice').value)
|
||||
@ -1308,6 +1502,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAllocationBanner(connection) {
|
||||
const banner = document.getElementById('allocationBanner');
|
||||
const customerSelect = document.getElementById('allocationCustomerSelect');
|
||||
const suggestionsWrap = document.getElementById('allocationSuggestions');
|
||||
const isBmcSharedFiber = Boolean(
|
||||
connection.is_manual_shared
|
||||
|| (connection.parent_id == null
|
||||
&& connection.allocation_model === 'shared'
|
||||
&& connection.value_type === 'delefiber')
|
||||
);
|
||||
const shouldSuggestCustomer = !connection.customer_id && !isBmcSharedFiber;
|
||||
banner.classList.toggle('d-none', !shouldSuggestCustomer);
|
||||
if (!shouldSuggestCustomer) return;
|
||||
|
||||
const payload = await safeJson(
|
||||
await fetch(`/api/v1/internet-connections/${connectionId}/allocation-suggestions`),
|
||||
{ items: [] }
|
||||
);
|
||||
const suggestions = Array.isArray(payload.items) ? payload.items : [];
|
||||
const suggestedIds = new Set(suggestions.map((item) => Number(item.customer_id)));
|
||||
const suggestedOptions = suggestions.map((item) => (
|
||||
`<option value="${item.customer_id}">${escapeHtml(item.customer_name || '-')} · ${escapeHtml(item.address || '')}</option>`
|
||||
)).join('');
|
||||
const otherOptions = customerOptions
|
||||
.filter((item) => !suggestedIds.has(Number(item.id)))
|
||||
.map((item) => `<option value="${item.id}">${escapeHtml(item.name || '-')}</option>`)
|
||||
.join('');
|
||||
customerSelect.innerHTML = '<option value="">Vælg kunde…</option>'
|
||||
+ (suggestedOptions ? `<optgroup label="Forslag fra adressen">${suggestedOptions}</optgroup>` : '')
|
||||
+ `<optgroup label="Alle kunder">${otherOptions}</optgroup>`;
|
||||
suggestionsWrap.innerHTML = suggestions.length
|
||||
? suggestions.map((item) => `
|
||||
<button class="allocation-suggestion text-start" type="button" onclick="selectAllocationCustomer(${item.customer_id})">
|
||||
<span class="fw-semibold d-block">${escapeHtml(item.customer_name || '-')}</span>
|
||||
<span class="small text-muted">${escapeHtml(item.address || '')}${item.location_name ? ` · ${escapeHtml(item.location_name)}` : ''}</span>
|
||||
</button>`).join('')
|
||||
: '<span class="small text-muted">Ingen sikre kundematch på adressen. Vælg manuelt i listen.</span>';
|
||||
document.getElementById('allocationFeedback').textContent = suggestions.length > 1
|
||||
? `${suggestions.length} virksomheder er registreret på adressen. Vælg den rigtige.`
|
||||
: suggestions.length === 1 ? 'Én virksomhed matcher adressen.' : '';
|
||||
}
|
||||
|
||||
async function selectAllocationCustomer(customerId) {
|
||||
document.getElementById('allocationCustomerSelect').value = String(customerId);
|
||||
await loadAllocationSubscriptions();
|
||||
}
|
||||
|
||||
async function loadAllocationSubscriptions() {
|
||||
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
|
||||
const select = document.getElementById('allocationSubscriptionSelect');
|
||||
const customerLink = document.getElementById('allocationCustomerLink');
|
||||
customerLink.classList.toggle('d-none', !customerId);
|
||||
customerLink.href = customerId ? `/customers/${customerId}` : '#';
|
||||
if (!customerId) {
|
||||
select.disabled = true;
|
||||
select.innerHTML = '<option value="">Kun tildel kunde – intet abonnement</option>';
|
||||
return;
|
||||
}
|
||||
select.disabled = true;
|
||||
select.innerHTML = '<option value="">Henter abonnementer…</option>';
|
||||
const subscriptions = await safeJson(
|
||||
await fetch(`/api/v1/internet-connections/subscription-options?customer_id=${customerId}&status=active`),
|
||||
[]
|
||||
);
|
||||
select.innerHTML = '<option value="">Kun tildel kunde – intet abonnement</option>'
|
||||
+ subscriptions.map((item) => `<option value="${item.id}">${escapeHtml(item.subscription_number || `#${item.id}`)} · ${escapeHtml(item.product_name || '-')}</option>`).join('');
|
||||
select.disabled = false;
|
||||
document.getElementById('saveAllocationBtn').textContent = subscriptions.length
|
||||
? 'Tildel kunde / abonnement'
|
||||
: 'Tildel kunde';
|
||||
}
|
||||
|
||||
async function saveConnectionAllocation() {
|
||||
const customerId = Number(document.getElementById('allocationCustomerSelect').value || 0) || null;
|
||||
const subscriptionId = Number(document.getElementById('allocationSubscriptionSelect').value || 0) || null;
|
||||
const feedback = document.getElementById('allocationFeedback');
|
||||
const button = document.getElementById('saveAllocationBtn');
|
||||
if (!customerId) {
|
||||
feedback.className = 'small text-danger';
|
||||
feedback.textContent = 'Vælg en kunde først.';
|
||||
return;
|
||||
}
|
||||
const payload = { customer_id: customerId };
|
||||
if (subscriptionId) {
|
||||
payload.subscription_id = subscriptionId;
|
||||
payload.value_type = 'subscription';
|
||||
payload.value_label = null;
|
||||
}
|
||||
button.disabled = true;
|
||||
feedback.className = 'small text-muted';
|
||||
feedback.textContent = 'Gemmer tildelingen…';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Tildelingen kunne ikke gemmes.'));
|
||||
await loadConnection();
|
||||
} catch (error) {
|
||||
feedback.className = 'small text-danger';
|
||||
feedback.textContent = error.message || 'Tildelingen kunne ikke gemmes.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConnection() {
|
||||
await loadLookups();
|
||||
|
||||
@ -1320,6 +1619,7 @@
|
||||
pricingHistoryResponse,
|
||||
contractsResponse,
|
||||
historyResponse,
|
||||
casesResponse,
|
||||
crossFieldPortsResponse,
|
||||
] = await Promise.all([
|
||||
fetch(`/api/v1/internet-connections/${connectionId}`),
|
||||
@ -1330,6 +1630,7 @@
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/pricing/history`),
|
||||
fetch('/api/v1/internet-connections/contracts'),
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/history`),
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/cases`),
|
||||
fetch(`/api/v1/internet-connections/${connectionId}/cross-field-ports`),
|
||||
]);
|
||||
|
||||
@ -1340,15 +1641,28 @@
|
||||
}
|
||||
|
||||
const connection = await connectionResponse.json();
|
||||
const ranges = await rangesResponse.json();
|
||||
const addresses = await addressesResponse.json();
|
||||
const summary = await summaryResponse.json();
|
||||
const pricing = await pricingResponse.json();
|
||||
const pricingHistory = await pricingHistoryResponse.json();
|
||||
const contracts = await contractsResponse.json();
|
||||
const history = await historyResponse.json();
|
||||
const ranges = await safeJson(rangesResponse, []);
|
||||
const addresses = await safeJson(addressesResponse, []);
|
||||
const summary = await safeJson(summaryResponse, { available: 0, in_use: 0, reserved: 0 });
|
||||
const pricing = await safeJson(pricingResponse, {});
|
||||
const pricingHistory = await safeJson(pricingHistoryResponse, []);
|
||||
const contracts = await safeJson(contractsResponse, []);
|
||||
const history = await safeJson(historyResponse, []);
|
||||
const cases = await safeJson(casesResponse, []);
|
||||
const crossFieldPorts = await safeJson(crossFieldPortsResponse, { items: [], summary: {} });
|
||||
|
||||
const failedSections = [
|
||||
[rangesResponse, 'IP-ranges'], [addressesResponse, 'IP-adresser'], [summaryResponse, 'IP-oversigt'],
|
||||
[pricingResponse, 'priser'], [pricingHistoryResponse, 'prishistorik'], [contractsResponse, 'kontrakter'],
|
||||
[historyResponse, 'historik'], [crossFieldPortsResponse, 'krydsfelt'],
|
||||
[casesResponse, 'sager'],
|
||||
].filter(([response]) => !response.ok).map(([, label]) => label);
|
||||
if (failedSections.length) {
|
||||
const feedback = document.getElementById('detailSaveFeedback');
|
||||
feedback.className = 'small text-danger';
|
||||
feedback.textContent = `Kunne ikke hente: ${failedSections.join(', ')}.`;
|
||||
}
|
||||
|
||||
currentConnection = connection;
|
||||
currentAddresses = Array.isArray(addresses) ? addresses : [];
|
||||
currentRanges = Array.isArray(ranges) ? ranges : [];
|
||||
@ -1356,15 +1670,35 @@
|
||||
? await safeJson(await fetch(`/api/v1/internet-connections/${connectionId}/children?bmcnet_only=true`), [])
|
||||
: [];
|
||||
|
||||
await renderAllocationBanner(connection);
|
||||
|
||||
renderCore(connection, pricing, summary);
|
||||
renderRanges(currentRanges);
|
||||
renderAddresses(currentAddresses);
|
||||
renderPricing(pricing, pricingHistory);
|
||||
renderHistory(history);
|
||||
renderConnectionCases(cases);
|
||||
renderRelationGrid(connection);
|
||||
renderBmcnetChildren(connection, currentBmcnetChildren);
|
||||
await loadDelefiberProductPrices();
|
||||
renderContractsOverview(contracts);
|
||||
renderCrossFieldPorts(crossFieldPorts);
|
||||
const createCaseUrl = `/sag/new?internet_connection_id=${encodeURIComponent(connectionId)}`;
|
||||
document.getElementById('createCaseForConnectionBtn').href = createCaseUrl;
|
||||
document.getElementById('createCaseForConnectionPanelBtn').href = createCaseUrl;
|
||||
}
|
||||
|
||||
function renderConnectionCases(cases) {
|
||||
const list = document.getElementById('connectionCasesList');
|
||||
if (!Array.isArray(cases) || !cases.length) {
|
||||
list.innerHTML = '<div class="text-muted">Ingen sager er knyttet direkte til forbindelsen endnu.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = cases.map(item => `
|
||||
<a class="d-flex justify-content-between align-items-center gap-3 border rounded-3 p-3 mb-2 text-decoration-none text-reset" href="/sag/${item.id}/v3">
|
||||
<div><div class="fw-semibold">SAG-${item.id} · ${escapeHtml(item.titel || 'Uden titel')}</div><div class="small text-muted">${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.responsible_name || 'Ikke tildelt')} · ændret ${formatDateTime(item.updated_at)}</div></div>
|
||||
<span class="badge text-bg-light border">${escapeHtml(item.status || '-')}</span>
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
function renderCrossFieldPorts(payload) {
|
||||
@ -1406,6 +1740,7 @@
|
||||
|
||||
function renderCore(connection, pricing, summary) {
|
||||
document.getElementById('detailName').textContent = connection.name || 'Unavngiven forbindelse';
|
||||
document.querySelector('#detailCircuitBadge span').textContent = connection.circuit_number || 'Intet kredsløbsnummer';
|
||||
const ownerText = connection.customer_name || 'Ingen kunde koblet på';
|
||||
const businessText = connection.allocation_model === 'shared'
|
||||
? `Ejer: ${ownerText} · kunder fordeles på ranges/IP'er`
|
||||
@ -1420,19 +1755,23 @@
|
||||
const childConnections = Array.isArray(currentBmcnetChildren) ? currentBmcnetChildren : [];
|
||||
const totalDown = Number(connection.download_mbps || 0);
|
||||
const totalUp = Number(connection.upload_mbps || 0);
|
||||
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || child.speed_mbps || 0), 0);
|
||||
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || child.speed_mbps || 0), 0);
|
||||
// Only explicit child allocations count as consumed capacity. A nominal
|
||||
// connection speed is not proof that bandwidth was allocated to a customer.
|
||||
const usedDown = childConnections.reduce((sum, child) => sum + Number(child.download_mbps || 0), 0);
|
||||
const usedUp = childConnections.reduce((sum, child) => sum + Number(child.upload_mbps || 0), 0);
|
||||
const downPct = totalDown > 0 ? Math.round((usedDown / totalDown) * 100) : 0;
|
||||
const upPct = totalUp > 0 ? Math.round((usedUp / totalUp) * 100) : 0;
|
||||
document.getElementById('detailSubtitle').textContent = `${connection.provider || 'Ingen leverandør'} · ${businessText}`;
|
||||
document.getElementById('metricSales').textContent = formatDKK(effectiveSales);
|
||||
document.getElementById('metricPurchase').textContent = formatDKK(effectivePurchase);
|
||||
document.getElementById('metricMargin').textContent = formatDKK(effectiveMargin);
|
||||
document.getElementById('metricIps').textContent = String((summary.in_use || 0) + (summary.reserved || 0));
|
||||
document.getElementById('metricBandwidth').textContent = `${usedDown} / ${totalDown || 0} Mbps`;
|
||||
document.getElementById('metricBandwidthSub').textContent = totalUp
|
||||
? `Upload ${usedUp} / ${totalUp} Mbps · ${downPct}% ned / ${upPct}% op`
|
||||
: `${downPct}%`;
|
||||
document.getElementById('metricIps').textContent = String((summary.available || 0) + (summary.in_use || 0) + (summary.reserved || 0));
|
||||
document.getElementById('metricBandwidth').textContent = childConnections.length
|
||||
? `${usedDown} Mbps allokeret`
|
||||
: '0 Mbps allokeret';
|
||||
document.getElementById('metricBandwidthSub').textContent = totalDown || totalUp
|
||||
? `Kapacitet ${totalDown || '-'} / ${totalUp || '-'} Mbps · ${downPct}% ned / ${upPct}% op${childConnections.length ? '' : ' · Ingen kundebåndbredde registreret'}`
|
||||
: 'Kapacitet ikke registreret';
|
||||
document.getElementById('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
|
||||
@ -1445,32 +1784,35 @@
|
||||
? `<a href="${escapeHtml(connection.monitoring_url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(connection.monitoring_url)}</a>`
|
||||
: '<span class="detail-read-muted">-</span>';
|
||||
|
||||
renderSla(connection);
|
||||
|
||||
document.getElementById('coreReadView').innerHTML = `
|
||||
<div class="detail-read-strip">
|
||||
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${connection.provider || '-'}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${connection.circuit_number || '-'}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Binding</span><span class="value">${connection.allocation_model_label || '-'} · ${connection.value_type_label || '-'}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${connection.connection_type || '-'}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${connection.technology || '-'}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Leverandør</span><span class="value">${escapeHtml(connection.provider || '-')}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Kredsløb</span><span class="value">${escapeHtml(connection.circuit_number || '-')}</span></div>
|
||||
${connection.is_shared_head ? `<div class="detail-read-chip"><span class="label">Netværksmodel</span><span class="value">Delt hovedforbindelse${connection.value_type_label ? ` · ${escapeHtml(connection.value_type_label)}` : ''}</span></div>` : ''}
|
||||
<div class="detail-read-chip"><span class="label">Type</span><span class="value">${escapeHtml(connection.connection_type || '-')}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Teknologi</span><span class="value">${escapeHtml(connection.technology || '-')}</span></div>
|
||||
<div class="detail-read-chip"><span class="label">Hastighed</span><span class="value">${connection.download_mbps || 0}/${connection.upload_mbps || 0} Mbps</span></div>
|
||||
</div>
|
||||
<div class="detail-read-grid">
|
||||
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${connection.name || '-'}</div></div>
|
||||
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${connection.address || '-'}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${connection.customer_name || '-'}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${connection.parent_name || '-'}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Abonnement</span><div class="value">${subscriptionText}</div></div>
|
||||
<div class="detail-read-row full"><span class="label">Navn</span><div class="value">${escapeHtml(connection.name || '-')}</div></div>
|
||||
<div class="detail-read-row full"><span class="label">Adresse</span><div class="value">${escapeHtml(connection.address || '-')}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Kunde</span><div class="value">${escapeHtml(connection.customer_name || '-')}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Parent</span><div class="value">${escapeHtml(connection.parent_name || '-')}</div></div>
|
||||
<div class="detail-read-row"><span class="label">${connection.subscription_number ? 'Abonnement' : 'Klassifikation'}</span><div class="value">${escapeHtml(subscriptionText)}</div></div>
|
||||
<div class="detail-read-row"><span class="label">Kontrakt</span><div class="value">${contractText}</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">Nominel hastighed</span><div class="value">${connection.speed_mbps ? `${connection.speed_mbps} 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 full"><span class="label">Noter</span><div class="value">${connection.notes || '<span class="detail-read-muted">-</span>'}</div></div>
|
||||
<div class="detail-read-row"><span class="label">SLA</span><div class="value">${connection.sla_subscription_number ? `${escapeHtml(connection.sla_product_name || 'SLA')} · ${formatDKK(connection.sla_price || 0)}` : '<span class="text-danger fw-semibold">Ingen SLA-aftale</span>'}</div></div>
|
||||
<div class="detail-read-row full"><span class="label">Noter</span><div class="value">${connection.notes ? escapeHtml(connection.notes) : '<span class="detail-read-muted">-</span>'}</div></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
setStatusBadge(connection.status);
|
||||
|
||||
document.getElementById('fieldName').value = connection.name || '';
|
||||
document.getElementById('fieldProvider').value = connection.provider || '';
|
||||
document.getElementById('fieldVendorId').value = connection.vendor_id || '';
|
||||
document.getElementById('fieldCircuit').value = connection.circuit_number || '';
|
||||
document.getElementById('fieldAddress').value = connection.address || '';
|
||||
setLookupValue('fieldCustomerLookup', 'fieldCustomerId', customerOptions, connection.customer_id);
|
||||
@ -1484,6 +1826,7 @@
|
||||
document.getElementById('fieldMonitoringUrl').value = connection.monitoring_url || '';
|
||||
document.getElementById('fieldAllocationModel').value = connection.allocation_model || 'dedicated';
|
||||
document.getElementById('fieldValueType').value = connection.value_type || 'other';
|
||||
document.getElementById('fieldManualShared').checked = Boolean(connection.is_manual_shared);
|
||||
document.getElementById('fieldValueLabel').value = connection.value_label || '';
|
||||
setSubscriptionValue(connection.subscription_id);
|
||||
document.getElementById('fieldContractStart').value = connection.contract_start || '';
|
||||
@ -1493,6 +1836,66 @@
|
||||
toggleCoreEdit(false, true);
|
||||
}
|
||||
|
||||
function renderSla(connection) {
|
||||
const banner = document.getElementById('slaBanner');
|
||||
const content = document.getElementById('slaBannerContent');
|
||||
const select = document.getElementById('slaSubscriptionSelect');
|
||||
const customerId = Number(connection.customer_id || 0);
|
||||
const isBmcSharedFiber = Boolean(
|
||||
connection.is_manual_shared
|
||||
|| (connection.parent_id == null && connection.allocation_model === 'shared' && connection.value_type === 'delefiber')
|
||||
);
|
||||
if (isBmcSharedFiber) {
|
||||
banner.className = 'd-none';
|
||||
return;
|
||||
}
|
||||
const options = subscriptionOptions.filter((item) =>
|
||||
Number(item.customer_id) === customerId && /\bsla\b/i.test(String(item.product_name || ''))
|
||||
);
|
||||
if (connection.sla_subscription_id && !options.some((item) => Number(item.id) === Number(connection.sla_subscription_id))) {
|
||||
options.unshift({
|
||||
id: connection.sla_subscription_id,
|
||||
product_name: connection.sla_product_name,
|
||||
subscription_number: connection.sla_subscription_number,
|
||||
price: connection.sla_price,
|
||||
status: connection.sla_status,
|
||||
});
|
||||
}
|
||||
select.innerHTML = '<option value="">Ingen SLA-aftale</option>' + options.map((item) =>
|
||||
`<option value="${item.id}" ${Number(item.id) === Number(connection.sla_subscription_id) ? 'selected' : ''}>${escapeHtml(item.product_name || 'SLA')} · ${escapeHtml(item.subscription_number || `#${item.id}`)} · ${formatDKK(item.price || 0)}</option>`
|
||||
).join('');
|
||||
select.disabled = !customerId;
|
||||
const hasSla = Boolean(connection.sla_subscription_id);
|
||||
const priceOk = Number(connection.sla_price || 0) > 0;
|
||||
const statusOk = connection.sla_status === 'active';
|
||||
banner.className = `alert mb-4 ${hasSla && priceOk && statusOk ? 'alert-success' : 'alert-warning'}`;
|
||||
content.innerHTML = hasSla
|
||||
? `<div class="fw-bold"><i class="bi bi-shield-check me-2"></i>${escapeHtml(connection.sla_product_name || 'SLA-aftale')}</div><div class="small mt-1">${escapeHtml(connection.sla_subscription_number || '')} · Pris ${formatDKK(connection.sla_price || 0)} · ${priceOk ? 'Pris registreret' : '<strong>Prisen skal kontrolleres</strong>'}${statusOk ? '' : ' · <strong>SLA-aftalen er ikke aktiv</strong>'}</div>`
|
||||
: `<div class="fw-bold"><i class="bi bi-shield-exclamation me-2"></i>Ingen SLA-aftale</div><div class="small mt-1">${customerId ? (options.length ? 'Vælg kundens SLA-aftale.' : 'Kunden har ingen aktiv SLA-aftale, der kan allokeres.') : 'Tildel først forbindelsen til en kunde.'}</div>`;
|
||||
}
|
||||
|
||||
async function saveSlaAllocation() {
|
||||
const value = Number(document.getElementById('slaSubscriptionSelect').value || 0) || null;
|
||||
const feedback = document.getElementById('slaFeedback');
|
||||
const button = document.getElementById('saveSlaBtn');
|
||||
button.disabled = true;
|
||||
feedback.textContent = 'Gemmer SLA-allokering…';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/internet-connections/${connectionId}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sla_subscription_id: value }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await extractErrorMessage(response, 'SLA-allokeringen kunne ikke gemmes.'));
|
||||
feedback.textContent = 'SLA-allokeringen er gemt.';
|
||||
await loadConnection();
|
||||
} catch (error) {
|
||||
feedback.className = 'small mt-2 text-danger';
|
||||
feedback.textContent = error.message;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCoreEdit(force, silent = false) {
|
||||
coreEditMode = Boolean(force);
|
||||
document.getElementById('coreReadView').classList.toggle('d-none', coreEditMode);
|
||||
@ -1512,27 +1915,38 @@
|
||||
const mismatchedRanges = ranges.filter((range) => range.belongs_to_connection === false);
|
||||
|
||||
if (!visibleRanges.length && !mismatchedRanges.length) {
|
||||
list.innerHTML = '<div class="text-muted">Ingen IP-ranges registreret endnu.</div>';
|
||||
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>';
|
||||
rangeSelect.innerHTML = '<option value="">Ingen ranges</option>';
|
||||
return;
|
||||
}
|
||||
|
||||
const visibleMarkup = visibleRanges.map((range) => `
|
||||
<div class="detail-grid-card" id="rangeCard-${range.id}">
|
||||
<div class="detail-grid-card ip-range-card" id="rangeCard-${range.id}">
|
||||
<div class="ip-range-main">
|
||||
<span class="label">${range.name || 'Range'}</span>
|
||||
<div class="value">${range.cidr || '-'}</div>
|
||||
<div class="small text-muted mt-2">Brugbare: ${range.usable_hosts || 0} · Brugt: ${range.used_addresses || 0} · Ledige: ${range.available_addresses || 0}</div>
|
||||
${range.customer_name ? `<div class="small text-muted mt-1">Kunde: ${range.customer_name}</div>` : ''}
|
||||
${range.provider_reference ? `<div class="small text-muted mt-1">Ref: ${range.provider_reference}</div>` : ''}
|
||||
${range.contract_number ? `<div class="small text-muted mt-1">Kontrakt: ${range.contract_number}</div>` : ''}
|
||||
${range.service_address ? `<div class="small text-muted mt-1">${range.service_address}</div>` : ''}
|
||||
${(Number(range.monthly_cost || 0) || Number(range.sales_price || 0)) ? `<div class="small text-muted mt-1">Kost ${formatDKK(range.monthly_cost || 0)} · Salg ${formatDKK(range.sales_price || 0)}</div>` : ''}
|
||||
${range.description ? `<div class="small text-muted mt-1">${range.description}</div>` : ''}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<div class="value ip-range-cidr"><i class="bi bi-globe2"></i>${range.cidr || '-'}</div>
|
||||
<div class="small text-muted mt-1">${range.service_address || 'Ingen serviceadresse'}</div>
|
||||
</div>
|
||||
<div class="ip-range-meta">
|
||||
<span class="label">Adresser</span>
|
||||
<div class="value">${range.used_addresses || 0} brugt · ${range.available_addresses || 0} ledige</div>
|
||||
<div class="small text-muted">${range.usable_hosts || 0} brugbare i alt</div>
|
||||
</div>
|
||||
<div class="ip-range-meta">
|
||||
<span class="label">Reference</span>
|
||||
<div class="value">${range.provider_reference || '-'}</div>
|
||||
<div class="small text-muted">Kontrakt ${range.contract_number || '-'}</div>
|
||||
</div>
|
||||
<div class="ip-range-meta">
|
||||
<span class="label">Økonomi</span>
|
||||
<div class="value">${formatDKK(range.monthly_cost || 0)} kost</div>
|
||||
<div class="small text-muted">${formatDKK(range.sales_price || 0)} salg${range.customer_name ? ` · ${range.customer_name}` : ''}</div>
|
||||
</div>
|
||||
<div class="ip-range-actions d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" type="button" onclick="toggleRangeEdit(${range.id}, true)">Rediger</button>
|
||||
</div>
|
||||
<div class="small text-muted mt-2" id="rangeFeedback-${range.id}"></div>
|
||||
<div class="d-none" id="rangeEdit-${range.id}">
|
||||
<div class="small text-muted ip-range-edit" id="rangeFeedback-${range.id}"></div>
|
||||
<div class="ip-range-edit d-none" id="rangeEdit-${range.id}">
|
||||
<div class="range-edit-grid">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Navn</label>
|
||||
@ -1581,15 +1995,12 @@
|
||||
`).join('');
|
||||
|
||||
const mismatchMarkup = mismatchedRanges.length ? `
|
||||
<div class="detail-grid-card" style="grid-column: 1 / -1; border-color: #f3c2c2; background: #fff8f8;">
|
||||
<span class="label text-danger">Afvigelser</span>
|
||||
<div class="small text-muted mb-2">Disse ranges matcher ikke forbindelsens adresse eller kunde og bør kontrolleres.</div>
|
||||
${mismatchedRanges.map((range) => `
|
||||
<div class="small mb-2">
|
||||
<strong>${range.cidr}</strong> · ${range.customer_name || 'Ingen kunde'}<br>
|
||||
<span class="text-danger">${range.alignment_warning || 'Matcher ikke forbindelsen'}</span>
|
||||
<div class="ip-range-warning">
|
||||
<i class="bi bi-exclamation-triangle-fill text-danger mt-1"></i>
|
||||
<div>
|
||||
<div class="fw-semibold text-danger">${mismatchedRanges.length} range${mismatchedRanges.length === 1 ? '' : 's'} kræver kontrol</div>
|
||||
${mismatchedRanges.map((range) => `<div class="small mt-1"><strong>${range.cidr}</strong> · ${range.alignment_warning || 'Matcher ikke forbindelsen'}</div>`).join('')}
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
@ -1661,7 +2072,7 @@
|
||||
});
|
||||
|
||||
if (!filtered.length) {
|
||||
body.innerHTML = '<tr><td colspan="5" class="text-muted py-4">Ingen IP-adresser matcher filtrene.</td></tr>';
|
||||
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>';
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1913,7 +2324,7 @@
|
||||
document.getElementById('fieldSubscriptionId').value = resolveSubscriptionId(document.getElementById('fieldSubscriptionLookup').value) || '';
|
||||
const payload = {
|
||||
name: document.getElementById('fieldName').value.trim(),
|
||||
provider: document.getElementById('fieldProvider').value.trim() || null,
|
||||
vendor_id: Number(document.getElementById('fieldVendorId').value || 0) || null,
|
||||
circuit_number: document.getElementById('fieldCircuit').value.trim() || null,
|
||||
address: document.getElementById('fieldAddress').value.trim() || null,
|
||||
customer_id: Number(document.getElementById('fieldCustomerId').value || 0) || null,
|
||||
@ -1929,6 +2340,7 @@
|
||||
value_type: document.getElementById('fieldValueType').value || 'other',
|
||||
value_label: document.getElementById('fieldValueLabel').value.trim() || null,
|
||||
subscription_id: Number(document.getElementById('fieldSubscriptionId').value || 0) || null,
|
||||
is_manual_shared: document.getElementById('fieldManualShared').checked,
|
||||
contract_start: document.getElementById('fieldContractStart').value || null,
|
||||
contract_end: document.getElementById('fieldContractEnd').value || null,
|
||||
notes: document.getElementById('fieldNotes').value.trim() || null,
|
||||
@ -1976,6 +2388,19 @@
|
||||
document.getElementById('fieldSubscriptionWrap').classList.toggle('d-none', valueType !== 'subscription');
|
||||
}
|
||||
|
||||
function toggleManualDelefiber() {
|
||||
const enabled = document.getElementById('fieldManualShared').checked;
|
||||
if (enabled) {
|
||||
document.getElementById('fieldAllocationModel').value = 'shared';
|
||||
document.getElementById('fieldValueType').value = 'delefiber';
|
||||
} else if (document.getElementById('fieldValueType').value === 'delefiber') {
|
||||
document.getElementById('fieldAllocationModel').value = 'dedicated';
|
||||
document.getElementById('fieldValueType').value = 'other';
|
||||
document.getElementById('fieldValueLabel').value = 'Internetforbindelse';
|
||||
}
|
||||
toggleValueFields();
|
||||
}
|
||||
|
||||
async function createRange() {
|
||||
const feedback = document.getElementById('rangeCreateFeedback');
|
||||
const button = document.getElementById('createRangeButton');
|
||||
@ -2039,7 +2464,7 @@
|
||||
assigned_connection_id: Number(document.getElementById('addressAssignedConnectionIdInput').value || 0) || null,
|
||||
};
|
||||
if (!payload.range_id || !payload.ip_address) {
|
||||
alert('Range og IP-adresse er påkrævet');
|
||||
showDetailMessage('Range og IP-adresse er påkrævet.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -2049,7 +2474,7 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
alert('Kunne ikke oprette IP-adresse');
|
||||
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke oprette IP-adresse.'), true);
|
||||
return;
|
||||
}
|
||||
document.getElementById('addressIpInput').value = '';
|
||||
@ -2090,7 +2515,7 @@
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
alert('Kunne ikke opdatere IP-adressen');
|
||||
showDetailMessage(await extractErrorMessage(response, 'Kunne ikke opdatere IP-adressen.'), true);
|
||||
return;
|
||||
}
|
||||
editingAddressId = null;
|
||||
@ -2150,6 +2575,14 @@
|
||||
syncBmcnetWizardPrice('bmcnetWizardIpProduct', 'bmcnetWizardIpPrice');
|
||||
await refreshBmcnetWizardAllocationMode();
|
||||
});
|
||||
document.getElementById('bmcnetWizardRangeSelect').addEventListener('change', (event) => {
|
||||
const option = event.target.options[event.target.selectedIndex];
|
||||
selectIpProductForPrefix(option?.dataset?.prefix);
|
||||
});
|
||||
document.getElementById('bmcnetWizardIpSelect').addEventListener('change', () => {
|
||||
// A single selected public address is sold as a Static WAN IP.
|
||||
if (document.getElementById('bmcnetWizardIpSelect').value) selectIpProductForPrefix(32);
|
||||
});
|
||||
document.getElementById('bmcnetWizardAddress').addEventListener('change', async () => {
|
||||
await refreshBmcnetWizardAllocationMode();
|
||||
});
|
||||
|
||||
@ -137,6 +137,33 @@
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.quick-customer-results {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 12px 12px;
|
||||
background: var(--bg-card);
|
||||
}
|
||||
|
||||
.quick-customer-result {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.quick-customer-result:hover { background: rgba(15, 76, 117, 0.09); }
|
||||
|
||||
.quick-type-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:.75rem; }
|
||||
.quick-type-choice { border:2px solid var(--border-color); border-radius:14px; padding:1rem; background:var(--bg-card); color:var(--text-primary); text-align:left; }
|
||||
.quick-type-choice:hover { border-color:#0f4c75; background:rgba(15,76,117,.06); }
|
||||
.quick-type-choice.active { border-color:#0f4c75; background:#0f4c75; color:#fff; box-shadow:0 8px 22px rgba(15,76,117,.22); }
|
||||
.quick-type-choice.active .text-muted { color:rgba(255,255,255,.8)!important; }
|
||||
|
||||
.invoice-sync-status {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
@ -173,14 +200,14 @@
|
||||
<div class="text-muted">Samlet overblik over forbindelser, kunder, IP-adresser, kontrakter og dækningsbidrag.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<a class="btn btn-outline-danger" href="#invoiceProcessingOverview">
|
||||
<i class="bi bi-receipt-cutoff me-1"></i>Fakturabehandling
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="loadInternetPage()">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="refreshActiveTab()">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Opdater
|
||||
</button>
|
||||
<button class="btn btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#createConnectionBlock">
|
||||
<i class="bi bi-plus-lg me-1"></i>Ny forbindelse
|
||||
<button class="btn btn-outline-primary" type="button" data-bs-toggle="modal" data-bs-target="#ipNordicImportModal">
|
||||
<i class="bi bi-file-earmark-spreadsheet me-1"></i>Importér IP Nordic
|
||||
</button>
|
||||
<button class="btn btn-primary" id="newConnectionBtn" type="button" data-bs-toggle="modal" data-bs-target="#quickSetupModal">
|
||||
<i class="bi bi-plus-lg me-1"></i>Opret eller tilknyt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -188,10 +215,13 @@
|
||||
<button class="internet-tab active" type="button" id="tabAll" onclick="setActiveTab('all')">Alle forbindelser</button>
|
||||
<button class="internet-tab" type="button" id="tabShared" onclick="setActiveTab('shared')">Delte hovedforbindelser</button>
|
||||
<button class="internet-tab" type="button" id="tabBmcnet" onclick="setActiveTab('bmcnet')">BMCnet</button>
|
||||
<button class="internet-tab" type="button" id="tabDedicated" onclick="setActiveTab('dedicated')">Dedikerede</button>
|
||||
<button class="internet-tab" type="button" id="tabUnallocated" onclick="setActiveTab('unallocated')">Ikke allokeret</button>
|
||||
<button class="internet-tab" type="button" id="tabInvoices" onclick="setActiveTab('invoices')">Behandlede internetfakturaer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="row g-3 mb-4" id="connectionsMetrics">
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Forbindelser</div>
|
||||
@ -200,7 +230,7 @@
|
||||
</div>
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="internet-kpi">
|
||||
<div class="internet-kpi-label">Aktive</div>
|
||||
<div class="internet-kpi-label">Aktive / afventer</div>
|
||||
<div class="internet-kpi-value" id="metricActive">0</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -232,7 +262,7 @@
|
||||
<input type="text" class="form-control" id="connectionNameInput" placeholder="Navn" />
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="text" class="form-control" id="connectionProviderInput" placeholder="Leverandør" />
|
||||
<select class="form-select" id="connectionVendorInput"><option value="">Vælg internetleverandør</option></select>
|
||||
</div>
|
||||
<div class="col-lg-2">
|
||||
<input type="number" class="form-control" id="connectionCustomerIdInput" placeholder="Kunde-ID" />
|
||||
@ -296,13 +326,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="internet-panel p-4 mb-4">
|
||||
<div class="internet-panel p-4 mb-4" id="connectionsOverview">
|
||||
<div class="internet-toolbar mb-3">
|
||||
<input type="search" class="form-control" id="searchInput" placeholder="Søg navn, kunde, leverandør, kredsløb eller adresse" />
|
||||
<input type="text" class="form-control" id="providerFilter" placeholder="Filtrer leverandør" />
|
||||
<select class="form-select" id="statusFilter">
|
||||
<option value="">Alle statusser</option>
|
||||
<option value="active">Aktive</option>
|
||||
<option value="pending">Afventer kontrol</option>
|
||||
<option value="planned">Planlagte</option>
|
||||
<option value="inactive">Inaktive</option>
|
||||
<option value="terminated">Opsagte</option>
|
||||
@ -349,13 +380,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="internet-panel p-4 mb-4" id="invoiceProcessingOverview">
|
||||
<div class="internet-panel p-4 mb-4 d-none" id="invoiceProcessingOverview">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-3">
|
||||
<div>
|
||||
<h3 class="h5 mb-1">Behandlede internetfakturaer</h3>
|
||||
<div class="internet-mini">GlobalConnect-fakturaer, oprettede/opdaterede forbindelser, IP-ranges og behandlingsfejl.</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<span class="internet-mini align-self-center" id="invoiceReconcileFeedback" role="status"></span>
|
||||
<select class="form-select form-select-sm" id="invoiceSyncStatusFilter" onchange="loadInvoiceSyncRuns()">
|
||||
<option value="">Alle resultater</option>
|
||||
<option value="success">Gennemført</option>
|
||||
@ -364,6 +396,7 @@
|
||||
<option value="skipped">Sprunget over</option>
|
||||
<option value="not_logged">Ældre uden detaljer</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" id="invoiceReconcileBtn" type="button" onclick="reconcileInvoiceSyncRuns()"><i class="bi bi-arrow-clockwise me-1"></i>Genkontrollér</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="loadInvoiceSyncRuns()"><i class="bi bi-arrow-repeat me-1"></i>Opdater</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -382,6 +415,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-type-grid mb-3">
|
||||
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
|
||||
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
|
||||
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
|
||||
<div class="row g-3 d-none mt-1" id="quickSetupFields">
|
||||
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
|
||||
<div class="col-12" id="quickCustomerWrap">
|
||||
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
|
||||
<input type="hidden" id="quickCustomerId">
|
||||
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
|
||||
</div>
|
||||
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
|
||||
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
|
||||
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
|
||||
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
|
||||
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
|
||||
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
|
||||
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
|
||||
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
|
||||
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
|
||||
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
|
||||
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
|
||||
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
|
||||
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
|
||||
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
|
||||
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
|
||||
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
|
||||
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
|
||||
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
|
||||
<tbody id="ipNordicPreviewBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
|
||||
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
|
||||
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
@ -391,6 +500,10 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info small">Vælg en eksisterende forbindelse, opret en separat afventende forbindelse, eller ignorér linjen med en begrundelse. Fakturaen markeres gennemført, når alle kontrollinjer er afklaret.</div>
|
||||
<div class="form-check form-switch mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="showResolvedInvoiceLines" onchange="toggleResolvedInvoiceLines()">
|
||||
<label class="form-check-label" for="showResolvedInvoiceLines">Vis allerede løste linjer</label>
|
||||
</div>
|
||||
<div id="invoiceReviewLines"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -402,6 +515,70 @@
|
||||
let invoiceSyncItems = [];
|
||||
let activeTab = 'all';
|
||||
let subscriptionOptions = [];
|
||||
let quickCustomerOptions = [];
|
||||
let quickProductOptions = [];
|
||||
let quickCustomerTimer = null;
|
||||
let quickSetupType = null;
|
||||
let quickSharedHeadOptions = [];
|
||||
let ipNordicPreview = null;
|
||||
let allocationSuggestionsByConnection = new Map();
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>'"]/g, (char) => ({
|
||||
'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'
|
||||
}[char]));
|
||||
}
|
||||
|
||||
async function runIpNordicImport(commit) {
|
||||
const input = document.getElementById('ipNordicFileInput');
|
||||
const feedback = document.getElementById('ipNordicImportFeedback');
|
||||
const previewButton = document.getElementById('ipNordicPreviewBtn');
|
||||
const commitButton = document.getElementById('ipNordicCommitBtn');
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
feedback.className = 'small mt-2 text-danger';
|
||||
feedback.textContent = 'Vælg først en Excel-fil.';
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('commit', commit ? 'true' : 'false');
|
||||
previewButton.disabled = true;
|
||||
commitButton.disabled = true;
|
||||
feedback.className = 'small mt-2 text-muted';
|
||||
feedback.textContent = commit ? 'Importerer...' : 'Kontrollerer filen...';
|
||||
try {
|
||||
const response = await fetch('/api/v1/internet-connections/import/ip-nordic', { method: 'POST', body: formData });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || 'Importen kunne ikke gennemføres.');
|
||||
ipNordicPreview = payload;
|
||||
document.getElementById('ipNordicPreviewWrap').classList.remove('d-none');
|
||||
document.getElementById('ipNordicPreviewBody').innerHTML = (payload.items || []).map((item) => `
|
||||
<tr>
|
||||
<td>${item.action === 'skip' ? `<span class="badge text-bg-secondary">Findes #${item.existing_connection_id}</span>` : '<span class="badge text-bg-success">Ny</span>'}</td>
|
||||
<td>${escapeHtml(item.reported_company || '-')}<div class="internet-mini">Nr. ${escapeHtml(item.company_number || '-')} · ${Number(item.line_count || 0)} linjer</div></td>
|
||||
<td>${escapeHtml(item.address || '-')}</td>
|
||||
<td>${escapeHtml(item.start_date || '-')}</td>
|
||||
<td class="text-end">${formatDKK(item.monthly_cost)}</td>
|
||||
<td class="text-end">${formatDKK(item.sales_price)}</td>
|
||||
</tr>`).join('');
|
||||
feedback.className = 'small mt-2 text-success';
|
||||
feedback.textContent = commit
|
||||
? `${payload.created_count} forbindelser oprettet, ${payload.skipped_count} eksisterende sprunget over.`
|
||||
: `${payload.create_count} nye og ${payload.existing_count} eksisterende forbindelser fundet.`;
|
||||
commitButton.classList.toggle('d-none', commit || payload.create_count === 0);
|
||||
if (commit) await loadInternetPage();
|
||||
} catch (error) {
|
||||
feedback.className = 'small mt-2 text-danger';
|
||||
feedback.textContent = error.message || 'Importen kunne ikke gennemføres.';
|
||||
} finally {
|
||||
previewButton.disabled = false;
|
||||
commitButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function previewIpNordicImport() { return runIpNordicImport(false); }
|
||||
function commitIpNordicImport() { return runIpNordicImport(true); }
|
||||
|
||||
function formatDKK(value) {
|
||||
return Number(value || 0).toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
|
||||
@ -431,17 +608,19 @@
|
||||
|
||||
function allocationBadge(item) {
|
||||
const label = item.allocation_model_label || (item.allocation_model === 'shared' ? 'Delt' : 'Dedikeret');
|
||||
return `<span class="internet-tag">${label}</span>`;
|
||||
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function valueBadge(item) {
|
||||
const label = item.value_type_label || 'Anden';
|
||||
return `<span class="internet-tag">${label}</span>`;
|
||||
return `<span class="internet-tag">${escapeHtml(label)}</span>`;
|
||||
}
|
||||
|
||||
function currentTabDescription() {
|
||||
if (activeTab === 'shared') return ' i delte hovedforbindelser';
|
||||
if (activeTab === 'bmcnet') return ' i BMCnet';
|
||||
if (activeTab === 'dedicated') return ' i dedikerede forbindelser';
|
||||
if (activeTab === 'unallocated') return ' uden allokeret kunde';
|
||||
return '';
|
||||
}
|
||||
|
||||
@ -551,6 +730,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function reconcileInvoiceSyncRuns() {
|
||||
const button = document.getElementById('invoiceReconcileBtn');
|
||||
const feedback = document.getElementById('invoiceReconcileFeedback');
|
||||
button.disabled = true;
|
||||
feedback.className = 'internet-mini align-self-center text-muted';
|
||||
feedback.textContent = 'Genkontrollerer…';
|
||||
try {
|
||||
const response = await fetch('/api/v1/internet-connections/invoice-sync-runs/reconcile', { method: 'POST' });
|
||||
if (!response.ok) throw new Error(await extractErrorMessage(response, 'Genkontrollen fejlede.'));
|
||||
const payload = await response.json();
|
||||
feedback.className = 'internet-mini align-self-center text-success';
|
||||
feedback.textContent = `${Number(payload.resolved_lines || 0)} linjer løst`;
|
||||
await loadInvoiceSyncRuns();
|
||||
} catch (error) {
|
||||
feedback.className = 'internet-mini align-self-center text-danger';
|
||||
feedback.textContent = error.message || 'Genkontrollen fejlede.';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function reviewConnectionOptions(selectedId = '') {
|
||||
return '<option value="">Vælg forbindelse…</option>' + allConnections
|
||||
.filter(item => String(item.provider || '').toLowerCase().includes('globalconnect'))
|
||||
@ -558,14 +758,33 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
function openInvoiceReview(runId) {
|
||||
const item = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
|
||||
function toggleResolvedInvoiceLines() {
|
||||
const show = document.getElementById('showResolvedInvoiceLines').checked;
|
||||
document.querySelectorAll('#invoiceReviewLines .resolved-review-line').forEach((element) => {
|
||||
element.classList.toggle('d-none', !show);
|
||||
});
|
||||
}
|
||||
|
||||
async function openInvoiceReview(runId) {
|
||||
const summaryItem = invoiceSyncItems.find(entry => Number(entry.run_id) === Number(runId));
|
||||
if (!summaryItem) return;
|
||||
const linesWrap = document.getElementById('invoiceReviewLines');
|
||||
linesWrap.innerHTML = '<div class="text-muted py-4">Henter kontrollinjer…</div>';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
|
||||
const response = await fetch(`/api/v1/internet-connections/invoice-sync-runs/${runId}`);
|
||||
if (!response.ok) {
|
||||
linesWrap.innerHTML = `<div class="text-danger py-4">${escapeInvoiceText(await extractErrorMessage(response, 'Kunne ikke hente kontrollinjer.'))}</div>`;
|
||||
return;
|
||||
}
|
||||
const detail = await response.json();
|
||||
const item = { ...summaryItem, ...detail };
|
||||
if (!item) return;
|
||||
const skippedItems = Array.isArray(item.result_json?.skipped_items) ? item.result_json.skipped_items : [];
|
||||
const decisions = Array.isArray(item.review_decisions) ? item.review_decisions : [];
|
||||
const decisionsByLine = new Map(decisions.map(decision => [Number(decision.line_number), decision]));
|
||||
document.getElementById('invoiceReviewNumber').textContent = item.invoice_number || '-';
|
||||
document.getElementById('invoiceReviewSummary').textContent = `${Number(item.resolved_lines || 0)} løst · ${Number(item.unresolved_lines || 0)} mangler`;
|
||||
document.getElementById('showResolvedInvoiceLines').checked = false;
|
||||
const groups = skippedItems.reduce((result, line) => {
|
||||
const reason = line.reason || 'Anden kontrol';
|
||||
(result[reason] ||= []).push(line);
|
||||
@ -577,7 +796,7 @@
|
||||
<div class="vstack gap-2">
|
||||
${lines.map(line => {
|
||||
const decision = decisionsByLine.get(Number(line.line_number));
|
||||
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75' : ''}">
|
||||
return `<div class="border rounded p-3 ${decision ? 'bg-light opacity-75 resolved-review-line d-none' : ''}">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">Linje ${Number(line.line_number)} · ${escapeInvoiceText(line.description || '-')}</div>
|
||||
@ -600,7 +819,6 @@
|
||||
</div>
|
||||
</section>
|
||||
`).join('') || '<div class="text-success">Alle kontrollinjer er løst.</div>';
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('invoiceReviewModal')).show();
|
||||
}
|
||||
|
||||
async function submitInvoiceReview(runId, lineNumber, action) {
|
||||
@ -645,22 +863,43 @@
|
||||
if (valueType) params.set('value_type', valueType);
|
||||
if (activeTab === 'shared') params.set('shared_only', 'true');
|
||||
if (activeTab === 'bmcnet') params.set('bmcnet_only', 'true');
|
||||
if (activeTab === 'dedicated') {
|
||||
params.set('allocation_model', 'dedicated');
|
||||
params.set('allocated_only', 'true');
|
||||
}
|
||||
if (activeTab === 'unallocated') params.set('unallocated_only', 'true');
|
||||
|
||||
try {
|
||||
const connectionsResponse = await fetch(`/api/v1/internet-connections?${params.toString()}`);
|
||||
|
||||
const connections = await safeJson(connectionsResponse, []);
|
||||
allConnections = Array.isArray(connections) ? connections : [];
|
||||
allocationSuggestionsByConnection = new Map();
|
||||
if (activeTab === 'unallocated') {
|
||||
const allocationResponse = await fetch('/api/v1/internet-connections/allocation-overview');
|
||||
const allocationPayload = await safeJson(allocationResponse, { items: [] });
|
||||
allocationSuggestionsByConnection = new Map(
|
||||
(allocationPayload.items || []).map((entry) => [Number(entry.connection_id), entry])
|
||||
);
|
||||
}
|
||||
const total = allConnections.length;
|
||||
const active = allConnections.filter((item) => item.status === 'active').length;
|
||||
const pending = allConnections.filter((item) => item.status === 'pending').length;
|
||||
const sharedHeads = allConnections.filter((item) => item.is_shared_head).length;
|
||||
const bmcnetConnections = allConnections.filter((item) => item.is_bmcnet_connection).length;
|
||||
const margin = allConnections.reduce((sum, item) => sum + Number(item.margin_amount || 0), 0);
|
||||
|
||||
document.getElementById('metricTotal').textContent = String(total);
|
||||
document.getElementById('metricActive').textContent = String(active);
|
||||
document.getElementById('metricSharedLabel').textContent = activeTab === 'bmcnet' ? 'BMCnet' : 'Delte hoveder';
|
||||
document.getElementById('metricShared').textContent = String(activeTab === 'bmcnet' ? bmcnetConnections : sharedHeads);
|
||||
document.getElementById('metricActive').textContent = `${active} / ${pending}`;
|
||||
const focusMetric = activeTab === 'bmcnet'
|
||||
? ['BMCnet', bmcnetConnections]
|
||||
: activeTab === 'dedicated'
|
||||
? ['Dedikerede', total]
|
||||
: activeTab === 'unallocated'
|
||||
? ['Uden kunde', total]
|
||||
: ['Delte hoveder', sharedHeads];
|
||||
document.getElementById('metricSharedLabel').textContent = focusMetric[0];
|
||||
document.getElementById('metricShared').textContent = String(focusMetric[1]);
|
||||
document.getElementById('metricMargin').textContent = formatDKK(margin);
|
||||
|
||||
renderConnections(allConnections);
|
||||
@ -679,23 +918,33 @@
|
||||
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}'">
|
||||
<td>
|
||||
<div class="fw-semibold">${item.name || '-'}</div>
|
||||
<div class="fw-semibold">${escapeHtml(item.name || '-')}</div>
|
||||
<div class="mb-1">${allocationBadge(item)}${valueBadge(item)}</div>
|
||||
<div class="internet-mini">${item.address || '-'}</div>
|
||||
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${item.parent_name}</div>` : ''}
|
||||
<div class="internet-mini">${escapeHtml(item.address || '-')}</div>
|
||||
${item.parent_name ? `<div class="internet-mini"><i class="bi bi-diagram-2 me-1"></i>Under ${escapeHtml(item.parent_name)}</div>` : ''}
|
||||
${item.is_shared_head ? `<div class="internet-mini"><i class="bi bi-diagram-3 me-1"></i>${Number(item.bmcnet_child_count || 0)} BMCnet-kunder · ${formatDKK(item.bmcnet_child_sales_price || 0)} salg</div>` : ''}
|
||||
</td>
|
||||
<td>
|
||||
<div>${item.customer_name || '-'}</div>
|
||||
<div class="internet-mini">Kunde-ID: ${item.customer_id || '-'}</div>
|
||||
<div>${escapeHtml(isOwnedSharedFiber ? 'BMC Networks' : (item.customer_name || '-'))}</div>
|
||||
<div class="internet-mini">${isOwnedSharedFiber ? 'Ejer · delt hovedfiber' : `Kunde-ID: ${item.customer_id || '-'}`}</div>
|
||||
${!isOwnedSharedFiber && uniqueSuggestion ? `<button class="btn btn-sm btn-outline-success mt-1" type="button" onclick="event.stopPropagation(); assignSuggestedCustomer(${Number(item.id)}, ${Number(uniqueSuggestion.customer_id)})"><i class="bi bi-person-check me-1"></i>${escapeHtml(uniqueSuggestion.customer_name || 'Tildel foreslået kunde')}</button>` : ''}
|
||||
${!isOwnedSharedFiber && !uniqueSuggestion && suggestionCount > 1 ? `<div class="internet-mini text-warning mt-1">${suggestionCount} adresseforslag · vælg på forbindelsen</div>` : ''}
|
||||
${isOwnedSharedFiber ? '' : item.sla_subscription_id
|
||||
? `<div class="internet-mini ${Number(item.sla_price || 0) > 0 && item.sla_status === 'active' ? 'text-success' : 'text-warning'} fw-semibold"><i class="bi bi-shield-check me-1"></i>${escapeHtml(item.sla_product_name || 'SLA')} · ${formatDKK(item.sla_price || 0)}${Number(item.sla_price || 0) > 0 ? '' : ' · Kontrollér pris'}${item.sla_status === 'active' ? '' : ' · Ikke aktiv'}</div>`
|
||||
: `<div class="internet-mini text-danger fw-semibold"><i class="bi bi-shield-exclamation me-1"></i>Ingen SLA-aftale</div>`}
|
||||
</td>
|
||||
<td>
|
||||
<div>${item.provider || '-'}</div>
|
||||
<div class="internet-mini">${item.circuit_number || 'Intet kredsløb'}</div>
|
||||
${item.subscription_number ? `<div class="internet-mini">Abonnement ${item.subscription_number} · ${item.subscription_product_name || '-'}</div>` : (item.value_label ? `<div class="internet-mini">${item.value_label}</div>` : '')}
|
||||
<div>${escapeHtml(item.provider || '-')}</div>
|
||||
<div class="internet-mini">${escapeHtml(item.circuit_number || 'Intet kredsløb')}</div>
|
||||
${item.subscription_number ? `<div class="internet-mini">Abonnement ${escapeHtml(item.subscription_number)} · ${escapeHtml(item.subscription_product_name || '-')}</div>` : (item.value_label ? `<div class="internet-mini">${escapeHtml(item.value_label)}</div>` : '')}
|
||||
</td>
|
||||
<td>
|
||||
<div>${formatSpeed(item)}</div>
|
||||
@ -713,7 +962,19 @@
|
||||
</a>
|
||||
</td>
|
||||
</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() {
|
||||
@ -721,7 +982,7 @@
|
||||
const saveButton = document.querySelector('#createConnectionBlock button.btn.btn-primary');
|
||||
const payload = {
|
||||
name: document.getElementById('connectionNameInput').value.trim(),
|
||||
provider: document.getElementById('connectionProviderInput').value.trim() || null,
|
||||
vendor_id: Number(document.getElementById('connectionVendorInput').value || 0) || null,
|
||||
customer_id: Number(document.getElementById('connectionCustomerIdInput').value || 0) || null,
|
||||
circuit_number: document.getElementById('connectionCircuitInput').value.trim() || null,
|
||||
address: document.getElementById('connectionAddressInput').value.trim() || null,
|
||||
@ -737,6 +998,8 @@
|
||||
value_type: document.getElementById('connectionValueTypeInput').value || 'other',
|
||||
value_label: document.getElementById('connectionValueLabelInput').value.trim() || null,
|
||||
subscription_id: Number(document.getElementById('connectionSubscriptionIdInput').value || 0) || null,
|
||||
is_manual_shared: document.getElementById('connectionAllocationInput').value === 'shared'
|
||||
&& document.getElementById('connectionValueTypeInput').value === 'delefiber',
|
||||
};
|
||||
|
||||
if (!payload.name) {
|
||||
@ -769,7 +1032,7 @@
|
||||
feedback.textContent = 'Forbindelse oprettet.';
|
||||
[
|
||||
'connectionNameInput',
|
||||
'connectionProviderInput',
|
||||
'connectionVendorInput',
|
||||
'connectionCustomerIdInput',
|
||||
'connectionCircuitInput',
|
||||
'connectionAddressInput',
|
||||
@ -811,7 +1074,23 @@
|
||||
document.getElementById('tabAll').classList.toggle('active', tab === 'all');
|
||||
document.getElementById('tabShared').classList.toggle('active', tab === 'shared');
|
||||
document.getElementById('tabBmcnet').classList.toggle('active', tab === 'bmcnet');
|
||||
loadInternetPage();
|
||||
document.getElementById('tabDedicated').classList.toggle('active', tab === 'dedicated');
|
||||
document.getElementById('tabUnallocated').classList.toggle('active', tab === 'unallocated');
|
||||
document.getElementById('tabInvoices').classList.toggle('active', tab === 'invoices');
|
||||
const invoiceMode = tab === 'invoices';
|
||||
document.getElementById('connectionsMetrics').classList.toggle('d-none', invoiceMode);
|
||||
document.getElementById('connectionsOverview').classList.toggle('d-none', invoiceMode);
|
||||
document.getElementById('createConnectionBlock').classList.add('collapse');
|
||||
document.getElementById('createConnectionBlock').classList.remove('show');
|
||||
document.getElementById('newConnectionBtn').classList.toggle('d-none', invoiceMode);
|
||||
document.getElementById('invoiceProcessingOverview').classList.toggle('d-none', !invoiceMode);
|
||||
if (invoiceMode) loadInvoiceSyncRuns();
|
||||
else loadInternetPage();
|
||||
}
|
||||
|
||||
function refreshActiveTab() {
|
||||
if (activeTab === 'invoices') return loadInvoiceSyncRuns();
|
||||
return loadInternetPage();
|
||||
}
|
||||
|
||||
function toggleCreateValueFields() {
|
||||
@ -845,6 +1124,315 @@
|
||||
.join('');
|
||||
}
|
||||
|
||||
async function loadInternetVendors() {
|
||||
const select = document.getElementById('connectionVendorInput');
|
||||
try {
|
||||
const response = await fetch('/api/v1/vendors?is_active=true&is_internet_provider=true&limit=100');
|
||||
const vendors = response.ok ? await response.json() : [];
|
||||
select.innerHTML = '<option value="">Vælg internetleverandør</option>' + vendors
|
||||
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
|
||||
const quickVendor = document.getElementById('quickVendor');
|
||||
if (quickVendor) quickVendor.innerHTML = '<option value="">Vælg leverandør…</option>' + vendors
|
||||
.map((vendor) => `<option value="${vendor.id}">${escapeHtml(vendor.name)}</option>`).join('');
|
||||
} catch (error) {
|
||||
select.innerHTML = '<option value="">Kunne ikke hente leverandører</option>';
|
||||
}
|
||||
}
|
||||
|
||||
function quickCustomerLabel(item) {
|
||||
return `${item.id} · ${item.name || '-'}`;
|
||||
}
|
||||
|
||||
function resolveQuickCustomerId() {
|
||||
const raw = document.getElementById('quickCustomerLookup').value.trim();
|
||||
const normalized = raw.toLowerCase();
|
||||
const match = quickCustomerOptions.find((item) =>
|
||||
quickCustomerLabel(item).toLowerCase() === normalized
|
||||
|| String(item.name || '').trim().toLowerCase() === normalized
|
||||
|| String(item.id) === raw
|
||||
);
|
||||
const id = match ? Number(match.id) : null;
|
||||
document.getElementById('quickCustomerId').value = id || '';
|
||||
if (match && !document.getElementById('quickConnectionName').value.trim()) {
|
||||
document.getElementById('quickConnectionName').value = `Internet - ${match.name}`;
|
||||
}
|
||||
if (match) {
|
||||
const customerAddress = [match.address, match.postal_code, match.city].filter(Boolean).join(', ');
|
||||
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
|
||||
suggestQuickSharedHead();
|
||||
}
|
||||
updateQuickSetupSummary();
|
||||
return id;
|
||||
}
|
||||
|
||||
async function searchQuickCustomers(query) {
|
||||
const q = String(query || '').trim();
|
||||
const results = document.getElementById('quickCustomerResults');
|
||||
if (q.length < 2) {
|
||||
quickCustomerOptions = [];
|
||||
results.innerHTML = '';
|
||||
results.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(q)}`);
|
||||
if (document.getElementById('quickCustomerLookup').value.trim() !== q) return;
|
||||
if (response.status === 401) {
|
||||
quickCustomerOptions = [];
|
||||
results.innerHTML = '<div class="p-3 text-danger"><strong>Din session er udløbet.</strong><br><a href="/login">Log ind igen</a> og åbn derefter guiden på ny.</div>';
|
||||
results.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
quickCustomerOptions = [];
|
||||
results.innerHTML = `<div class="p-3 text-danger">Kunderne kunne ikke hentes (fejl ${response.status}).</div>`;
|
||||
results.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
quickCustomerOptions = response.ok ? await response.json() : [];
|
||||
results.innerHTML = quickCustomerOptions.length
|
||||
? quickCustomerOptions.map((item) => `
|
||||
<button class="quick-customer-result" type="button" data-quick-customer-id="${item.id}">
|
||||
<strong>${escapeHtml(item.name || '-')}</strong>
|
||||
<span class="small text-muted ms-2">${item.cvr_nummer ? `CVR ${escapeHtml(item.cvr_nummer)}` : `ID ${item.id}`}</span>
|
||||
${item.address ? `<div class="small text-muted">${escapeHtml([item.address, item.postal_code, item.city].filter(Boolean).join(', '))}</div>` : ''}
|
||||
</button>`).join('')
|
||||
: '<div class="small text-muted p-3">Ingen kunder fundet</div>';
|
||||
results.classList.remove('d-none');
|
||||
}
|
||||
|
||||
function selectQuickCustomer(customerId) {
|
||||
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(customerId));
|
||||
if (!customer) return;
|
||||
document.getElementById('quickCustomerLookup').value = customer.name || quickCustomerLabel(customer);
|
||||
document.getElementById('quickCustomerId').value = String(customer.id);
|
||||
document.getElementById('quickCustomerResults').classList.add('d-none');
|
||||
if (!document.getElementById('quickConnectionName').value.trim()) {
|
||||
document.getElementById('quickConnectionName').value = `Internet - ${customer.name}`;
|
||||
}
|
||||
const customerAddress = [customer.address, customer.postal_code, customer.city].filter(Boolean).join(', ');
|
||||
if (customerAddress) document.getElementById('quickAddress').value = customerAddress;
|
||||
suggestQuickSharedHead();
|
||||
updateQuickSetupSummary();
|
||||
}
|
||||
|
||||
async function loadQuickProducts() {
|
||||
const response = await fetch('/api/v1/products?limit=1000&is_active=true');
|
||||
const products = response.ok ? await response.json() : [];
|
||||
quickProductOptions = products.filter((item) => {
|
||||
const text = `${item.name || ''} ${item.short_description || ''}`.toLowerCase();
|
||||
const network = item.attributes_json?.network || {};
|
||||
return network.kind === 'internet_access' || /internet|fiber|bredb/.test(text);
|
||||
});
|
||||
document.getElementById('quickProduct').innerHTML = '<option value="">Vælg internetprodukt…</option>'
|
||||
+ quickProductOptions.map((item) => `<option value="${item.id}" data-price="${Number(item.sales_price || 0)}">${escapeHtml(item.name || '-')} · ${formatDKK(item.sales_price || 0)}</option>`).join('');
|
||||
}
|
||||
|
||||
function selectQuickSetupType(type) {
|
||||
quickSetupType = type;
|
||||
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.toggle('active', button.dataset.quickType === type));
|
||||
document.getElementById('quickTypeHint').classList.add('d-none');
|
||||
document.getElementById('quickSetupFields').classList.remove('d-none');
|
||||
document.getElementById('quickSetupSubmit').classList.remove('d-none');
|
||||
const isBmcnet = type === 'bmcnet';
|
||||
const isShared = type === 'shared';
|
||||
document.getElementById('quickSharedHeadWrap').classList.toggle('d-none', !isBmcnet);
|
||||
document.getElementById('quickConnectionModeWrap').classList.toggle('d-none', isBmcnet || isShared);
|
||||
document.getElementById('quickCustomerWrap').classList.toggle('d-none', isShared);
|
||||
document.getElementById('quickOwnerWrap').classList.toggle('d-none', !isShared);
|
||||
document.querySelectorAll('.quick-subscription-field').forEach((node) => node.classList.toggle('d-none', isShared));
|
||||
document.getElementById('quickConnectionStepTitle').textContent = isShared ? '2. Forbindelse' : '3. Forbindelse';
|
||||
document.getElementById('quickAdvancedConnectionFields').classList.toggle('d-none', isBmcnet);
|
||||
document.getElementById('quickBmcnetNotesWrap').classList.toggle('d-none', !isBmcnet);
|
||||
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', isBmcnet));
|
||||
if (isBmcnet || isShared) document.getElementById('quickConnectionMode').value = 'new';
|
||||
document.getElementById('quickSetupSubmit').innerHTML = isBmcnet
|
||||
? '<i class="bi bi-check2-circle me-1"></i>Opret BMCnet og abonnement'
|
||||
: isShared
|
||||
? '<i class="bi bi-check2-circle me-1"></i>Opret delt hovedfiber'
|
||||
: '<i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement';
|
||||
toggleQuickConnectionMode();
|
||||
}
|
||||
|
||||
function toggleQuickConnectionMode() {
|
||||
const existing = document.getElementById('quickConnectionMode').value === 'existing';
|
||||
document.getElementById('quickExistingWrap').classList.toggle('d-none', !existing);
|
||||
document.querySelectorAll('.quick-new-field').forEach((node) => node.classList.toggle('d-none', existing));
|
||||
document.querySelectorAll('.quick-physical-field').forEach((node) => node.classList.toggle('d-none', existing || quickSetupType === 'bmcnet'));
|
||||
updateQuickSetupSummary();
|
||||
}
|
||||
|
||||
async function populateQuickConnections() {
|
||||
let connections = [];
|
||||
try {
|
||||
const response = await fetch('/api/v1/internet-connections');
|
||||
connections = response.ok ? await response.json() : [];
|
||||
} catch (error) {
|
||||
connections = allConnections;
|
||||
}
|
||||
document.getElementById('quickExistingConnection').innerHTML = '<option value="">Vælg forbindelse…</option>'
|
||||
+ connections.filter((item) => !item.subscription_id).map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)} · ${escapeHtml(item.customer_name || 'Ingen kunde')}</option>`).join('');
|
||||
quickSharedHeadOptions = connections.filter((item) => item.is_shared_head);
|
||||
document.getElementById('quickSharedHead').innerHTML = '<option value="">Vælg hovedforbindelse…</option>'
|
||||
+ quickSharedHeadOptions.map((item) => `<option value="${item.id}">${escapeHtml(item.name || `#${item.id}`)}${item.address ? ` · ${escapeHtml(item.address)}` : ''}</option>`).join('');
|
||||
}
|
||||
|
||||
function normalizeQuickAddress(value) {
|
||||
return String(value || '')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function quickEditDistance(left, right) {
|
||||
const previous = Array.from({length: right.length + 1}, (_, index) => index);
|
||||
for (let i = 1; i <= left.length; i += 1) {
|
||||
let diagonal = previous[0];
|
||||
previous[0] = i;
|
||||
for (let j = 1; j <= right.length; j += 1) {
|
||||
const above = previous[j];
|
||||
previous[j] = Math.min(
|
||||
previous[j] + 1,
|
||||
previous[j - 1] + 1,
|
||||
diagonal + (left[i - 1] === right[j - 1] ? 0 : 1),
|
||||
);
|
||||
diagonal = above;
|
||||
}
|
||||
}
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
function quickTokenSimilarity(left, right) {
|
||||
if (left === right) return 1;
|
||||
return 1 - (quickEditDistance(left, right) / Math.max(left.length, right.length, 1));
|
||||
}
|
||||
|
||||
function quickAddressMatchScore(input, candidate) {
|
||||
const wanted = normalizeQuickAddress(input);
|
||||
const existing = normalizeQuickAddress(candidate);
|
||||
if (!wanted || !existing) return 0;
|
||||
if (wanted === existing) return 100;
|
||||
const wantedParts = wanted.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
|
||||
const existingParts = existing.split(' ').filter((part) => part.length > 1 && !/^\d+$/.test(part));
|
||||
const tokenScore = wantedParts.length
|
||||
? wantedParts.reduce((sum, part) => sum + Math.max(0, ...existingParts.map((candidatePart) => quickTokenSimilarity(part, candidatePart))), 0) / wantedParts.length
|
||||
: 0;
|
||||
const wantedNumbers = wanted.match(/\b\d+[a-z]?\b/g) || [];
|
||||
const existingNumbers = new Set(existing.match(/\b\d+[a-z]?\b/g) || []);
|
||||
const wantedPostal = wantedNumbers.find((part) => /^\d{4}$/.test(part));
|
||||
const wantedHouse = wantedNumbers.find((part) => part !== wantedPostal);
|
||||
const samePostal = Boolean(wantedPostal && existingNumbers.has(wantedPostal));
|
||||
const sameHouseNumber = Boolean(wantedHouse && existingNumbers.has(wantedHouse));
|
||||
return Math.round((tokenScore * 65) + (sameHouseNumber ? 25 : 0) + (samePostal ? 10 : 0));
|
||||
}
|
||||
|
||||
function suggestQuickSharedHead() {
|
||||
if (quickSetupType !== 'bmcnet') return;
|
||||
const address = document.getElementById('quickAddress').value.trim();
|
||||
const select = document.getElementById('quickSharedHead');
|
||||
const hint = document.getElementById('quickSharedHeadSuggestion');
|
||||
if (address.length < 4) {
|
||||
hint.className = 'small text-muted mt-1';
|
||||
hint.textContent = 'Skriv installationsadressen, så foreslår systemet en hovedforbindelse.';
|
||||
return;
|
||||
}
|
||||
const matches = quickSharedHeadOptions
|
||||
.map((item) => ({item, score: quickAddressMatchScore(address, item.address)}))
|
||||
.filter((match) => match.score >= 55)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
if (!matches.length) {
|
||||
hint.className = 'small text-warning mt-1';
|
||||
hint.textContent = 'Ingen delt hovedforbindelse matcher adressen. Vælg manuelt.';
|
||||
return;
|
||||
}
|
||||
const best = matches[0];
|
||||
select.value = String(best.item.id);
|
||||
hint.className = 'small text-success fw-semibold mt-1';
|
||||
hint.innerHTML = `<i class="bi bi-check-circle me-1"></i>Foreslået ud fra adressen: ${escapeHtml(best.item.name || `#${best.item.id}`)}${best.item.address ? ` · ${escapeHtml(best.item.address)}` : ''}`;
|
||||
updateQuickSetupSummary();
|
||||
}
|
||||
|
||||
function updateQuickSetupSummary() {
|
||||
const customer = quickCustomerOptions.find((item) => Number(item.id) === Number(document.getElementById('quickCustomerId').value || 0));
|
||||
const product = quickProductOptions.find((item) => Number(item.id) === Number(document.getElementById('quickProduct').value || 0));
|
||||
const existing = document.getElementById('quickConnectionMode').value === 'existing';
|
||||
const connectionText = existing
|
||||
? document.getElementById('quickExistingConnection').selectedOptions[0]?.textContent || 'vælg forbindelse'
|
||||
: document.getElementById('quickConnectionName').value.trim() || 'ny forbindelse';
|
||||
const owner = quickSetupType === 'shared' ? 'BMC Networks (delefiber)' : (customer?.name || 'vælg kunde');
|
||||
document.getElementById('quickSetupSummary').innerHTML = quickSetupType === 'shared'
|
||||
? `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(connectionText)}. Der oprettes kun en delt hovedforbindelse — intet produkt eller abonnement.`
|
||||
: `<strong>Kontrol:</strong> ${escapeHtml(owner)} · ${escapeHtml(product?.name || 'vælg produkt')} · ${escapeHtml(connectionText)}. Abonnementet oprettes som kladde.`;
|
||||
}
|
||||
|
||||
async function submitQuickSetup() {
|
||||
const feedback = document.getElementById('quickSetupFeedback');
|
||||
const button = document.getElementById('quickSetupSubmit');
|
||||
const customerId = resolveQuickCustomerId();
|
||||
const productId = Number(document.getElementById('quickProduct').value || 0) || null;
|
||||
const existing = document.getElementById('quickConnectionMode').value === 'existing';
|
||||
const payload = {
|
||||
customer_id: customerId,
|
||||
product_id: productId,
|
||||
start_date: document.getElementById('quickStartDate').value,
|
||||
connection_id: existing ? (Number(document.getElementById('quickExistingConnection').value || 0) || null) : null,
|
||||
connection_name: existing ? null : document.getElementById('quickConnectionName').value.trim(),
|
||||
vendor_id: existing ? null : (Number(document.getElementById('quickVendor').value || 0) || null),
|
||||
circuit_number: document.getElementById('quickCircuit').value.trim() || null,
|
||||
address: document.getElementById('quickAddress').value.trim() || null,
|
||||
technology: document.getElementById('quickTechnology').value.trim() || null,
|
||||
download_mbps: Number(document.getElementById('quickDownload').value || 0) || null,
|
||||
upload_mbps: Number(document.getElementById('quickUpload').value || 0) || null,
|
||||
monthly_cost: Number(document.getElementById('quickCost').value || 0),
|
||||
unit_price: document.getElementById('quickPrice').value === '' ? null : Number(document.getElementById('quickPrice').value),
|
||||
billing_interval: document.getElementById('quickInterval').value,
|
||||
billing_day: Number(document.getElementById('quickBillingDay').value || 1),
|
||||
notes: (quickSetupType === 'bmcnet' ? document.getElementById('quickBmcnetNotes').value : document.getElementById('quickNotes').value).trim() || null,
|
||||
shared_fiber: quickSetupType === 'shared',
|
||||
};
|
||||
if (!quickSetupType) {
|
||||
feedback.textContent = 'Vælg først fysisk forbindelse eller BMCnet.';
|
||||
return;
|
||||
}
|
||||
const missingSubscription = quickSetupType !== 'shared' && (!payload.customer_id || !payload.product_id || !payload.start_date);
|
||||
if (missingSubscription || (existing ? !payload.connection_id : !payload.connection_name)) {
|
||||
feedback.textContent = quickSetupType === 'shared'
|
||||
? 'Skriv et navn til hovedforbindelsen.'
|
||||
: 'Vælg kunde, produkt, startdato og forbindelse.';
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
feedback.textContent = 'Opretter og forbinder…';
|
||||
try {
|
||||
const sharedHeadId = Number(document.getElementById('quickSharedHead').value || 0) || null;
|
||||
if (quickSetupType === 'bmcnet' && !sharedHeadId) throw new Error('Vælg en delt hovedforbindelse.');
|
||||
const endpoint = quickSetupType === 'bmcnet'
|
||||
? `/api/v1/internet-connections/${sharedHeadId}/bmcnet-connections`
|
||||
: '/api/v1/internet-connections/quick-setup';
|
||||
const requestPayload = quickSetupType === 'bmcnet' ? {
|
||||
customer_id: payload.customer_id,
|
||||
case_title: payload.connection_name,
|
||||
service_label: payload.connection_name,
|
||||
address: payload.address,
|
||||
billing_interval: payload.billing_interval,
|
||||
billing_day: payload.billing_day,
|
||||
start_date: payload.start_date,
|
||||
internet_product_id: payload.product_id,
|
||||
internet_unit_price: payload.unit_price,
|
||||
notes: payload.notes,
|
||||
} : payload;
|
||||
const response = await fetch(endpoint, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(requestPayload)});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const result = await response.json();
|
||||
const connectionLink = `<a href="/economy/internet-connections/${result.connection.id}">Åbn forbindelsen</a>`;
|
||||
const subscriptionLink = result.sag?.id ? ` · <a href="/sag/${result.sag.id}/v3?tab=subscription">Åbn abonnementet</a>` : '';
|
||||
feedback.innerHTML = `Oprettet. ${connectionLink}${subscriptionLink}`;
|
||||
await loadInternetPage();
|
||||
} catch (error) {
|
||||
feedback.textContent = `Kunne ikke oprette: ${error.message}`;
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
document.getElementById('searchInput').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') loadInternetPage();
|
||||
@ -852,8 +1440,36 @@
|
||||
document.getElementById('connectionSubscriptionInput').addEventListener('change', () => {
|
||||
document.getElementById('connectionSubscriptionIdInput').value = resolveSubscriptionId(document.getElementById('connectionSubscriptionInput').value) || '';
|
||||
});
|
||||
document.getElementById('quickCustomerLookup').addEventListener('input', (event) => {
|
||||
document.getElementById('quickCustomerId').value = '';
|
||||
clearTimeout(quickCustomerTimer);
|
||||
quickCustomerTimer = setTimeout(() => searchQuickCustomers(event.target.value), 220);
|
||||
});
|
||||
document.getElementById('quickCustomerLookup').addEventListener('change', resolveQuickCustomerId);
|
||||
document.getElementById('quickCustomerResults').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-quick-customer-id]');
|
||||
if (button) selectQuickCustomer(Number(button.dataset.quickCustomerId));
|
||||
});
|
||||
document.getElementById('quickProduct').addEventListener('change', (event) => {
|
||||
const option = event.target.selectedOptions[0];
|
||||
document.getElementById('quickPrice').value = option?.value ? option.dataset.price || '' : '';
|
||||
updateQuickSetupSummary();
|
||||
});
|
||||
document.getElementById('quickAddress').addEventListener('input', suggestQuickSharedHead);
|
||||
['quickExistingConnection','quickConnectionName','quickStartDate','quickInterval','quickBillingDay'].forEach((id) => document.getElementById(id)?.addEventListener('change', updateQuickSetupSummary));
|
||||
document.getElementById('quickSetupModal').addEventListener('show.bs.modal', async () => {
|
||||
quickSetupType = null;
|
||||
document.querySelectorAll('[data-quick-type]').forEach((button) => button.classList.remove('active'));
|
||||
document.getElementById('quickTypeHint').classList.remove('d-none');
|
||||
document.getElementById('quickSetupFields').classList.add('d-none');
|
||||
document.getElementById('quickSetupSubmit').classList.add('d-none');
|
||||
if (!document.getElementById('quickStartDate').value) document.getElementById('quickStartDate').value = new Date().toISOString().slice(0, 10);
|
||||
await loadQuickProducts();
|
||||
await populateQuickConnections();
|
||||
toggleQuickConnectionMode();
|
||||
});
|
||||
toggleCreateValueFields();
|
||||
await loadSubscriptionOptions();
|
||||
await Promise.all([loadSubscriptionOptions(), loadInternetVendors()]);
|
||||
await Promise.all([loadInternetPage(), loadInvoiceSyncRuns()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -2,16 +2,77 @@ import json
|
||||
import logging
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiohttp
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.core.economic_write_policy import assert_economic_write_allowed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _economic_error_message(status: int, response_text: str) -> str:
|
||||
"""Return a useful, bounded e-conomic validation error without headers or credentials."""
|
||||
raw = str(response_text or "").strip()
|
||||
messages: List[str] = []
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except (TypeError, ValueError):
|
||||
payload = None
|
||||
|
||||
def collect(value: Any, prefix: str = "") -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
if str(key).lower() in {"developerhint", "logid", "httpstatuscode"}:
|
||||
continue
|
||||
collect(child, f"{prefix}.{key}".strip("."))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
collect(child, prefix)
|
||||
elif value not in (None, "") and str(value) not in messages:
|
||||
label = f"{prefix}: " if prefix else ""
|
||||
messages.append(f"{label}{value}")
|
||||
|
||||
if payload is not None:
|
||||
collect(payload)
|
||||
elif raw:
|
||||
messages.append(raw)
|
||||
detail = " · ".join(messages)[:1200]
|
||||
return f"e-conomic afviste ordren ({status})" + (f": {detail}" if detail else "")
|
||||
|
||||
|
||||
def _require_valid_product_numbers(product_numbers: set[str], valid_product_numbers: set[str]) -> List[str]:
|
||||
"""Reject priced lines before POST when e-conomic cannot resolve their product."""
|
||||
missing = sorted(product_numbers - valid_product_numbers)
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"Ordren blev ikke sendt. Følgende varenumre findes ikke i e-conomic: "
|
||||
+ ", ".join(missing)
|
||||
+ ". Knyt eller opret varerne under Varer og e-conomic, og prøv igen."
|
||||
),
|
||||
)
|
||||
return missing
|
||||
|
||||
|
||||
def _product_creation_payload(proposal: Dict[str, Any], group_number: int) -> Dict[str, Any]:
|
||||
payload = {
|
||||
"productNumber": proposal["product_number"],
|
||||
"name": str(proposal["name"])[:300],
|
||||
"description": str(proposal.get("description") or proposal["name"])[:2500],
|
||||
"salesPrice": proposal["sales_price"],
|
||||
"barred": False,
|
||||
"productGroup": {"productGroupNumber": group_number},
|
||||
}
|
||||
if proposal.get("ean"):
|
||||
payload["barCode"] = str(proposal["ean"])[:50]
|
||||
return payload
|
||||
|
||||
|
||||
class OrdreEconomicExportService:
|
||||
"""e-conomic export service for global ordre page."""
|
||||
|
||||
@ -20,8 +81,17 @@ class OrdreEconomicExportService:
|
||||
self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN
|
||||
self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN
|
||||
|
||||
self.read_only = settings.ORDRE_ECONOMIC_READ_ONLY
|
||||
self.dry_run = settings.ORDRE_ECONOMIC_DRY_RUN
|
||||
configured_fields = getattr(settings, "model_fields_set", set())
|
||||
self.read_only = (
|
||||
settings.ORDRE_ECONOMIC_READ_ONLY
|
||||
if "ORDRE_ECONOMIC_READ_ONLY" in configured_fields
|
||||
else settings.ECONOMIC_READ_ONLY
|
||||
)
|
||||
self.dry_run = (
|
||||
settings.ORDRE_ECONOMIC_DRY_RUN
|
||||
if "ORDRE_ECONOMIC_DRY_RUN" in configured_fields
|
||||
else settings.ECONOMIC_DRY_RUN
|
||||
)
|
||||
self.default_layout = settings.ORDRE_ECONOMIC_LAYOUT
|
||||
self.default_product = settings.ORDRE_ECONOMIC_PRODUCT
|
||||
|
||||
@ -57,7 +127,21 @@ class OrdreEconomicExportService:
|
||||
notes: Optional[str] = None,
|
||||
layout_number: Optional[int] = None,
|
||||
user_id: Optional[int] = None,
|
||||
document_key: Optional[str] = None,
|
||||
currency: str = 'DKK',
|
||||
create_missing_products: Optional[Dict[str, int]] = None,
|
||||
export_reference: Optional[str] = None,
|
||||
external_id: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
from app.products.backend.economic_documents import active_connection, export_document, preflight, unsaved_key
|
||||
connection = active_connection()
|
||||
if connection:
|
||||
if not self._check_write_permission(f'Export ordre for customer {customer_id}') or settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
||||
checked = await preflight(connection, customer_id, lines, layout_number, currency, notes)
|
||||
return {'success': True, 'dry_run': True, 'message': 'Safety mode: valideret uden ekstern skrivning', 'details': checked}
|
||||
return await export_document(connection, 'order', document_key or unsaved_key(customer_id, lines, notes, layout_number),
|
||||
customer_id, lines, layout_number, currency, notes, user_id,
|
||||
external_id=external_id)
|
||||
customer = execute_query_single(
|
||||
"SELECT id, name, economic_customer_number FROM customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
@ -81,7 +165,9 @@ class OrdreEconomicExportService:
|
||||
product_map: Dict[int, str] = {}
|
||||
if product_ids:
|
||||
product_rows = execute_query(
|
||||
"SELECT id, sku_internal FROM products WHERE id = ANY(%s)",
|
||||
"""SELECT id, name, sku_internal, ean, short_description, long_description,
|
||||
sales_price, economic_product_group_number
|
||||
FROM products WHERE id = ANY(%s)""",
|
||||
(product_ids,),
|
||||
) or []
|
||||
product_map = {
|
||||
@ -89,8 +175,13 @@ class OrdreEconomicExportService:
|
||||
for row in product_rows
|
||||
if row.get("sku_internal")
|
||||
}
|
||||
product_details = {int(row["id"]): row for row in product_rows}
|
||||
else:
|
||||
product_details = {}
|
||||
|
||||
economic_lines: List[Dict[str, Any]] = []
|
||||
creation_candidates: Dict[str, Dict[str, Any]] = {}
|
||||
created_product_numbers: List[str] = []
|
||||
for line in selected_lines:
|
||||
try:
|
||||
quantity = float(line.get("quantity") or 0)
|
||||
@ -123,29 +214,150 @@ class OrdreEconomicExportService:
|
||||
|
||||
if product_number:
|
||||
line_payload["product"] = {"productNumber": str(product_number)}
|
||||
local_product = product_details.get(int(product_id)) if product_id is not None and str(product_id).isdigit() else None
|
||||
creation_candidates.setdefault(str(product_number), {
|
||||
"product_number": str(product_number),
|
||||
"product_id": int(product_id) if product_id is not None and str(product_id).isdigit() else None,
|
||||
"name": (local_product or {}).get("name") or line_payload["description"],
|
||||
"description": (local_product or {}).get("long_description") or (local_product or {}).get("short_description") or line_payload["description"],
|
||||
"ean": (local_product or {}).get("ean") or line.get("ean"),
|
||||
"sales_price": float((local_product or {}).get("sales_price") or unit_price),
|
||||
"suggested_group_number": (local_product or {}).get("economic_product_group_number"),
|
||||
})
|
||||
|
||||
if discount > 0:
|
||||
line_payload["discountPercentage"] = discount
|
||||
|
||||
economic_lines.append(line_payload)
|
||||
|
||||
operation = f"Export ordre for customer {customer_id} to e-conomic"
|
||||
write_allowed = self._check_write_permission(operation)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
customer_number = int(customer["economic_customer_number"])
|
||||
async with session.get(
|
||||
f"{self.api_url}/customers/{customer_number}",
|
||||
headers=self._headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as customer_response:
|
||||
customer_text = await customer_response.text()
|
||||
if customer_response.status != 200:
|
||||
logger.error("❌ e-conomic customer lookup failed (%s): %s", customer_response.status, customer_text)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=_economic_error_message(customer_response.status, customer_text),
|
||||
)
|
||||
economic_customer = await customer_response.json(content_type=None)
|
||||
|
||||
payment_terms = economic_customer.get("paymentTerms")
|
||||
vat_zone = economic_customer.get("vatZone")
|
||||
if not payment_terms or not vat_zone:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail="e-conomic-kunden mangler betalingsbetingelser eller momszone",
|
||||
)
|
||||
|
||||
product_numbers = {
|
||||
str(line.get("product", {}).get("productNumber") or "").strip()
|
||||
for line in economic_lines
|
||||
if line.get("product")
|
||||
}
|
||||
valid_product_numbers = set()
|
||||
for product_number in product_numbers:
|
||||
if not product_number:
|
||||
continue
|
||||
async with session.get(
|
||||
f"{self.api_url}/products/{quote(product_number, safe='')}",
|
||||
headers=self._headers(),
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as product_response:
|
||||
if product_response.status == 200:
|
||||
valid_product_numbers.add(product_number)
|
||||
elif product_response.status == 404:
|
||||
logger.warning("e-conomic product %s does not exist", product_number)
|
||||
else:
|
||||
product_text = await product_response.text()
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=_economic_error_message(product_response.status, product_text),
|
||||
)
|
||||
|
||||
missing_product_numbers = sorted(product_numbers - valid_product_numbers)
|
||||
if missing_product_numbers:
|
||||
async with session.get(
|
||||
f"{self.api_url}/product-groups?pagesize=1000",
|
||||
headers=self._headers(), timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as groups_response:
|
||||
groups_text = await groups_response.text()
|
||||
if groups_response.status != 200:
|
||||
raise HTTPException(502, _economic_error_message(groups_response.status, groups_text))
|
||||
groups_data = await groups_response.json(content_type=None)
|
||||
groups = [
|
||||
{"number": row.get("productGroupNumber"), "name": row.get("name") or ""}
|
||||
for row in (groups_data.get("collection") or []) if row.get("productGroupNumber") is not None
|
||||
]
|
||||
approved_groups = create_missing_products or {}
|
||||
if any(number not in approved_groups for number in missing_product_numbers):
|
||||
raise HTTPException(status_code=409, detail={
|
||||
"code": "economic_products_missing",
|
||||
"message": "Godkend oprettelse af de manglende varer før ordren eksporteres.",
|
||||
"products": [creation_candidates[number] for number in missing_product_numbers],
|
||||
"product_groups": groups,
|
||||
})
|
||||
if self.read_only or self.dry_run:
|
||||
raise HTTPException(409, "Safety mode blokerer oprettelse af varer i e-conomic")
|
||||
available_groups = {int(group["number"]) for group in groups}
|
||||
for number in missing_product_numbers:
|
||||
group_number = int(approved_groups[number])
|
||||
if group_number not in available_groups:
|
||||
raise HTTPException(409, f"Varegruppe {group_number} findes ikke i e-conomic")
|
||||
proposal = creation_candidates[number]
|
||||
product_payload = _product_creation_payload(proposal, group_number)
|
||||
assert_economic_write_allowed("POST", "/products")
|
||||
async with session.post(
|
||||
f"{self.api_url}/products", headers=self._headers(), json=product_payload,
|
||||
timeout=aiohttp.ClientTimeout(total=30),
|
||||
) as create_response:
|
||||
create_text = await create_response.text()
|
||||
if create_response.status not in (200, 201):
|
||||
raise HTTPException(502, _economic_error_message(create_response.status, create_text))
|
||||
created = await create_response.json(content_type=None)
|
||||
if str(created.get("productNumber") or "") != number:
|
||||
raise HTTPException(502, f"e-conomic oprettede ikke det forventede varenummer {number}")
|
||||
valid_product_numbers.add(number)
|
||||
created_product_numbers.append(number)
|
||||
_require_valid_product_numbers(product_numbers, valid_product_numbers)
|
||||
|
||||
customer_layout = economic_customer.get("layout") or {}
|
||||
resolved_layout_number = customer_layout.get("layoutNumber") or self.default_layout
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"date": date.today().isoformat(),
|
||||
"currency": "DKK",
|
||||
"customer": {
|
||||
"customerNumber": int(customer["economic_customer_number"]),
|
||||
"currency": str(economic_customer.get("currency") or "DKK"),
|
||||
"customer": {"customerNumber": customer_number},
|
||||
"paymentTerms": payment_terms,
|
||||
"recipient": {
|
||||
"name": str(economic_customer.get("name") or customer.get("name") or "Kunde"),
|
||||
"address": str(economic_customer.get("address") or ""),
|
||||
"zip": str(economic_customer.get("zip") or ""),
|
||||
"city": str(economic_customer.get("city") or ""),
|
||||
"country": str(economic_customer.get("country") or ""),
|
||||
"vatZone": vat_zone,
|
||||
},
|
||||
"layout": {
|
||||
"layoutNumber": int(layout_number or self.default_layout),
|
||||
"layoutNumber": int(resolved_layout_number),
|
||||
},
|
||||
"lines": economic_lines,
|
||||
}
|
||||
|
||||
if notes:
|
||||
payload["notes"] = {"textLine1": str(notes)[:250]}
|
||||
payload["notes"] = {"textLine1": str(notes)[:1000]}
|
||||
if external_id is not None:
|
||||
payload["references"] = {"other": str(external_id)[:100]}
|
||||
elif export_reference:
|
||||
payload["references"] = {"other": "BMC-HUB:" + str(export_reference)[:90]}
|
||||
|
||||
operation = f"Export ordre for customer {customer_id} to e-conomic"
|
||||
if not self._check_write_permission(operation):
|
||||
if not write_allowed:
|
||||
return {
|
||||
"success": True,
|
||||
"dry_run": True,
|
||||
@ -157,13 +369,15 @@ class OrdreEconomicExportService:
|
||||
"read_only": self.read_only,
|
||||
"dry_run": self.dry_run,
|
||||
"user_id": user_id,
|
||||
"skipped_product_numbers": missing_product_numbers,
|
||||
"missing_product_numbers": missing_product_numbers,
|
||||
"payload": payload,
|
||||
},
|
||||
}
|
||||
|
||||
logger.info("📤 Sending ordre payload to e-conomic: %s", json.dumps(payload, default=str))
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
assert_economic_write_allowed("POST", "/orders/drafts")
|
||||
async with session.post(
|
||||
f"{self.api_url}/orders/drafts",
|
||||
headers=self._headers(),
|
||||
@ -175,7 +389,7 @@ class OrdreEconomicExportService:
|
||||
logger.error("❌ e-conomic export failed (%s): %s", response.status, response_text)
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"e-conomic export fejlede ({response.status})",
|
||||
detail=_economic_error_message(response.status, response_text),
|
||||
)
|
||||
|
||||
export_result = await response.json(content_type=None)
|
||||
@ -192,7 +406,15 @@ class OrdreEconomicExportService:
|
||||
"customer_name": customer.get("name"),
|
||||
"selected_line_count": len(selected_lines),
|
||||
"user_id": user_id,
|
||||
"skipped_product_numbers": missing_product_numbers,
|
||||
"missing_product_numbers": missing_product_numbers,
|
||||
"economic_response": export_result,
|
||||
"request_payload": payload,
|
||||
"economic_customer_number": customer_number,
|
||||
"currency": payload["currency"],
|
||||
"layout_number": int(resolved_layout_number),
|
||||
"economic_order_url": export_result.get("self"),
|
||||
"created_product_numbers": created_product_numbers,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
182
app/modules/orders/backend/economic_sync.py
Normal file
182
app/modules/orders/backend/economic_sync.py
Normal file
@ -0,0 +1,182 @@
|
||||
"""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)}
|
||||
@ -12,7 +12,42 @@ from app.modules.orders.backend.service import aggregate_order_lines
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
ALLOWED_SYNC_STATUSES = {"pending", "exported", "failed", "posted", "paid"}
|
||||
ALLOWED_SYNC_STATUSES = {"pending", "uncertain", "exported", "failed", "posted", "paid"}
|
||||
|
||||
|
||||
def _economic_order_number(export_result: Dict[str, Any]) -> Any:
|
||||
"""Normalize legacy and catalog-aware e-conomic export response fields."""
|
||||
return (
|
||||
export_result.get("economic_order_number")
|
||||
or export_result.get("economic_draft_id")
|
||||
or export_result.get("draftOrderNumber")
|
||||
or export_result.get("order_number")
|
||||
or export_result.get("orderNumber")
|
||||
)
|
||||
|
||||
|
||||
def _validate_sync_status_number(sync_status: str, incoming: Any, existing: Any) -> None:
|
||||
if sync_status in {"exported", "posted", "paid"} and not (incoming if incoming is not None else existing):
|
||||
raise HTTPException(409, "Status kan ikke sættes til eksporteret uden et e-conomic-ordrenummer")
|
||||
|
||||
|
||||
def _snapshot_lines(lines):
|
||||
from app.products.backend.economic_documents import active_connection, snapshot_draft_lines
|
||||
return snapshot_draft_lines(lines) if active_connection() else lines
|
||||
|
||||
|
||||
async def _check_draft_prices(http_request, customer_id, lines, draft_id=None):
|
||||
from app.products.backend.economic_documents import active_connection
|
||||
if not active_connection():
|
||||
return
|
||||
from app.core.auth_dependencies import get_current_user, security
|
||||
from app.core.auth_service import AuthService
|
||||
from app.products.backend.economic_pricing import validate_manual_prices
|
||||
from app.core.database import execute_query_single
|
||||
actor = await get_current_user(http_request, await security(http_request))
|
||||
saved = execute_query_single('SELECT customer_id,lines_json FROM ordre_drafts WHERE id=%s', (draft_id,)) if draft_id else None
|
||||
saved_lines = _safe_json_field(saved['lines_json']) if saved and saved['customer_id'] == customer_id else []
|
||||
validate_manual_prices(lines, customer_id, actor.get('is_superadmin') or AuthService.user_has_permission(actor['id'], 'economic.pricing.override'), saved_lines)
|
||||
|
||||
|
||||
class OrdreLineInput(BaseModel):
|
||||
@ -25,7 +60,9 @@ class OrdreLineInput(BaseModel):
|
||||
discount_percentage: float = Field(default=0, ge=0, le=100)
|
||||
unit: Optional[str] = None
|
||||
product_id: Optional[int] = None
|
||||
ean: Optional[str] = None
|
||||
selected: bool = True
|
||||
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
|
||||
|
||||
|
||||
class OrdreExportRequest(BaseModel):
|
||||
@ -35,6 +72,8 @@ class OrdreExportRequest(BaseModel):
|
||||
layout_number: Optional[int] = None
|
||||
draft_id: Optional[int] = None
|
||||
force_export: bool = False
|
||||
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
|
||||
create_missing_products: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OrdreDraftUpsertRequest(BaseModel):
|
||||
@ -49,6 +88,15 @@ class OrdreDraftConsolidateRequest(BaseModel):
|
||||
draft_ids: List[int] = Field(..., min_length=2)
|
||||
|
||||
|
||||
class EconomicDraftUpdatePreviewRequest(BaseModel):
|
||||
lines: List[Dict[str, Any]]
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class EconomicDraftUpdateConfirmRequest(BaseModel):
|
||||
request_id: str
|
||||
|
||||
|
||||
def _safe_json_field(value: Any) -> Any:
|
||||
if value is None:
|
||||
return None
|
||||
@ -138,6 +186,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"""Export selected ordre lines to e-conomic draft order."""
|
||||
try:
|
||||
user_id = _get_user_id_from_request(http_request)
|
||||
from app.products.backend.economic_documents import active_connection
|
||||
if active_connection() or request.create_missing_products:
|
||||
from app.core.auth_dependencies import get_current_user, security
|
||||
from app.core.auth_service import AuthService
|
||||
actor = await get_current_user(http_request, await security(http_request))
|
||||
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.documents.export'):
|
||||
raise HTTPException(403, 'Du mangler rettighed til e-conomic-eksport')
|
||||
if request.create_missing_products and not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.catalog.manage'):
|
||||
raise HTTPException(403, 'Du mangler rettighed til at oprette varer i e-conomic')
|
||||
user_id = actor['id']
|
||||
previous_status = None
|
||||
export_idempotency_key = None
|
||||
|
||||
@ -173,12 +231,18 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
)
|
||||
|
||||
line_payload = [line.model_dump() for line in request.lines]
|
||||
await _check_draft_prices(http_request, request.customer_id, line_payload, request.draft_id)
|
||||
export_result = await ordre_economic_export_service.export_order(
|
||||
customer_id=request.customer_id,
|
||||
lines=line_payload,
|
||||
notes=request.notes,
|
||||
layout_number=request.layout_number,
|
||||
user_id=user_id,
|
||||
document_key=f'order-draft:{request.draft_id}' if request.draft_id else None,
|
||||
currency=request.currency,
|
||||
create_missing_products=request.create_missing_products,
|
||||
export_reference=export_idempotency_key,
|
||||
external_id=request.draft_id,
|
||||
)
|
||||
|
||||
exported_line_keys = [line.get("line_key") for line in line_payload if line.get("line_key")]
|
||||
@ -199,16 +263,22 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
economic_order_number = (
|
||||
export_result.get("economic_order_number")
|
||||
or export_result.get("order_number")
|
||||
or export_result.get("orderNumber")
|
||||
)
|
||||
economic_order_number = _economic_order_number(export_result)
|
||||
economic_invoice_number = (
|
||||
export_result.get("economic_invoice_number")
|
||||
or export_result.get("invoice_number")
|
||||
or export_result.get("invoiceNumber")
|
||||
)
|
||||
if not export_result.get("dry_run") and not economic_order_number:
|
||||
raise HTTPException(502, "e-conomic returnerede intet ordrenummer. Kladden er ikke markeret som eksporteret.")
|
||||
details = export_result.get("details") or {}
|
||||
request_payload = details.get("request_payload") or {}
|
||||
response_payload = details.get("economic_response") or {}
|
||||
net_amount = sum(
|
||||
float(line.get("quantity") or 0) * float(line.get("unitNetPrice") or 0)
|
||||
* (1 - float(line.get("discountPercentage") or 0) / 100)
|
||||
for line in (request_payload.get("lines") or [])
|
||||
)
|
||||
target_sync_status = "pending" if export_result.get("dry_run") else "exported"
|
||||
|
||||
execute_query(
|
||||
@ -219,6 +289,17 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
export_idempotency_key = %s,
|
||||
economic_order_number = COALESCE(%s, economic_order_number),
|
||||
economic_invoice_number = COALESCE(%s, economic_invoice_number),
|
||||
economic_export_id = %s,
|
||||
economic_order_url = %s,
|
||||
exported_economic_customer_number = %s,
|
||||
exported_currency = %s,
|
||||
exported_layout_number = %s,
|
||||
exported_net_amount = %s,
|
||||
export_request_json = %s::jsonb,
|
||||
export_response_json = %s::jsonb,
|
||||
exported_created_products = %s::jsonb,
|
||||
last_export_error = NULL,
|
||||
exported_by_user_id = %s,
|
||||
last_sync_at = CURRENT_TIMESTAMP,
|
||||
last_exported_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
@ -230,6 +311,16 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
export_idempotency_key,
|
||||
str(economic_order_number) if economic_order_number is not None else None,
|
||||
str(economic_invoice_number) if economic_invoice_number is not None else None,
|
||||
export_result.get("export_id") or export_idempotency_key,
|
||||
details.get("economic_order_url"),
|
||||
str(details.get("economic_customer_number") or "") or None,
|
||||
details.get("currency"),
|
||||
details.get("layout_number"),
|
||||
net_amount,
|
||||
json.dumps(request_payload, ensure_ascii=False),
|
||||
json.dumps(response_payload, ensure_ascii=False),
|
||||
json.dumps(details.get("created_product_numbers") or [], ensure_ascii=False),
|
||||
user_id,
|
||||
request.draft_id,
|
||||
),
|
||||
)
|
||||
@ -244,12 +335,33 @@ async def export_ordre(request: OrdreExportRequest, http_request: Request):
|
||||
"idempotency_key": export_idempotency_key,
|
||||
"economic_order_number": economic_order_number,
|
||||
"economic_invoice_number": economic_invoice_number,
|
||||
"economic_export_id": export_result.get("export_id") or export_idempotency_key,
|
||||
"economic_customer_number": details.get("economic_customer_number"),
|
||||
"currency": details.get("currency"),
|
||||
"layout_number": details.get("layout_number"),
|
||||
"net_amount": net_amount,
|
||||
"created_product_numbers": details.get("created_product_numbers") or [],
|
||||
},
|
||||
user_id,
|
||||
)
|
||||
|
||||
return export_result
|
||||
except HTTPException:
|
||||
except HTTPException as exc:
|
||||
is_preview = isinstance(exc.detail, dict) and exc.detail.get("code") == "economic_products_missing"
|
||||
if request.draft_id and not is_preview:
|
||||
try:
|
||||
from app.core.database import execute_query
|
||||
execute_query(
|
||||
"UPDATE ordre_drafts SET last_export_error=%s,last_sync_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s",
|
||||
(str(exc.detail)[:2000], request.draft_id),
|
||||
)
|
||||
_log_sync_event(
|
||||
request.draft_id, "export_failed", previous_status, previous_status,
|
||||
{"status_code": exc.status_code, "message": str(exc.detail)[:1000],
|
||||
"idempotency_key": export_idempotency_key}, user_id,
|
||||
)
|
||||
except Exception as log_exc:
|
||||
logger.warning("Kunne ikke gemme eksportfejl for kladde %s: %s", request.draft_id, log_exc)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error exporting ordre to e-conomic: %s", e, exc_info=True)
|
||||
@ -300,7 +412,13 @@ async def list_ordre_drafts(
|
||||
async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||
"""Get single ordre draft with lines payload (no user filtering)."""
|
||||
try:
|
||||
query = "SELECT * FROM ordre_drafts WHERE id = %s LIMIT 1"
|
||||
query = """SELECT d.*, c.name AS customer_name,
|
||||
c.economic_customer_number AS current_economic_customer_number,
|
||||
u.full_name AS exported_by_name
|
||||
FROM ordre_drafts d
|
||||
LEFT JOIN customers c ON c.id = d.customer_id
|
||||
LEFT JOIN users u ON u.user_id = d.exported_by_user_id
|
||||
WHERE d.id = %s LIMIT 1"""
|
||||
params = (draft_id,)
|
||||
|
||||
from app.core.database import execute_query_single
|
||||
@ -318,10 +436,41 @@ async def get_ordre_draft(draft_id: int, http_request: Request):
|
||||
raise HTTPException(status_code=500, detail="Failed to fetch ordre draft")
|
||||
|
||||
|
||||
async def _require_economic_order_update(http_request: Request) -> Dict[str, Any]:
|
||||
from app.core.auth_dependencies import get_current_user, security
|
||||
from app.core.auth_service import AuthService
|
||||
actor = await get_current_user(http_request, await security(http_request))
|
||||
if not actor.get('is_superadmin') and not AuthService.user_has_permission(actor['id'], 'economic.orders.update'):
|
||||
raise HTTPException(403, 'Du mangler rettighed til at opdatere e-conomic-ordrekladder')
|
||||
return actor
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-update-preview')
|
||||
async def preview_economic_draft_update(draft_id: int, payload: EconomicDraftUpdatePreviewRequest, http_request: Request):
|
||||
actor = await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import create_update_preview
|
||||
return await create_update_preview(draft_id, payload.lines, payload.notes, actor['id'])
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-update-confirm')
|
||||
async def confirm_economic_draft_update(draft_id: int, payload: EconomicDraftUpdateConfirmRequest, http_request: Request):
|
||||
actor = await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import apply_update
|
||||
return await apply_update(payload.request_id, draft_id, actor['id'])
|
||||
|
||||
|
||||
@router.post('/ordre/drafts/{draft_id}/economic-status-refresh')
|
||||
async def refresh_economic_draft_status(draft_id: int, http_request: Request):
|
||||
await _require_economic_order_update(http_request)
|
||||
from app.modules.orders.backend.economic_sync import sync_one_draft
|
||||
return await sync_one_draft(draft_id, force=True)
|
||||
|
||||
|
||||
@router.post("/ordre/drafts")
|
||||
async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Request):
|
||||
"""Create a new ordre draft."""
|
||||
try:
|
||||
await _check_draft_prices(http_request, request.customer_id, request.lines)
|
||||
user_id = _get_user_id_from_request(http_request)
|
||||
from app.core.database import execute_query
|
||||
|
||||
@ -342,7 +491,7 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
||||
params = (
|
||||
request.title,
|
||||
request.customer_id,
|
||||
json.dumps(request.lines, ensure_ascii=False),
|
||||
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
|
||||
request.notes,
|
||||
request.layout_number,
|
||||
user_id,
|
||||
@ -350,6 +499,8 @@ async def create_ordre_draft(request: OrdreDraftUpsertRequest, http_request: Req
|
||||
)
|
||||
result = execute_query(query, params)
|
||||
return result[0]
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error creating ordre draft: %s", e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to create ordre draft")
|
||||
@ -364,6 +515,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
||||
query = """
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE sync_status = 'pending') AS pending_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'uncertain') AS uncertain_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'exported') AS exported_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'failed') AS failed_count,
|
||||
COUNT(*) FILTER (WHERE sync_status = 'posted') AS posted_count,
|
||||
@ -373,6 +525,7 @@ async def get_ordre_draft_sync_summary(http_request: Request):
|
||||
"""
|
||||
return execute_query_single(query, ()) or {
|
||||
"pending_count": 0,
|
||||
"uncertain_count": 0,
|
||||
"exported_count": 0,
|
||||
"failed_count": 0,
|
||||
"posted_count": 0,
|
||||
@ -417,12 +570,13 @@ async def update_ordre_draft_sync_status(draft_id: int, payload: Dict[str, Any],
|
||||
|
||||
from app.core.database import execute_query_single
|
||||
previous = execute_query_single(
|
||||
"SELECT sync_status FROM ordre_drafts WHERE id = %s",
|
||||
"SELECT sync_status, economic_order_number FROM ordre_drafts WHERE id = %s",
|
||||
(draft_id,)
|
||||
)
|
||||
if not previous:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
from_status = (previous.get("sync_status") or "pending").strip().lower()
|
||||
_validate_sync_status_number(sync_status, economic_order_number, previous.get("economic_order_number"))
|
||||
|
||||
values.append(draft_id)
|
||||
from app.core.database import execute_query
|
||||
@ -525,6 +679,20 @@ async def list_ordre_draft_sync_events(
|
||||
async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, http_request: Request):
|
||||
"""Update existing ordre draft."""
|
||||
try:
|
||||
from app.core.database import execute_query_single
|
||||
existing = execute_query_single('SELECT customer_id,lines_json,notes,layout_number,sync_status FROM ordre_drafts WHERE id=%s', (draft_id,))
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Draft not found")
|
||||
if (existing.get('sync_status') or '').lower() in {'exported','posted','paid'}:
|
||||
content_changed = (
|
||||
existing.get('customer_id') != request.customer_id
|
||||
or (_safe_json_field(existing.get('lines_json')) or []) != request.lines
|
||||
or (existing.get('notes') or None) != (request.notes or None)
|
||||
or existing.get('layout_number') != request.layout_number
|
||||
)
|
||||
if content_changed:
|
||||
raise HTTPException(409, 'Brug “Opdatér e-conomic-kladde”, så ændringen forhåndsvises og gemmes begge steder.')
|
||||
await _check_draft_prices(http_request, request.customer_id, request.lines, draft_id)
|
||||
from app.core.database import execute_query
|
||||
|
||||
query = """
|
||||
@ -541,7 +709,7 @@ async def update_ordre_draft(draft_id: int, request: OrdreDraftUpsertRequest, ht
|
||||
params = (
|
||||
request.title,
|
||||
request.customer_id,
|
||||
json.dumps(request.lines, ensure_ascii=False),
|
||||
json.dumps(_snapshot_lines(request.lines), ensure_ascii=False),
|
||||
request.notes,
|
||||
request.layout_number,
|
||||
draft_id,
|
||||
|
||||
@ -344,9 +344,9 @@
|
||||
const index = line.originalIndex;
|
||||
const isManual = line.source_type === 'manual';
|
||||
const descriptionField = isManual
|
||||
? `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
onchange="ordreLines[${index}].description = this.value;">`
|
||||
: escapeHtml(line.description || '-');
|
||||
? `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
|
||||
onchange="ordreLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`
|
||||
: `<span style="white-space:pre-line">${escapeHtml(line.description || '-')}</span>`;
|
||||
|
||||
const manualActions = isManual
|
||||
? `
|
||||
|
||||
@ -88,6 +88,8 @@
|
||||
<button class="btn btn-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
|
||||
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
|
||||
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
|
||||
<button class="btn btn-outline-primary" onclick="previewPrices()">Kontrollér nye priser</button>
|
||||
<a class="btn btn-outline-primary" href="/settings/economic-products">Varekoblinger og eksportfejl</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -95,8 +97,9 @@
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
|
||||
</div>
|
||||
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
|
||||
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<div id="forceExportWrap" class="d-flex justify-content-end mb-3">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="forceExportToggle">
|
||||
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
|
||||
@ -115,6 +118,7 @@
|
||||
<div class="info-item">
|
||||
<div class="info-label">Kunde ID</div>
|
||||
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
|
||||
<div id="customerIdentity" class="small text-muted mt-1">-</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
@ -145,13 +149,37 @@
|
||||
<div class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
|
||||
</div>
|
||||
|
||||
<div id="exportReceiptCard" class="card mb-3 d-none">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
|
||||
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
|
||||
</div>
|
||||
<div class="card-body"><div class="row g-3">
|
||||
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
|
||||
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
|
||||
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
|
||||
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
|
||||
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<div class="sync-card">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
|
||||
<div>
|
||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5>
|
||||
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div>
|
||||
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
|
||||
<div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
|
||||
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
|
||||
</button>
|
||||
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
|
||||
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events
|
||||
</button>
|
||||
@ -166,6 +194,7 @@
|
||||
<div class="sync-label">Sync status</div>
|
||||
<select id="syncStatusSelect" class="form-select form-select-sm">
|
||||
<option value="pending">pending</option>
|
||||
<option value="uncertain">uncertain</option>
|
||||
<option value="exported">exported</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="posted">posted</option>
|
||||
@ -176,7 +205,7 @@
|
||||
<div class="sync-label">e-conomic ordre nr.</div>
|
||||
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3" id="invoiceNumberEditWrap">
|
||||
<div class="sync-label">e-conomic faktura nr.</div>
|
||||
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
|
||||
</div>
|
||||
@ -200,15 +229,19 @@
|
||||
<div class="sync-label">Ordrenummer</div>
|
||||
<div id="economicOrderNumberView" class="sync-value">-</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="col-md-3" id="invoiceNumberViewWrap">
|
||||
<div class="sync-label">Fakturanummer</div>
|
||||
<div id="economicInvoiceNumberView" class="sync-value">-</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="sync-label">Betalingsstatus</div>
|
||||
<div id="economicPaymentStatusView" class="sync-value">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end mb-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label mb-1">Event type</label>
|
||||
<label class="form-label mb-1">Hændelse</label>
|
||||
<input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
@ -292,6 +325,37 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title">Varer oprettes før eksport</h5>
|
||||
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
|
||||
<div id="economicProductPreviewRows"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
|
||||
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
|
||||
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
|
||||
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
|
||||
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -304,6 +368,8 @@
|
||||
let syncEventsOffset = 0;
|
||||
let syncEventsTotal = 0;
|
||||
let detailToast = null;
|
||||
let pendingEconomicProductPreview = null;
|
||||
let pendingEconomicUpdateId = null;
|
||||
|
||||
function showToast(message, variant = 'dark') {
|
||||
const toastEl = document.getElementById('detailToast');
|
||||
@ -322,8 +388,8 @@
|
||||
detailToast.show();
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0));
|
||||
function formatCurrency(value, currency = 'DKK') {
|
||||
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
@ -354,6 +420,7 @@
|
||||
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
|
||||
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
|
||||
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
|
||||
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
|
||||
return '<span class="badge bg-warning text-dark">pending</span>';
|
||||
}
|
||||
|
||||
@ -366,6 +433,32 @@
|
||||
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
|
||||
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
|
||||
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
|
||||
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
|
||||
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
|
||||
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
|
||||
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
|
||||
|
||||
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
|
||||
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
|
||||
if (hasReceipt) {
|
||||
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
|
||||
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
|
||||
const period = orderData.coverage_start && orderData.coverage_end
|
||||
? `${orderData.coverage_start} – ${orderData.coverage_end}`
|
||||
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
|
||||
document.getElementById('receiptPeriod').textContent = period;
|
||||
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
|
||||
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
|
||||
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
|
||||
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
|
||||
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
|
||||
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
|
||||
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
|
||||
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
|
||||
const link = document.getElementById('economicOrderLink');
|
||||
link.classList.toggle('d-none', !orderData.economic_order_url);
|
||||
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
|
||||
}
|
||||
}
|
||||
|
||||
function renderLines() {
|
||||
@ -378,9 +471,11 @@
|
||||
|
||||
tbody.innerHTML = orderLines.map((line, index) => {
|
||||
const isExportedLine = line.export_status === 'exported';
|
||||
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">`;
|
||||
const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
|
||||
const lockLine = isExportedLine && !mayEditExportedDraft;
|
||||
const descriptionField = `<textarea class="form-control form-control-sm" rows="2" style="min-width:260px; resize:vertical;"
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].description = this.value;">${escapeHtml(line.description || '')}</textarea>`;
|
||||
|
||||
const exportStatus = line.export_status || '-';
|
||||
const statusBadge = exportStatus === 'exported'
|
||||
@ -406,17 +501,17 @@
|
||||
<td>${descriptionField}</td>
|
||||
<td style="min-width:100px;">
|
||||
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:120px;">
|
||||
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td style="min-width:110px;">
|
||||
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
|
||||
${isExportedLine ? 'disabled' : ''}
|
||||
${lockLine ? 'disabled' : ''}
|
||||
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
|
||||
</td>
|
||||
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
|
||||
@ -502,7 +597,7 @@
|
||||
const product = data.product;
|
||||
line.product_id = product.id || line.product_id || null;
|
||||
line.description = product.name || product.product_name || line.description || '';
|
||||
if (!Number(line.unit_price || 0) && product.sales_price != null) {
|
||||
if (line.unit_price == null && product.sales_price != null) {
|
||||
line.unit_price = Number(product.sales_price || 0);
|
||||
}
|
||||
if (product.ean) {
|
||||
@ -542,7 +637,8 @@
|
||||
discount_percentage: Number(line.discountPercentage || 0),
|
||||
amount: Number(line.totalNetAmount || 0),
|
||||
unit: line.unit || 'stk',
|
||||
product_id: line.product.productNumber || null,
|
||||
product_id: null,
|
||||
economic_product_number: line.product.productNumber || null,
|
||||
selected: line.selected !== false,
|
||||
export_status: line.export_status || null,
|
||||
};
|
||||
@ -569,13 +665,25 @@
|
||||
|
||||
document.getElementById('orderTitle').value = orderData.title || '';
|
||||
document.getElementById('customerId').value = orderData.customer_id || '';
|
||||
document.getElementById('customerIdentity').textContent = orderData.customer_name
|
||||
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
|
||||
: '-';
|
||||
document.getElementById('layoutNumber').value = orderData.layout_number || '';
|
||||
document.getElementById('orderNotes').value = orderData.notes || '';
|
||||
|
||||
const hasExported = orderData.last_exported_at ? true : false;
|
||||
const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
|
||||
const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
|
||||
document.getElementById('orderStatus').innerHTML = hasExported
|
||||
? '<span class="badge bg-success">Eksporteret</span>'
|
||||
: '<span class="badge bg-warning text-dark">Ikke eksporteret</span>';
|
||||
: isUncertain ? '<span class="badge bg-warning text-dark">Uafklaret</span>'
|
||||
: '<span class="badge bg-light text-dark border">Ikke eksporteret</span>';
|
||||
const errorBanner = document.getElementById('exportErrorBanner');
|
||||
errorBanner.textContent = orderData.last_export_error ? `Seneste eksportfejl: ${orderData.last_export_error}` : '';
|
||||
errorBanner.classList.toggle('d-none', !orderData.last_export_error);
|
||||
const openEconomicDraft = orderData.sync_status === 'exported' && orderData.economic_order_number && !orderData.economic_invoice_number;
|
||||
document.getElementById('economicUpdateBtn').classList.toggle('d-none', !openEconomicDraft);
|
||||
document.getElementById('economicStatusBtn').classList.toggle('d-none', !orderData.economic_order_number);
|
||||
document.getElementById('economicPaymentStatusView').textContent = orderData.economic_payment_status || (orderData.economic_document_state === 'draft' ? 'Ikke faktureret' : '-');
|
||||
|
||||
document.getElementById('createdAt').textContent = formatDate(orderData.created_at);
|
||||
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
|
||||
@ -666,9 +774,18 @@
|
||||
}
|
||||
|
||||
body.innerHTML = items.map((event) => {
|
||||
const payload = typeof event.event_payload === 'object'
|
||||
? JSON.stringify(event.event_payload, null, 2)
|
||||
: String(event.event_payload || '');
|
||||
const data = typeof event.event_payload === 'object' ? event.event_payload : {};
|
||||
let payload = String(event.event_payload || '');
|
||||
if (event.event_type === 'export_success') {
|
||||
payload = `Ordre ${data.economic_order_number || '-'} · Kunde ${data.economic_customer_number || '-'} · ${data.currency || '-'} · Layout ${data.layout_number || '-'} · ${formatCurrency(data.net_amount, data.currency)}`;
|
||||
if ((data.created_product_numbers || []).length) payload += ` · Oprettede varer: ${data.created_product_numbers.join(', ')}`;
|
||||
} else if (event.event_type === 'export_attempt') {
|
||||
payload = data.force_export ? 'Tvungen geneksport startet' : 'Eksport startet';
|
||||
} else if (event.event_type === 'sync_status_manual_update') {
|
||||
payload = `Manuel statusændring${data.economic_order_number ? ` · Ordre ${data.economic_order_number}` : ''}`;
|
||||
} else if (typeof event.event_payload === 'object') {
|
||||
payload = Object.entries(data).map(([key, value]) => `${key}: ${value ?? '-'}`).join(' · ');
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
@ -676,7 +793,7 @@
|
||||
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
|
||||
<td>${escapeHtml(event.from_status || '-')}</td>
|
||||
<td>${escapeHtml(event.to_status || '-')}</td>
|
||||
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td>
|
||||
<td><div class="event-payload">${escapeHtml(payload)}</div></td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
@ -761,7 +878,99 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function exportOrder() {
|
||||
async function previewEconomicUpdate() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-preview`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({lines: orderLines, notes: document.getElementById('orderNotes').value || null}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Forhåndsvisning fejlede');
|
||||
pendingEconomicUpdateId = data.request_id;
|
||||
const before = data.before || {}, after = data.after || {};
|
||||
const rows = (after.lines || []).map((line, i) => {
|
||||
const old = (before.lines || [])[i] || {};
|
||||
return `<tr><td>${escapeHtml(line.product_number || '-')}</td><td style="white-space:pre-line">${escapeHtml(old.description)} → ${escapeHtml(line.description)}</td><td>${escapeHtml(old.quantity)} → ${escapeHtml(line.quantity)}</td><td>${escapeHtml(old.unit_price)} → ${escapeHtml(line.unit_price)}</td><td>${escapeHtml(old.discount_percentage)} → ${escapeHtml(line.discount_percentage)}</td></tr>`;
|
||||
}).join('');
|
||||
document.getElementById('economicUpdateComparison').innerHTML = `<div class="mb-3"><strong>Noter:</strong> ${escapeHtml(before.notes || '-')} → ${escapeHtml(after.notes || '-')}</div><div class="table-responsive"><table class="table table-sm"><thead><tr><th>Vare</th><th>Beskrivelse</th><th>Antal</th><th>Pris</th><th>Rabat %</th></tr></thead><tbody>${rows}</tbody></table></div>`;
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicUpdatePreviewModal')).show();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function confirmEconomicUpdate() {
|
||||
if (!pendingEconomicUpdateId) return;
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-update-confirm`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request_id:pendingEconomicUpdateId})});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Opdateringen fejlede');
|
||||
bootstrap.Modal.getInstance(document.getElementById('economicUpdatePreviewModal'))?.hide();
|
||||
pendingEconomicUpdateId = null;
|
||||
showToast('e-conomic-ordrekladden er opdateret', 'success');
|
||||
await loadOrder();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function refreshEconomicStatus() {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/ordre/drafts/${draftId}/economic-status-refresh`, {method:'POST'});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.detail || 'Statuskontrol fejlede');
|
||||
showToast(data.invoice_number ? `Faktura ${data.invoice_number}: ${data.payment_status || data.status}` : 'Ordren er stadig en åben kladde', 'success');
|
||||
await loadOrder();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function previewPrices() {
|
||||
try {
|
||||
const url = `/api/v1/economic/ordre-drafts/${draftId}/reprice`;
|
||||
const res = await fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:'{}'});
|
||||
const preview = await res.json();
|
||||
if (!res.ok) throw Error(preview.detail || 'Prisberegning fejlede');
|
||||
const summary = preview.changes.map(x => `${x.description}: ${x.before} → ${x.after}`).join('\n');
|
||||
if (!preview.changes.length) { showToast('Ingen prisændringer foreslået. Abonnementspriser bevares.', 'info'); return; }
|
||||
if (!confirm('Genberegn den GEMTE kladde? Ikke-gemte ændringer indgår ikke.\nAbonnementspriser bevares.\n\n'+summary)) return;
|
||||
const saved = await fetch(url, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:preview.token})});
|
||||
const data = await saved.json();
|
||||
if (!saved.ok) throw Error(data.detail || 'Kunne ikke gemme priser');
|
||||
await loadOrder();
|
||||
} catch (err) { showToast(err.message, 'danger'); }
|
||||
}
|
||||
|
||||
function showEconomicProductPreview(detail) {
|
||||
pendingEconomicProductPreview = detail;
|
||||
const groups = detail.product_groups || [];
|
||||
document.getElementById('economicProductPreviewRows').innerHTML = (detail.products || []).map((product, index) => {
|
||||
const options = groups.map(group => {
|
||||
const selected = Number(product.suggested_group_number) === Number(group.number) ? ' selected' : '';
|
||||
return `<option value="${Number(group.number)}"${selected}>${escapeHtml(group.number)} · ${escapeHtml(group.name)}</option>`;
|
||||
}).join('');
|
||||
return `<div class="card mb-3" data-economic-product="${escapeHtml(product.product_number)}">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Opret produkt ${escapeHtml(product.product_number)}</strong>
|
||||
<span class="badge bg-success">${formatCurrency(product.sales_price)}</span>
|
||||
</div>
|
||||
<div class="card-body"><div class="row g-3">
|
||||
<div class="col-md-4"><div class="small text-muted">Navn</div><div>${escapeHtml(product.name || '-')}</div></div>
|
||||
<div class="col-md-4"><div class="small text-muted">EAN / stregkode</div><div>${escapeHtml(product.ean || '-')}</div></div>
|
||||
<div class="col-md-4"><label class="small text-muted" for="economicGroup${index}">Varegruppe</label><select id="economicGroup${index}" class="form-select form-select-sm" data-product-group>${options}</select></div>
|
||||
<div class="col-12"><div class="small text-muted">Beskrivelse</div><div>${escapeHtml(product.description || '-')}</div></div>
|
||||
</div></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('economicProductPreviewModal')).show();
|
||||
}
|
||||
|
||||
async function confirmEconomicProductCreation() {
|
||||
if (!pendingEconomicProductPreview) return;
|
||||
const approvals = {};
|
||||
document.querySelectorAll('[data-economic-product]').forEach(card => {
|
||||
approvals[card.dataset.economicProduct] = Number(card.querySelector('[data-product-group]').value);
|
||||
});
|
||||
bootstrap.Modal.getInstance(document.getElementById('economicProductPreviewModal'))?.hide();
|
||||
await exportOrder(approvals);
|
||||
}
|
||||
|
||||
async function exportOrder(createMissingProducts = {}) {
|
||||
const customerId = Number(document.getElementById('customerId').value || 0);
|
||||
if (!customerId) {
|
||||
showToast('Angiv kunde ID før eksport', 'warning');
|
||||
@ -782,15 +991,18 @@
|
||||
description: line.description,
|
||||
quantity: Number(line.quantity || 0),
|
||||
unit_price: Number(line.unit_price || 0),
|
||||
currency: line.currency || 'DKK',
|
||||
discount_percentage: Number(line.discount_percentage || 0),
|
||||
unit: line.unit || 'stk',
|
||||
product_id: line.product_id || null,
|
||||
ean: line.ean || null,
|
||||
selected: true,
|
||||
})),
|
||||
notes: document.getElementById('orderNotes').value || null,
|
||||
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
|
||||
draft_id: draftId,
|
||||
force_export: document.getElementById('forceExportToggle').checked,
|
||||
create_missing_products: createMissingProducts,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -801,6 +1013,10 @@
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (data.detail && data.detail.code === 'economic_products_missing') {
|
||||
showEconomicProductPreview(data.detail);
|
||||
return;
|
||||
}
|
||||
throw new Error(data.detail || 'Eksport fejlede');
|
||||
}
|
||||
|
||||
|
||||
1
app/modules/planner/__init__.py
Normal file
1
app/modules/planner/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Case planning and capacity module."""
|
||||
1
app/modules/planner/backend/__init__.py
Normal file
1
app/modules/planner/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner API package."""
|
||||
34
app/modules/planner/backend/providers.py
Normal file
34
app/modules/planner/backend/providers.py
Normal file
@ -0,0 +1,34 @@
|
||||
"""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()
|
||||
460
app/modules/planner/backend/router.py
Normal file
460
app/modules/planner/backend/router.py
Normal file
@ -0,0 +1,460 @@
|
||||
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 []}
|
||||
1
app/modules/planner/frontend/__init__.py
Normal file
1
app/modules/planner/frontend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Planner frontend package."""
|
||||
11
app/modules/planner/frontend/views.py
Normal file
11
app/modules/planner/frontend/views.py
Normal file
@ -0,0 +1,11 @@
|
||||
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})
|
||||
26
app/modules/planner/templates/index.html
Normal file
26
app/modules/planner/templates/index.html
Normal file
File diff suppressed because one or more lines are too long
195
app/modules/sag/backend/create_support.py
Normal file
195
app/modules/sag/backend/create_support.py
Normal file
@ -0,0 +1,195 @@
|
||||
"""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
|
||||
@ -5,11 +5,13 @@ CRUD operations, user preferences, snooze/dismiss functionality
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from fastapi import APIRouter, HTTPException, status, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.database import execute_query, execute_insert
|
||||
from app.core.config import settings
|
||||
from app.core.auth_dependencies import require_any_permission
|
||||
from app.services.reminder_notification_service import reminder_notification_service
|
||||
|
||||
@ -19,6 +21,94 @@ router = APIRouter()
|
||||
case_read_access = require_any_permission("cases.view", "tickets.view")
|
||||
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||
|
||||
def _ensure_task_list_rules():
|
||||
execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules (
|
||||
id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL,
|
||||
times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true,
|
||||
notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False)
|
||||
execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False)
|
||||
|
||||
def _task_list(user_id: int, include_groups: bool):
|
||||
group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else ""
|
||||
params = (user_id, user_id) if include_groups else (user_id,)
|
||||
return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name
|
||||
FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id
|
||||
WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred')
|
||||
AND (s.ansvarlig_bruger_id=%s {group_filter})
|
||||
ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or []
|
||||
|
||||
async def _send_task_list(rule, user_id: int):
|
||||
tasks = _task_list(user_id, bool(rule.get('include_groups')))
|
||||
if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'}
|
||||
base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/')
|
||||
|
||||
def cell(value, fallback='—'):
|
||||
value = str(value or fallback).replace('|', '\\|').replace('\n', ' ')
|
||||
return value[:120]
|
||||
|
||||
lines = [
|
||||
'| Sag | Kunde | Status | Prioritet | Deadline |',
|
||||
'| :-- | :-- | :-- | :-- | :-- |',
|
||||
]
|
||||
for row in tasks:
|
||||
title = cell(row.get('titel'), 'Uden titel')
|
||||
link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)"
|
||||
deadline = row.get('deadline')
|
||||
if hasattr(deadline, 'strftime'):
|
||||
deadline = deadline.strftime('%d.%m.%Y')
|
||||
lines.append(
|
||||
f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} "
|
||||
f"| {cell(row.get('priority'))} | {cell(deadline)} |"
|
||||
)
|
||||
result = await reminder_notification_service.send_reminder(
|
||||
reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None,
|
||||
reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id],
|
||||
recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')),
|
||||
notify_email=False, notify_frontend=True, override_user_preferences=True)
|
||||
return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result}
|
||||
|
||||
async def process_task_list_rules():
|
||||
_ensure_task_list_rules()
|
||||
now = datetime.now()
|
||||
window_start = now - timedelta(minutes=5)
|
||||
for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []:
|
||||
times = rule.get('times_json') or []
|
||||
if isinstance(times, str): times = json.loads(times)
|
||||
for scheduled_time in times:
|
||||
try:
|
||||
hour, minute = (int(value) for value in scheduled_time.split(':', 1))
|
||||
scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id'])
|
||||
continue
|
||||
last_sent = rule.get('last_sent_at')
|
||||
if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at):
|
||||
await _send_task_list(rule, int(rule['user_id']))
|
||||
execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False)
|
||||
break
|
||||
|
||||
@router.get('/api/v1/reminder-task-rules')
|
||||
async def list_task_rules(request: Request):
|
||||
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
|
||||
return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or []
|
||||
|
||||
@router.post('/api/v1/reminder-task-rules')
|
||||
async def save_task_rule(request: Request, data: dict):
|
||||
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
|
||||
title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)]
|
||||
if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt')
|
||||
rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost)
|
||||
VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True))))
|
||||
return rows[0]
|
||||
|
||||
@router.post('/api/v1/reminder-task-rules/{rule_id}/send-now')
|
||||
async def send_task_rule_now(rule_id:int, request:Request):
|
||||
_ensure_task_list_rules(); user_id=_get_user_id_from_request(request)
|
||||
rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id))
|
||||
if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke')
|
||||
return await _send_task_list(rule[0],user_id)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
|
||||
@ -6,6 +6,7 @@ import re
|
||||
import hashlib
|
||||
import base64
|
||||
import html
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, List, Optional, Dict
|
||||
@ -89,6 +90,23 @@ def _get_user_id_from_request(request: Request) -> int:
|
||||
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
|
||||
|
||||
|
||||
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
|
||||
"""Mark a case as internet-related without creating duplicate tags."""
|
||||
existing = execute_query_single(
|
||||
"""SELECT id FROM sag_tags
|
||||
WHERE sag_id = %s AND deleted_at IS NULL
|
||||
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
|
||||
LIMIT 1""",
|
||||
(sag_id,),
|
||||
)
|
||||
if not existing:
|
||||
execute_query(
|
||||
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
|
||||
(sag_id,),
|
||||
fetch=False,
|
||||
)
|
||||
|
||||
|
||||
def _normalize_case_status(status_value: Optional[str]) -> str:
|
||||
allowed_statuses = []
|
||||
seen = set()
|
||||
@ -419,7 +437,15 @@ class SagBuzzwordSelectionRequest(BaseModel):
|
||||
|
||||
|
||||
class SagListPreferencesUpdate(BaseModel):
|
||||
type_filters: List[str] = Field(default_factory=list)
|
||||
type_filters: Optional[List[str]] = None
|
||||
column_order: Optional[List[str]] = None
|
||||
hidden_columns: Optional[List[str]] = None
|
||||
|
||||
|
||||
SAG_LIST_COLUMN_KEYS = (
|
||||
"id", "company", "contact", "description", "type", "priority", "status",
|
||||
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
|
||||
@ -1011,8 +1037,25 @@ async def create_sag(request: Request, data: dict):
|
||||
_validate_group_id(assigned_group_id)
|
||||
|
||||
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||
pipeline = data.get("pipeline") if case_type == "pipeline" else None
|
||||
order_items = data.get("order_items") if case_type == "ordre" else []
|
||||
pipeline = data.get("pipeline")
|
||||
order_items = data.get("order_items", [])
|
||||
raw_contact_ids = data.get("contact_ids") or []
|
||||
if not isinstance(raw_contact_ids, list):
|
||||
raise HTTPException(status_code=400, detail="contact_ids skal være en liste")
|
||||
contact_ids = []
|
||||
for raw_contact_id in raw_contact_ids:
|
||||
contact_id = _coerce_optional_int(raw_contact_id, "contact_id")
|
||||
if contact_id and contact_id not in contact_ids:
|
||||
contact_ids.append(contact_id)
|
||||
telefoni_opkald_id = _coerce_optional_int(data.get("telefoni_opkald_id"), "telefoni_opkald_id")
|
||||
raw_connection_ids = data.get("internet_connection_ids") or []
|
||||
if not isinstance(raw_connection_ids, list):
|
||||
raise HTTPException(status_code=400, detail="internet_connection_ids skal være en liste")
|
||||
internet_connection_ids = []
|
||||
for raw_connection_id in raw_connection_ids:
|
||||
connection_id = _coerce_optional_int(raw_connection_id, "internet_connection_id")
|
||||
if connection_id and connection_id not in internet_connection_ids:
|
||||
internet_connection_ids.append(connection_id)
|
||||
if pipeline is not None and not isinstance(pipeline, dict):
|
||||
raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
|
||||
if not isinstance(order_items, list):
|
||||
@ -1027,6 +1070,8 @@ async def create_sag(request: Request, data: dict):
|
||||
if pipeline_values["amount"] not in (None, ""):
|
||||
try:
|
||||
pipeline_values["amount"] = float(pipeline_values["amount"])
|
||||
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
|
||||
raise ValueError("Invalid amount")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
|
||||
else:
|
||||
@ -1081,6 +1126,8 @@ async def create_sag(request: Request, data: dict):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
|
||||
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
|
||||
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
|
||||
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
|
||||
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
@ -1098,6 +1145,64 @@ async def create_sag(request: Request, data: dict):
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
|
||||
if contact_ids:
|
||||
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
|
||||
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
|
||||
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
|
||||
if missing_contact_ids:
|
||||
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
|
||||
for index, contact_id in enumerate(contact_ids):
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
|
||||
VALUES (%s, %s, 'Kontakt', %s)""",
|
||||
(result["id"], contact_id, index == 0),
|
||||
)
|
||||
cursor.execute(
|
||||
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||
VALUES (%s, %s, FALSE, 'Kontakt')
|
||||
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
|
||||
(contact_id, data.get("customer_id")),
|
||||
)
|
||||
|
||||
if internet_connection_ids:
|
||||
cursor.execute(
|
||||
"""SELECT id FROM internet_connections_connections
|
||||
WHERE id = ANY(%s) AND deleted_at IS NULL""",
|
||||
(internet_connection_ids,),
|
||||
)
|
||||
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
|
||||
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
|
||||
if missing_connection_ids:
|
||||
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
|
||||
for connection_id in internet_connection_ids:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
|
||||
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
|
||||
(result["id"], connection_id, current_user_id),
|
||||
)
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_tags (sag_id, tag_navn)
|
||||
SELECT %s, 'internet forbindelse'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sag_tags
|
||||
WHERE sag_id = %s AND deleted_at IS NULL
|
||||
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
|
||||
)""",
|
||||
(result["id"], result["id"]),
|
||||
)
|
||||
|
||||
if telefoni_opkald_id:
|
||||
cursor.execute(
|
||||
"""UPDATE telefoni_opkald
|
||||
SET sag_id = %s,
|
||||
kontakt_id = COALESCE(%s, kontakt_id)
|
||||
WHERE id = %s
|
||||
RETURNING id""",
|
||||
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
for item in normalized_items:
|
||||
if has_purchase_columns:
|
||||
@ -1114,7 +1219,11 @@ async def create_sag(request: Request, data: dict):
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
|
||||
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
|
||||
)
|
||||
from app.modules.sag.backend.create_support import attach_create_relations
|
||||
tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
|
||||
conn.commit()
|
||||
result = dict(result)
|
||||
result["tag_actions"] = tag_actions
|
||||
logger.info("✅ Case created: %s", result["id"])
|
||||
return dict(result)
|
||||
except Exception:
|
||||
@ -1128,6 +1237,46 @@ async def create_sag(request: Request, data: dict):
|
||||
logger.error("❌ Error creating case: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/internet-connections")
|
||||
async def list_sag_internet_connections(sag_id: int):
|
||||
return execute_query(
|
||||
"""SELECT link.connection_id,link.created_at,ic.name,ic.circuit_number,ic.provider_reference,
|
||||
ic.address,ic.customer_id
|
||||
FROM sag_internet_connections link
|
||||
JOIN internet_connections_connections ic ON ic.id=link.connection_id AND ic.deleted_at IS NULL
|
||||
WHERE link.sag_id=%s ORDER BY link.created_at DESC""", (sag_id,),
|
||||
) or []
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/internet-connections", dependencies=[Depends(case_edit_access)])
|
||||
async def link_sag_internet_connection(sag_id: int, request: Request, data: dict):
|
||||
connection_id = _coerce_optional_int(data.get("connection_id"), "connection_id")
|
||||
if not connection_id:
|
||||
raise HTTPException(status_code=400, detail="connection_id er påkrævet")
|
||||
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
|
||||
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
||||
if not execute_query_single(
|
||||
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
|
||||
):
|
||||
raise HTTPException(status_code=404, detail="Internetforbindelsen findes ikke")
|
||||
execute_query(
|
||||
"""INSERT INTO sag_internet_connections (sag_id,connection_id,linked_by_user_id)
|
||||
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
|
||||
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
|
||||
)
|
||||
_ensure_case_internet_connection_tag(sag_id)
|
||||
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
|
||||
|
||||
|
||||
@router.delete("/sag/{sag_id}/internet-connections/{connection_id}", dependencies=[Depends(case_edit_access)])
|
||||
async def unlink_sag_internet_connection(sag_id: int, connection_id: int):
|
||||
execute_query(
|
||||
"DELETE FROM sag_internet_connections WHERE sag_id=%s AND connection_id=%s",
|
||||
(sag_id, connection_id), fetch=False,
|
||||
)
|
||||
return {"unlinked": True}
|
||||
|
||||
@router.get("/sag/{sag_id:int}")
|
||||
async def get_sag(sag_id: int):
|
||||
"""Get a specific case."""
|
||||
@ -1259,16 +1408,14 @@ async def list_recent_sager(request: Request, limit: int = Query(10, ge=1, le=10
|
||||
async def get_my_sag_list_preferences(request: Request):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
try:
|
||||
has_columns = table_has_column("user_sag_list_preferences", "column_order")
|
||||
select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT type_filters
|
||||
FROM user_sag_list_preferences
|
||||
WHERE user_id = %s
|
||||
""",
|
||||
f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
|
||||
(user_id,),
|
||||
) or []
|
||||
if not rows:
|
||||
return {"type_filters": []}
|
||||
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
|
||||
|
||||
raw = rows[0].get("type_filters")
|
||||
parsed = []
|
||||
@ -1291,20 +1438,43 @@ async def get_my_sag_list_preferences(request: Request):
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
return {"type_filters": normalized}
|
||||
raw_order = rows[0].get("column_order") if has_columns else None
|
||||
raw_hidden = rows[0].get("hidden_columns") if has_columns else None
|
||||
if isinstance(raw_order, str):
|
||||
try: raw_order = json.loads(raw_order)
|
||||
except Exception: raw_order = []
|
||||
if isinstance(raw_hidden, str):
|
||||
try: raw_hidden = json.loads(raw_hidden)
|
||||
except Exception: raw_hidden = []
|
||||
order = [str(key) for key in (raw_order or []) if str(key) in SAG_LIST_COLUMN_KEYS]
|
||||
order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in order)
|
||||
hidden = [str(key) for key in (raw_hidden or []) if str(key) in SAG_LIST_COLUMN_KEYS]
|
||||
return {"type_filters": normalized, "column_order": order, "hidden_columns": hidden}
|
||||
except Exception as e:
|
||||
if "user_sag_list_preferences" in str(e):
|
||||
return {"type_filters": []}
|
||||
return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
|
||||
logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to load list preferences")
|
||||
|
||||
|
||||
@router.get("/sag/me/quick-filter-context")
|
||||
async def get_my_sag_quick_filter_context(request: Request):
|
||||
"""Return the authenticated employee and their groups for list quick filters."""
|
||||
user_id = _get_user_id_from_request(request)
|
||||
rows = execute_query(
|
||||
"SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
|
||||
(user_id,),
|
||||
) or []
|
||||
return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
|
||||
|
||||
@router.patch("/sag/me/list-preferences")
|
||||
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
|
||||
user_id = _get_user_id_from_request(request)
|
||||
try:
|
||||
normalized = []
|
||||
seen = set()
|
||||
for value in payload.type_filters or []:
|
||||
existing = await get_my_sag_list_preferences(request)
|
||||
for value in (payload.type_filters if payload.type_filters is not None else existing["type_filters"]):
|
||||
item = str(value or "").strip().lower()
|
||||
if not item or item in seen:
|
||||
continue
|
||||
@ -1313,18 +1483,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
|
||||
seen.add(item)
|
||||
normalized.append(item)
|
||||
|
||||
requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
|
||||
column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
|
||||
column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
|
||||
requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
|
||||
hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
|
||||
|
||||
if not table_has_column("user_sag_list_preferences", "column_order"):
|
||||
raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
|
||||
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at)
|
||||
VALUES (%s, %s::jsonb, NOW())
|
||||
INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
|
||||
VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
type_filters = EXCLUDED.type_filters,
|
||||
column_order = EXCLUDED.column_order,
|
||||
hidden_columns = EXCLUDED.hidden_columns,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
(user_id, json.dumps(normalized)),
|
||||
(user_id, json.dumps(normalized), json.dumps(column_order), json.dumps(hidden_columns)),
|
||||
)
|
||||
return {"type_filters": normalized}
|
||||
return {"type_filters": normalized, "column_order": column_order, "hidden_columns": hidden_columns}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to save list preferences")
|
||||
@ -1541,6 +1724,7 @@ async def delete_todo_step(step_id: int):
|
||||
async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
||||
"""Update a case."""
|
||||
try:
|
||||
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
|
||||
# Check if case exists
|
||||
check = execute_query(
|
||||
"""
|
||||
@ -1562,6 +1746,23 @@ async def update_sag(sag_id: int, request: Request, updates: dict = Body(...)):
|
||||
|
||||
if "status" in updates:
|
||||
updates["status"] = _normalize_case_status(updates.get("status"))
|
||||
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
|
||||
new_status = str(updates.get("status") or "").strip().lower()
|
||||
case_type = str(previous_row.get("template_key") or "").strip().lower()
|
||||
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
|
||||
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
|
||||
has_registered_time = execute_query_single(
|
||||
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
|
||||
(sag_id,),
|
||||
) or {}
|
||||
if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"code": "close_without_time_confirmation_required",
|
||||
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
|
||||
},
|
||||
)
|
||||
if "deadline" in updates:
|
||||
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
|
||||
if "start_date" in updates:
|
||||
@ -2412,6 +2613,28 @@ async def list_case_contacts(sag_id: int):
|
||||
logger.error("❌ Error listing case contacts: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to list case contacts")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/reply-recipients")
|
||||
async def list_case_reply_recipients(sag_id: int, q: str = ""):
|
||||
"""Email-capable case contacts first, followed by contacts from the case customer."""
|
||||
rows = execute_query(
|
||||
"""
|
||||
WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
|
||||
SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
|
||||
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
|
||||
WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
|
||||
UNION
|
||||
SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
|
||||
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
|
||||
) SELECT DISTINCT ON (id) * FROM candidates
|
||||
WHERE NULLIF(TRIM(email),'') IS NOT NULL
|
||||
AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
|
||||
ORDER BY id, linked DESC
|
||||
""",
|
||||
(sag_id, sag_id, q.strip(), q.strip()),
|
||||
) or []
|
||||
return rows
|
||||
|
||||
@router.post("/sag/{sag_id}/contacts")
|
||||
async def add_case_contact(sag_id: int, data: dict):
|
||||
"""Add a contact to a case."""
|
||||
@ -3079,6 +3302,44 @@ async def list_sale_items(sag_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to list sale items")
|
||||
|
||||
|
||||
@router.get("/procurement/overview")
|
||||
async def procurement_overview():
|
||||
"""Operational queue for purchase lines awaiting order, receipt or delivery."""
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
|
||||
p.status, p.line_date, p.external_ref, p.purchase_purpose,
|
||||
p.supplier_invoice_id, p.supplier_invoice_line_id,
|
||||
s.titel AS case_title, c.name AS customer_name,
|
||||
EXISTS (
|
||||
SELECT 1 FROM sag_salgsvarer sale
|
||||
WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
|
||||
AND sale.status <> 'cancelled'
|
||||
) AS has_sales_line,
|
||||
CASE
|
||||
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
|
||||
WHEN p.status = 'confirmed' THEN 'ordered'
|
||||
ELSE 'to_order'
|
||||
END AS fulfilment_state
|
||||
FROM sag_salgsvarer p
|
||||
JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
WHERE p.type = 'purchase' AND p.status <> 'cancelled'
|
||||
ORDER BY CASE
|
||||
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
|
||||
WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
|
||||
p.line_date NULLS LAST, p.id DESC
|
||||
"""
|
||||
) or []
|
||||
counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
|
||||
for row in rows:
|
||||
state = row.get("fulfilment_state") or "to_order"
|
||||
counts[state] = counts.get(state, 0) + 1
|
||||
if not row.get("has_sales_line"):
|
||||
counts["missing_sales_order"] += 1
|
||||
return {"items": rows, "counts": counts}
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/sale-items")
|
||||
async def create_sale_item(sag_id: int, data: dict):
|
||||
"""Create a sale item for a case."""
|
||||
|
||||
@ -1,118 +1,500 @@
|
||||
import json
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from app.core.database import execute_query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from app.core.auth_dependencies import get_current_user, require_any_permission
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.models.schemas import Solution, SolutionCreate, SolutionUpdate
|
||||
from app.core.auth_dependencies import require_any_permission
|
||||
from app.services.case_analysis_service import CaseAnalysisService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
|
||||
VISIBILITIES = {"internal", "general", "customer"}
|
||||
APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"}
|
||||
RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"}
|
||||
TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"}
|
||||
|
||||
SECRET_PATTERNS = (
|
||||
(re.compile(r"(?i)\b(password|passwd|kodeord|api[_ -]?key|secret|token)\s*[:=]\s*([^\s,;]+)"), r"\1: [FJERNET]"),
|
||||
(re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"), "Bearer [FJERNET]"),
|
||||
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[PRIVAT NØGLE FJERNET]"),
|
||||
(re.compile(r"(?i)(?:postgres|mysql|mongodb(?:\+srv)?)://[^\s]+"), "[DATABASEFORBINDELSE FJERNET]"),
|
||||
)
|
||||
|
||||
|
||||
def _user_id(current_user: dict) -> Optional[int]:
|
||||
value = current_user.get("id") or current_user.get("user_id")
|
||||
return int(value) if value is not None else None
|
||||
|
||||
|
||||
def _clean_list(values) -> list[str]:
|
||||
cleaned, seen = [], set()
|
||||
for value in values or []:
|
||||
item = str(value or "").strip()
|
||||
key = item.casefold()
|
||||
if item and key not in seen:
|
||||
seen.add(key)
|
||||
cleaned.append(item[:100])
|
||||
return cleaned[:30]
|
||||
|
||||
|
||||
def _normalize_payload(data: dict) -> dict:
|
||||
if "title" in data:
|
||||
data["title"] = str(data.get("title") or "").strip()
|
||||
if not data["title"]:
|
||||
raise HTTPException(status_code=422, detail="Løsningen skal have en titel")
|
||||
if "visibility" in data:
|
||||
data["visibility"] = str(data.get("visibility") or "internal").lower()
|
||||
if data["visibility"] not in VISIBILITIES:
|
||||
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
|
||||
if "approval_status" in data:
|
||||
data["approval_status"] = str(data.get("approval_status") or "draft").lower()
|
||||
if data["approval_status"] not in APPROVAL_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
|
||||
if data.get("result") is not None:
|
||||
raw = str(data["result"]).strip()
|
||||
data["result"] = RESULT_ALIASES.get(raw.casefold(), raw)
|
||||
if data.get("solution_type") is not None:
|
||||
raw = str(data["solution_type"]).strip()
|
||||
data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw)
|
||||
for field in ("tags", "products"):
|
||||
if field in data:
|
||||
data[field] = _clean_list(data[field])
|
||||
return data
|
||||
|
||||
|
||||
def _redact_sensitive(text: str) -> tuple[str, list[str]]:
|
||||
cleaned = str(text or "")
|
||||
warnings = []
|
||||
for pattern, replacement in SECRET_PATTERNS:
|
||||
cleaned, count = pattern.subn(replacement, cleaned)
|
||||
if count:
|
||||
warnings.append(f"{count} mulig(e) hemmelighed(er) blev fjernet før AI-behandling")
|
||||
return cleaned, warnings
|
||||
|
||||
|
||||
def _knowledge_tokens(text: str) -> list[str]:
|
||||
ignored = {"eller", "ikke", "med", "den", "det", "der", "som", "for", "fra", "til", "har", "kan", "skal", "sag", "sagen", "test", "viden"}
|
||||
tokens, seen = [], set()
|
||||
for token in re.findall(r"[A-Za-zÀ-ÿ0-9_.-]{3,}", str(text or "").lower()):
|
||||
if token in ignored or token in seen:
|
||||
continue
|
||||
seen.add(token)
|
||||
tokens.append(token)
|
||||
return tokens[:24]
|
||||
|
||||
|
||||
def _normalize_source_refs(raw_refs, allowed_refs: set[str], sag_id: int) -> list[str]:
|
||||
if isinstance(raw_refs, str):
|
||||
raw_refs = re.split(r"[,;\n]+", raw_refs)
|
||||
normalized = [f"Sag {sag_id}"]
|
||||
for raw in raw_refs or []:
|
||||
value = str(raw or "").strip().strip("[]")
|
||||
match = re.search(r"(?i)\b(sag|kommentar|mail|tid|artikel)\s*#?\s*(\d+)\b", value)
|
||||
if not match:
|
||||
continue
|
||||
kind = match.group(1).capitalize()
|
||||
canonical = f"{kind} {int(match.group(2))}"
|
||||
if canonical in allowed_refs and canonical not in normalized:
|
||||
normalized.append(canonical)
|
||||
return normalized
|
||||
|
||||
|
||||
def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None:
|
||||
version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1}
|
||||
snapshot = dict(solution)
|
||||
for key, value in list(snapshot.items()):
|
||||
if hasattr(value, "isoformat"):
|
||||
snapshot[key] = value.isoformat()
|
||||
execute_query(
|
||||
"INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)",
|
||||
(solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note),
|
||||
)
|
||||
|
||||
|
||||
def _case_knowledge_context(sag_id: int, limit: int = 6) -> tuple[dict, list[dict]]:
|
||||
case = execute_query_single(
|
||||
"""SELECT s.id,s.titel,s.beskrivelse,s.status,
|
||||
(SELECT sk.customer_id FROM sag_kunder sk WHERE sk.sag_id=s.id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1) AS customer_id,
|
||||
COALESCE((SELECT string_agg(t.name,' ') FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=s.id),'') AS tag_text
|
||||
FROM sag_sager s WHERE s.id=%s AND s.deleted_at IS NULL""",
|
||||
(sag_id,),
|
||||
)
|
||||
if not case:
|
||||
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
||||
tokens = _knowledge_tokens(" ".join([str(case.get("titel") or ""), str(case.get("beskrivelse") or ""), str(case.get("tag_text") or "")]))
|
||||
if not tokens:
|
||||
return case, []
|
||||
search_query = " OR ".join(tokens)
|
||||
articles = execute_query(
|
||||
"""SELECT ka.id,ka.title,ka.summary,ka.problem,ka.root_cause,ka.solution,ka.workaround,
|
||||
ka.visibility,ka.customer_id,ka.tags,ka.products,ka.sag_id,ka.updated_at,
|
||||
ts_rank_cd(ka.search_document,websearch_to_tsquery('simple',%s)) AS relevance
|
||||
FROM knowledge_articles ka
|
||||
WHERE ka.status='published' AND ka.archived_at IS NULL
|
||||
AND (ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))
|
||||
AND ka.search_document @@ websearch_to_tsquery('simple',%s)
|
||||
ORDER BY relevance DESC,ka.updated_at DESC LIMIT %s""",
|
||||
(search_query, case.get("customer_id"), search_query, limit),
|
||||
) or []
|
||||
if articles:
|
||||
top_relevance = float(articles[0].get("relevance") or 0)
|
||||
relative_floor = max(0.05, top_relevance * 0.35)
|
||||
articles = [row for row in articles if float(row.get("relevance") or 0) >= relative_floor]
|
||||
return case, articles
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
||||
async def get_solution(sag_id: int):
|
||||
"""Get the solution associated with a case."""
|
||||
try:
|
||||
query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
|
||||
result = execute_query(query, (sag_id,))
|
||||
if not result:
|
||||
return None
|
||||
return result[0]
|
||||
except Exception as e:
|
||||
logger.error("❌ Error getting solution for case %s: %s", sag_id, e)
|
||||
raise HTTPException(status_code=500, detail="Failed to get solution")
|
||||
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
||||
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
|
||||
return result[0] if result else None
|
||||
|
||||
@router.post(
|
||||
"/sag/{sag_id}/solution",
|
||||
response_model=Solution,
|
||||
dependencies=[Depends(case_edit_access)],
|
||||
|
||||
@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),
|
||||
)
|
||||
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")
|
||||
created = result[0]
|
||||
_version_solution(created, user_id, "Løsning oprettet")
|
||||
return created
|
||||
|
||||
# Check if solution already exists
|
||||
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||
if check:
|
||||
raise HTTPException(status_code=400, detail="Solution already exists for this case")
|
||||
|
||||
query = """
|
||||
INSERT INTO sag_solutions
|
||||
(sag_id, title, description, solution_type, result, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
@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 på dansk.
|
||||
- confidence er et tal mellem 0 og 1.
|
||||
"""
|
||||
params = (
|
||||
sag_id,
|
||||
solution.title,
|
||||
solution.description,
|
||||
solution.solution_type,
|
||||
solution.result,
|
||||
getattr(request.state, "user_id", None)
|
||||
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:
|
||||
warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner")
|
||||
result = {
|
||||
"title": case.get("titel") or "",
|
||||
"problem": case.get("beskrivelse") or "",
|
||||
"root_cause": "",
|
||||
"investigation": "",
|
||||
"description": "",
|
||||
"workaround": "",
|
||||
"solution_type": "Support",
|
||||
"result": "Ej løst",
|
||||
"tags": _knowledge_tokens(case.get("tag_text") or "")[:10],
|
||||
"products": [],
|
||||
"confidence": 0.2,
|
||||
"uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"],
|
||||
"source_refs": [f"Sag {sag_id}"],
|
||||
}
|
||||
allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"}
|
||||
source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id)
|
||||
draft = {
|
||||
"title": str(result.get("title") or case.get("titel") or "")[:255],
|
||||
"problem": str(result.get("problem") or ""),
|
||||
"root_cause": str(result.get("root_cause") or ""),
|
||||
"investigation": str(result.get("investigation") or ""),
|
||||
"description": str(result.get("description") or ""),
|
||||
"workaround": str(result.get("workaround") or ""),
|
||||
"solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"),
|
||||
"result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"),
|
||||
"tags": _clean_list(result.get("tags")),
|
||||
"products": _clean_list(result.get("products")),
|
||||
"confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))),
|
||||
"uncertainties": _clean_list(result.get("uncertainties")),
|
||||
"source_refs": source_refs,
|
||||
}
|
||||
if not draft["description"]:
|
||||
warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning")
|
||||
if not source_refs:
|
||||
warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger")
|
||||
return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True}
|
||||
|
||||
|
||||
@router.get("/solutions")
|
||||
async def list_solutions(
|
||||
q: str = Query("", max_length=200),
|
||||
approval_status: Optional[str] = None,
|
||||
visibility: Optional[str] = None,
|
||||
include_archived: bool = False,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
_current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"]
|
||||
params: list = []
|
||||
if approval_status:
|
||||
if approval_status not in APPROVAL_STATUSES:
|
||||
raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus")
|
||||
where.append("s.approval_status=%s")
|
||||
params.append(approval_status)
|
||||
if visibility:
|
||||
if visibility not in VISIBILITIES:
|
||||
raise HTTPException(status_code=422, detail="Ugyldig synlighed")
|
||||
where.append("s.visibility=%s")
|
||||
params.append(visibility)
|
||||
term = q.strip()
|
||||
if term:
|
||||
where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)")
|
||||
like = f"%{term}%"
|
||||
params.extend([like] * 7)
|
||||
where_sql = " AND ".join(where)
|
||||
count = execute_query_single(
|
||||
f"""SELECT COUNT(*) AS total FROM sag_solutions s
|
||||
JOIN sag_sager sg ON sg.id=s.sag_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
||||
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
||||
) c ON TRUE WHERE {where_sql}""",
|
||||
tuple(params),
|
||||
) or {"total": 0}
|
||||
items = execute_query(
|
||||
f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name,
|
||||
COALESCE(creator.full_name,creator.username) AS created_by,
|
||||
COALESCE(updater.full_name,updater.username) AS updated_by,
|
||||
ka.id AS article_id, ka.status AS article_status
|
||||
FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id
|
||||
WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1
|
||||
) c ON TRUE
|
||||
LEFT JOIN users creator ON creator.user_id=s.created_by_user_id
|
||||
LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id
|
||||
LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id
|
||||
WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""",
|
||||
tuple(params + [limit, offset]),
|
||||
) or []
|
||||
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived}
|
||||
|
||||
|
||||
@router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)])
|
||||
async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
||||
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,))
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret")
|
||||
user_id = _user_id(current_user)
|
||||
execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id))
|
||||
archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
||||
_version_solution(archived, user_id, "Løsning arkiveret")
|
||||
return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]}
|
||||
|
||||
|
||||
@router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)])
|
||||
async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)):
|
||||
solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,))
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke")
|
||||
user_id = _user_id(current_user)
|
||||
restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id))
|
||||
_version_solution(restored, user_id, "Løsning gendannet")
|
||||
return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]}
|
||||
|
||||
|
||||
@router.get("/knowledge/articles")
|
||||
async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)):
|
||||
term = q.strip()
|
||||
scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')"
|
||||
params: list = [customer_id] if customer_id else []
|
||||
search = ""
|
||||
if term:
|
||||
search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)"
|
||||
params.extend([term, f"%{term}%"])
|
||||
count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0}
|
||||
item_params = []
|
||||
rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0"
|
||||
if term:
|
||||
item_params.append(term)
|
||||
item_params.extend(params)
|
||||
item_params.extend([limit, offset])
|
||||
items = execute_query(
|
||||
f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products,
|
||||
ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name
|
||||
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
|
||||
WHERE ka.status='published' AND {scope}{search}
|
||||
ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""",
|
||||
tuple(item_params),
|
||||
) or []
|
||||
return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/knowledge/articles/{article_id}")
|
||||
async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)):
|
||||
article = execute_query_single(
|
||||
"""SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by
|
||||
FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id
|
||||
LEFT JOIN users u ON u.user_id=ka.published_by_user_id
|
||||
WHERE ka.id=%s AND ka.status='published'""",
|
||||
(article_id,),
|
||||
)
|
||||
|
||||
result = execute_query(query, params)
|
||||
if result:
|
||||
logger.info("✅ Solution created for case: %s", sag_id)
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to create solution")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error creating solution: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to create solution")
|
||||
|
||||
@router.patch(
|
||||
"/sag/{sag_id}/solution",
|
||||
response_model=Solution,
|
||||
dependencies=[Depends(case_edit_access)],
|
||||
)
|
||||
async def update_solution(sag_id: int, updates: SolutionUpdate):
|
||||
"""Update a solution."""
|
||||
try:
|
||||
# Check if solution exists
|
||||
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||
if not check:
|
||||
raise HTTPException(status_code=404, detail="Solution not found")
|
||||
|
||||
# Build dynamic update query
|
||||
set_clauses = []
|
||||
params = []
|
||||
|
||||
# Helper to check and add params
|
||||
if updates.title is not None:
|
||||
set_clauses.append("title = %s")
|
||||
params.append(updates.title)
|
||||
if updates.description is not None:
|
||||
set_clauses.append("description = %s")
|
||||
params.append(updates.description)
|
||||
if updates.solution_type is not None:
|
||||
set_clauses.append("solution_type = %s")
|
||||
params.append(updates.solution_type)
|
||||
if updates.result is not None:
|
||||
set_clauses.append("result = %s")
|
||||
params.append(updates.result)
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
|
||||
params.append(sag_id)
|
||||
query = f"UPDATE sag_solutions SET {', '.join(set_clauses)} WHERE sag_id = %s RETURNING *"
|
||||
|
||||
result = execute_query(query, tuple(params))
|
||||
if result:
|
||||
logger.info("✅ Solution updated for case: %s", sag_id)
|
||||
return result[0]
|
||||
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error updating solution: %s", e)
|
||||
raise HTTPException(status_code=500, detail="Failed to update solution")
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
|
||||
return article
|
||||
|
||||
@ -13,6 +13,48 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/knowledge", response_class=HTMLResponse)
|
||||
async def knowledge_index(request: Request):
|
||||
"""Read-only first release of the case knowledge base."""
|
||||
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/solutions", response_class=HTMLResponse)
|
||||
async def solutions_management(request: Request):
|
||||
"""Central management surface for case solutions and their publication flow."""
|
||||
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/procurement", response_class=HTMLResponse)
|
||||
async def procurement_overview_page(request: Request):
|
||||
return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
|
||||
|
||||
@router.get("/reminder-rules", response_class=HTMLResponse)
|
||||
async def reminder_rules_page(request: Request):
|
||||
return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
|
||||
async def knowledge_detail(request: Request, article_id: int):
|
||||
article = execute_query(
|
||||
"""
|
||||
SELECT ka.*, c.name AS customer_name,
|
||||
COALESCE(u.full_name, u.username) AS published_by
|
||||
FROM knowledge_articles ka
|
||||
LEFT JOIN customers c ON c.id = ka.customer_id
|
||||
LEFT JOIN users u ON u.user_id = ka.published_by_user_id
|
||||
WHERE ka.id = %s AND ka.status = 'published'
|
||||
""",
|
||||
(article_id,),
|
||||
)
|
||||
if not article:
|
||||
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
|
||||
return templates.TemplateResponse(
|
||||
"modules/sag/templates/knowledge_detail.html",
|
||||
{"request": request, "article": article[0]},
|
||||
)
|
||||
|
||||
|
||||
def _render_api_print_bridge(api_path: str, page_title: str) -> str:
|
||||
safe_api_path = json.dumps(api_path)
|
||||
safe_title = json.dumps(page_title)
|
||||
@ -203,6 +245,7 @@ async def sager_liste(
|
||||
assigned_group_id: str = Query(None),
|
||||
unassigned: bool = Query(False),
|
||||
include_deferred: bool = Query(False),
|
||||
quick: str = Query(None),
|
||||
):
|
||||
"""Display list of all cases."""
|
||||
try:
|
||||
@ -215,6 +258,7 @@ async def sager_liste(
|
||||
query = """
|
||||
SELECT s.*,
|
||||
c.name as customer_name,
|
||||
sk_first.contact_id AS kontakt_id,
|
||||
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
g.name AS assigned_group_name,
|
||||
@ -278,7 +322,8 @@ async def sager_liste(
|
||||
query += ")"
|
||||
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
|
||||
|
||||
normalized_status = str(status or "").strip().lower()
|
||||
normalized_quick = str(quick or "").strip().lower()
|
||||
normalized_status = "all" if normalized_quick == "closed" else str(status or "").strip().lower()
|
||||
normalized_priority = str(priority or "").strip().lower()
|
||||
if normalized_status == "all":
|
||||
pass
|
||||
@ -316,6 +361,7 @@ async def sager_liste(
|
||||
fallback_query = """
|
||||
SELECT s.*,
|
||||
c.name as customer_name,
|
||||
NULL::integer AS kontakt_id,
|
||||
'' as kontakt_navn,
|
||||
COALESCE(u.full_name, u.username) AS ansvarlig_navn,
|
||||
NULL::text AS assigned_group_name,
|
||||
@ -439,6 +485,7 @@ async def sager_liste(
|
||||
"relations_map": relations_map,
|
||||
"child_ids": list(child_ids),
|
||||
"statuses": status_options,
|
||||
"status_options": status_options,
|
||||
"all_tags": [t['tag_navn'] for t in all_tags],
|
||||
"current_status": status,
|
||||
"current_priority": normalized_priority,
|
||||
@ -451,16 +498,19 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"current_quick_filter": normalized_quick or "all",
|
||||
"closed_statuses": closed_statuses,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("❌ Error displaying case list")
|
||||
fallback_status_options = _fetch_case_status_options()
|
||||
return templates.TemplateResponse("modules/sag/templates/index.html", {
|
||||
"request": request,
|
||||
"sager": [],
|
||||
"relations_map": {},
|
||||
"child_ids": [],
|
||||
"statuses": _fetch_case_status_options(),
|
||||
"statuses": fallback_status_options,
|
||||
"status_options": fallback_status_options,
|
||||
"all_tags": [],
|
||||
"current_status": status,
|
||||
"current_priority": str(priority or "").strip().lower(),
|
||||
@ -473,6 +523,7 @@ async def sager_liste(
|
||||
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
|
||||
"current_assigned_group_id": assigned_group_id_int,
|
||||
"current_unassigned": requested_unassigned,
|
||||
"current_quick_filter": str(quick or "").strip().lower() or "all",
|
||||
"closed_statuses": _fetch_closed_case_statuses(),
|
||||
})
|
||||
|
||||
@ -792,11 +843,31 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
c.phone,
|
||||
c.mobile,
|
||||
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
|
||||
JOIN contacts c ON sk.contact_id = c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cu.name AS customer_name
|
||||
SELECT cu.id AS customer_id, cu.name AS customer_name
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
@ -821,7 +892,7 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
comments = execute_query(comments_query, (sag_id,))
|
||||
|
||||
# Fetch Solution
|
||||
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
|
||||
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
|
||||
solution_res = execute_query(solution_query, (sag_id,))
|
||||
solution = solution_res[0] if solution_res else None
|
||||
|
||||
@ -1153,11 +1224,25 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
c.phone,
|
||||
c.mobile,
|
||||
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
|
||||
JOIN contacts c ON sk.contact_id = c.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT cu.name AS customer_name
|
||||
SELECT cu.id AS customer_id, cu.name AS customer_name
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
@ -1182,7 +1267,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
comments = execute_query(comments_query, (sag_id,))
|
||||
|
||||
# Fetch Solution
|
||||
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s"
|
||||
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
|
||||
solution_res = execute_query(solution_query, (sag_id,))
|
||||
solution = solution_res[0] if solution_res else None
|
||||
|
||||
|
||||
@ -4,6 +4,21 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.cc-panel { border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; margin: 0 0 12px; background: var(--bg-surface, #fff); }
|
||||
.cc-panel > summary { padding: 14px 16px; cursor: pointer; font-weight: 600; }
|
||||
.cc-summary { display: block; color: var(--text-secondary, #65758a); font-weight: 400; margin-left: 18px; overflow-wrap: anywhere; }
|
||||
.cc-panel-body { padding: 8px 16px 16px; }
|
||||
.cc-panel-body > h5, .cc-panel-body > section > h5, .cc-panel-body > section > hr, #createForm > hr { display: none; }
|
||||
.cc-types { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cc-types .btn { min-height: 44px; }
|
||||
#cc-more > summary { padding: 10px; cursor: pointer; }
|
||||
.cc-actions { position: sticky; bottom: 55px; z-index: 1020; padding: 16px; border: 1px solid var(--border-color, #dce3eb); border-radius: 10px; background: var(--bg-surface, #fff); box-shadow: 0 -4px 18px #0001; flex-wrap: wrap; }
|
||||
.cc-workload { margin: 12px 0; padding: 12px; border-radius: 8px; background: var(--bg-hover, #f5f7fa); }
|
||||
.cc-workload summary { cursor: pointer; }
|
||||
.cc-work-item { padding: 8px 0; border-bottom: 1px solid var(--border-color, #dce3eb); }
|
||||
.cc-work-item small { display: block; }
|
||||
[data-bs-theme="dark"] #createForm .bg-light { background: var(--bg-surface) !important; color: var(--text-primary); }
|
||||
@media(max-width: 600px) { .cc-actions { bottom: 55px; padding: 10px; gap: 8px !important; } .cc-actions #cc-save { width: 100%; } .cc-actions .btn { padding: 10px 16px !important; } .cc-panel-body { padding: 8px 12px; } }
|
||||
/* Gradient Header for the Card */
|
||||
.card-header-custom {
|
||||
background: linear-gradient(135deg, var(--bmc-blue, #0f4c75) 0%, #3282b8 100%);
|
||||
@ -136,18 +151,144 @@
|
||||
.case-top-alerts .alert-danger {
|
||||
border-left-color: #e03131;
|
||||
}
|
||||
.cc-page { max-width: 1440px; padding-bottom: 100px !important; }
|
||||
.cc-pagebar { display:flex; justify-content:space-between; align-items:center; gap:16px; margin:0 0 20px; }
|
||||
.cc-pagebar > a { color:var(--text-secondary,#63758c); text-decoration:none; font-size:.88rem; }
|
||||
.cc-pagebar .btn { border-radius:12px; font-size:.85rem; padding:10px 16px; }
|
||||
.cc-page .card-custom { border:1px solid var(--border-color,#dce5ed); border-radius:22px; box-shadow:0 16px 48px #143b5610; }
|
||||
.cc-page .card-header-custom { display:flex; justify-content:space-between; align-items:center; gap:20px; padding:34px 36px; border-radius:21px 21px 0 0; background:linear-gradient(115deg,#122f46,#164b62); }
|
||||
.cc-eyebrow { display:block; color:#9ed7de; font-size:.66rem; font-weight:700; letter-spacing:.15em; margin-bottom:10px; }
|
||||
.cc-page h1 { font-size:1.9rem; font-weight:700; letter-spacing:-.04em; margin-bottom:10px; }
|
||||
.cc-page .card-header-custom p { color:#c5dce6; font-size:.9rem; max-width:520px; line-height:1.6; }
|
||||
.cc-hero-icon { display:grid; place-items:center; flex-shrink:0; width:72px; height:72px; border-radius:22px; background:#ffffff0d; border:1px solid #ffffff25; font-size:2rem; color:#a9e4df; }
|
||||
.cc-page .card-custom > .card-body { padding:32px 42px !important; }
|
||||
.cc-page .form-control,.cc-page .form-select { border-radius:10px; min-height:43px; background-color:var(--bg-surface,#fff); border-color:var(--border-color,#dbe4ec); }
|
||||
.cc-page .input-group-text { border-radius:10px 0 0 10px; background:transparent !important; color:#7891a5; }
|
||||
.cc-page .input-group .form-control { border-top-left-radius:0; border-bottom-left-radius:0; }
|
||||
.cc-page textarea.form-control { line-height:1.7; padding:14px 16px; }
|
||||
.cc-page .form-label { font-size:.82rem; }
|
||||
.cc-page .form-text { font-size:.75rem; line-height:1.5; }
|
||||
.cc-page .cc-type-section > label { font-size:.72rem; text-transform:uppercase; letter-spacing:.08em; color:var(--text-secondary,#63758c); }
|
||||
.cc-page #cc-types { align-items:stretch; gap:10px; }
|
||||
.cc-page #cc-types > button { flex:1; border:1px solid var(--border-color,#dbe4ec); color:var(--text-primary,#26465d); border-radius:13px; text-align:left; padding:14px; background:var(--bg-surface,#fff); transition:border-color .15s,background .15s; }
|
||||
.cc-type-name { display:block; font-size:.9rem; font-weight:600; }
|
||||
.cc-type-hint { display:block; color:var(--text-secondary,#708397); margin-top:5px; font-size:.7rem; font-weight:400; }
|
||||
.cc-page #cc-types > button.active { border-color:#258783; background:var(--accent-light,#eaf6f4); box-shadow:0 0 0 1px #258783; }
|
||||
.cc-page #cc-types > .cc-message-type { border-style:dashed; border-color:#86aaa8; background:color-mix(in srgb,var(--accent-light,#eaf6f4) 62%,var(--bg-surface,#fff)); }
|
||||
.cc-page #cc-types > .cc-message-type:hover { border-style:solid; background:var(--accent-light,#eaf6f4); }
|
||||
.cc-message-type .cc-type-name { display:flex; align-items:center; gap:7px; }
|
||||
.cc-message-type .cc-type-name::after { content:'INTERN'; padding:2px 5px; border-radius:4px; background:#267b74; color:#fff; font-size:.52rem; font-weight:700; letter-spacing:.08em; }
|
||||
.cc-message-fallback { display:flex; width:100%; margin-top:12px; text-align:left; padding:14px; border-radius:13px; }
|
||||
.cc-page #cc-types > button:hover { border-color:#258783; }
|
||||
.cc-page #cc-more { flex-basis:100%; font-size:.78rem; }
|
||||
.cc-page #cc-more > summary { padding:4px 0; color:var(--text-secondary,#63758c); }
|
||||
.cc-page #cc-more .cc-types { padding-top:10px; }
|
||||
.cc-page .cc-panel { border-radius:13px; margin-bottom:12px; }
|
||||
.cc-page .cc-panel > summary { display:flex; align-items:center; gap:12px; list-style:none; padding:15px 17px; }
|
||||
.cc-page .cc-panel > summary::-webkit-details-marker { display:none; }
|
||||
.cc-page .cc-panel > summary::after { content:'⌄'; margin-left:auto; color:#8196a8; transition:transform .15s; }
|
||||
.cc-page .cc-panel[open] > summary::after { transform:rotate(180deg); }
|
||||
.cc-panel-icon { display:grid; place-items:center; flex-shrink:0; width:35px; height:35px; border-radius:10px; color:#3e777e; background:var(--accent-light,#eef5f6); }
|
||||
.cc-panel-label { min-width:0; font-size:.86rem; }
|
||||
.cc-page .cc-summary { margin:3px 0 0; font-size:.72rem; }
|
||||
.cc-page .cc-panel[open] { border-color:#a7c5cc; }
|
||||
.cc-page .cc-panel-body { padding:8px 18px 18px; }
|
||||
.cc-page .cc-actions { border-radius:14px; box-shadow:0 8px 30px #143b561c; margin-top:28px !important; }
|
||||
.cc-page .cc-actions .btn { border-radius:10px; }
|
||||
.cc-page #submitBtn { background:#176967; border-color:#176967; }
|
||||
.cc-page #cc-save { color:var(--text-secondary,#63758c); font-size:.73rem; }
|
||||
.cc-template-toolbar { display:flex; align-items:center; gap:12px; flex-wrap:wrap; padding-bottom:20px; margin-bottom:20px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
||||
.cc-template-toolbar .form-label { margin:0; }
|
||||
.cc-template-toolbar .form-select { flex:1; width:auto; max-width:320px; min-width:180px; font-size:.83rem; }
|
||||
.cc-page .cc-workload { background:var(--bg-hover,#f5f8fa); border:1px solid var(--border-color,#e3eaf0); font-size:.8rem; }
|
||||
.cc-contact-cases { margin-top:14px; padding:0; border:1px solid #bdd9da; border-radius:12px; background:var(--bg-surface,#fff); overflow:hidden; font-size:.8rem; box-shadow:0 4px 14px #164b620c; }
|
||||
.cc-contact-cases[hidden] { display:none; }
|
||||
.cc-contact-cases__header { display:flex; align-items:center; gap:10px; padding:11px 13px; background:#edf8f7; color:#195f5e; }
|
||||
.cc-contact-cases__header-icon { display:grid; place-items:center; width:29px; height:29px; border-radius:8px; background:#cfeceb; font-size:.85rem; }
|
||||
.cc-contact-cases__eyebrow { display:block; color:#4d8382; font-size:.62rem; font-weight:700; letter-spacing:.07em; text-transform:uppercase; }
|
||||
.cc-contact-cases__title { display:block; font-weight:700; line-height:1.25; }
|
||||
.cc-contact-cases__body { padding:6px 13px 10px; }
|
||||
.cc-contact-cases__group { padding-top:8px; }
|
||||
.cc-contact-cases__group-name { display:block; color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin:0 0 4px; }
|
||||
.cc-contact-cases__item { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:9px 0; border-bottom:1px solid var(--border-color,#e6eff0); }
|
||||
.cc-contact-cases__item:last-child { border-bottom:0; }
|
||||
.cc-contact-cases__item a { color:var(--text-primary,#213547); font-weight:600; text-decoration:none; }
|
||||
.cc-contact-cases__item a:hover { color:#176967; text-decoration:underline; }
|
||||
.cc-contact-cases__item small { color:var(--text-secondary,#65758a); white-space:nowrap; font-size:.69rem; }
|
||||
.cc-contact-cases__more { display:block; padding-top:6px; color:var(--text-secondary,#65758a); font-size:.69rem; }
|
||||
.cc-contact-cases__empty { padding:11px 13px; color:var(--text-secondary,#65758a); }
|
||||
.cc-tag-suggestions { margin:10px 0 4px; padding-top:10px; border-top:1px solid var(--border-color,#e3eaf0); }
|
||||
.cc-tag-suggestions__label { color:var(--text-secondary,#65758a); font-size:.7rem; font-weight:600; margin-bottom:6px; }
|
||||
.cc-tag-suggestions .btn { border-radius:999px; font-size:.72rem; padding:3px 8px; }
|
||||
[data-bs-theme="dark"] .cc-contact-cases { background:var(--bg-surface); border-color:#326b6b; }
|
||||
[data-bs-theme="dark"] .cc-contact-cases__header { background:#173e3e; color:#c9edeb; }
|
||||
@media(min-width:1200px) {
|
||||
.cc-page #cc-relations .row { grid-template-columns:minmax(0, 1fr) minmax(0, 1fr); display:grid; gap:28px !important; }
|
||||
.cc-page #cc-relations .row > [class*="col-"] { width:auto; max-width:none; padding:0; }
|
||||
.cc-page .cc-panel-body { padding:14px 24px 24px; }
|
||||
.cc-contact-cases__item { display:grid; grid-template-columns:minmax(0, 1fr) auto; column-gap:14px; align-items:center; }
|
||||
.cc-contact-cases__item small { text-align:right; margin:0; }
|
||||
}
|
||||
@media(max-width:600px) { .cc-page .card-header-custom { padding:25px 20px; } .cc-hero-icon { display:none; } .cc-page h1 { font-size:1.6rem; } .cc-page .card-custom > .card-body { padding:20px 16px !important; } .cc-page #cc-types > button { flex:1 1 40%; } .cc-pagebar { align-items:flex-start; } .cc-pagebar .btn { max-width:190px; } }
|
||||
|
||||
/* Calm, task-first layout: the form is the product, not the decoration. */
|
||||
.cc-page { max-width:1240px; }
|
||||
.cc-page .card-custom { border-radius:16px; box-shadow:0 8px 28px #143b560d; }
|
||||
.cc-page .card-header-custom { min-height:auto; padding:20px 28px; border-radius:15px 15px 0 0; background:var(--bg-surface,#fff); color:var(--text-primary,#213547); border-bottom:1px solid var(--border-color,#dce5ed); }
|
||||
.cc-page .card-header-custom p { display:none; }
|
||||
.cc-page .card-header-custom .cc-eyebrow { color:#267b74; margin:0 0 4px; font-size:.59rem; }
|
||||
.cc-page .card-header-custom h1 { margin:0; font-size:1.35rem; letter-spacing:-.02em; color:inherit; }
|
||||
.cc-page .cc-hero-icon { width:42px; height:42px; border-radius:12px; font-size:1.15rem; color:#267b74; background:var(--accent-light,#eaf6f4); border:0; }
|
||||
.cc-page .card-custom > .card-body { padding:24px 28px 32px !important; }
|
||||
.cc-template-toolbar { padding:12px 14px; margin:0 0 16px !important; border:1px solid var(--border-color,#e3eaf0); border-radius:10px; background:var(--bg-hover,#f7fafb); }
|
||||
.cc-template-toolbar .form-label { font-weight:600; font-size:.76rem; }
|
||||
.cc-template-toolbar .form-select { min-height:36px; max-width:280px; font-size:.78rem; }
|
||||
.cc-page .cc-type-section { padding:0 0 16px; margin-bottom:16px !important; border-bottom:1px solid var(--border-color,#e3eaf0); }
|
||||
.cc-page .cc-type-section > label { display:block; margin-bottom:8px !important; text-transform:none; letter-spacing:0; color:var(--text-primary,#213547); font-weight:600; font-size:.82rem; }
|
||||
.cc-page #caseTypeHelp { display:none; }
|
||||
.cc-page #cc-types { gap:7px; }
|
||||
.cc-page #cc-types > button { flex:0 1 auto; min-height:0; padding:8px 11px; border-radius:9px; font-size:.78rem; }
|
||||
.cc-page .cc-type-name { display:inline; font-size:.78rem; }
|
||||
.cc-page .cc-type-hint { display:none; }
|
||||
.cc-page #cc-types > button.active { box-shadow:none; background:#176967; border-color:#176967; color:#fff; }
|
||||
.cc-page #cc-types > button.active .cc-type-name::after { background:#fff3; color:#fff; }
|
||||
.cc-page #cc-types > .cc-message-type { margin-left:auto; border-style:solid; background:transparent; }
|
||||
.cc-page #cc-more { flex-basis:auto; }
|
||||
.cc-page #cc-more > summary { padding:8px 4px; }
|
||||
.cc-page .cc-panel { border-radius:10px; margin-bottom:8px; box-shadow:none; }
|
||||
.cc-page .cc-panel > summary { padding:12px 14px; }
|
||||
.cc-panel-icon { width:30px; height:30px; border-radius:8px; font-size:.82rem; }
|
||||
.cc-page .cc-panel-label { font-size:.8rem; }
|
||||
.cc-page .cc-summary { display:inline; margin-left:7px; font-size:.69rem; }
|
||||
.cc-page .cc-panel-body { padding:8px 14px 16px; }
|
||||
.cc-page .cc-panel-body > .row { margin-bottom:0 !important; }
|
||||
.cc-page #cc-relations[open] { border-color:#9dc7c3; }
|
||||
.cc-page #cc-metadata { margin-top:16px; }
|
||||
.cc-page #cc-workload { margin:8px 0 16px; padding:10px 14px; }
|
||||
.cc-page .cc-actions { margin-top:20px !important; padding:12px 14px; }
|
||||
/* The panels have very different heights. Keep them in a single stable flow
|
||||
instead of pretending they are equal dashboard cards. */
|
||||
@media(min-width:992px) {
|
||||
.cc-page .cc-panel:not(#cc-relations):not(#cc-metadata) { width:100%; display:block; margin-right:0; }
|
||||
}
|
||||
@media(max-width:700px) {
|
||||
.cc-page .card-header-custom { padding:16px 18px; }
|
||||
.cc-page .card-custom > .card-body { padding:18px 14px 28px !important; }
|
||||
.cc-page #cc-types > .cc-message-type { margin-left:0; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container py-4">
|
||||
<div class="container py-4 cc-page">
|
||||
<div class="cc-pagebar"><a href="/sag"><i class="bi bi-arrow-left me-2"></i>Alle sager</a></div>
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
<div class="col-12">
|
||||
<!-- Main Card -->
|
||||
<div class="card card-custom">
|
||||
<div class="card-header-custom">
|
||||
<h2 class="mb-0 fs-4 fw-bold"><i class="bi bi-plus-circle me-2"></i>Opret Ny Sag</h2>
|
||||
<p class="mb-0 opacity-75 small mt-1">Udfyld formularen for at oprette en ny sag i systemet.</p>
|
||||
<div><span class="cc-eyebrow">ET GODT STED AT STARTE</span><h1>Opret en ny sag</h1><p class="mb-0">Saml opgaven, vælg de rette personer, og tilføj det, du har brug for.</p></div>
|
||||
<span class="cc-hero-icon" aria-hidden="true"><i class="bi bi-folder-plus"></i></span>
|
||||
</div>
|
||||
|
||||
<div class="card-body p-4">
|
||||
@ -162,10 +303,14 @@
|
||||
</div>
|
||||
|
||||
<form id="createForm" novalidate>
|
||||
<div class="mb-4 p-3 rounded-3 border bg-light">
|
||||
<div class="mb-4 cc-type-section">
|
||||
<label for="type" class="form-label mb-2">Hvilken type sag vil du oprette?</label>
|
||||
<select class="form-select form-select-lg" id="type" required></select>
|
||||
<div class="form-text" id="caseTypeHelp">Vælg sagstype for at vise de relevante felter.</div>
|
||||
<button type="button" id="caseMessageTypeButton" class="btn btn-outline-primary cc-message-type cc-message-fallback" onclick="window.caseCreateUI?.openMessage?.() || window.openInternalMessage?.()">
|
||||
<span class="cc-type-name"><i class="bi bi-chat-square-text"></i>Besked til kollega</span>
|
||||
<small class="cc-type-hint">Kort besked eller telefonbesked</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Section: Relations -->
|
||||
@ -199,6 +344,7 @@
|
||||
<input type="hidden" id="customer_id" name="customer_id">
|
||||
</div>
|
||||
</div>
|
||||
<div id="internetConnectionPrefill" class="alert alert-info d-none mb-4" role="status"></div>
|
||||
|
||||
<hr class="my-4 opacity-25">
|
||||
|
||||
@ -386,7 +532,7 @@
|
||||
let customerContactsLoadToken = 0;
|
||||
let successAlertTimeout;
|
||||
let orderLineCounter = 0;
|
||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null };
|
||||
let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null, internetConnectionId: null };
|
||||
let topAlertLoadToken = 0;
|
||||
|
||||
function escapeTopAlertHtml(value) {
|
||||
@ -475,6 +621,13 @@
|
||||
}, duration);
|
||||
}
|
||||
|
||||
function showCreateError(message) {
|
||||
const errorDiv = document.getElementById('error');
|
||||
errorDiv.classList.remove('d-none');
|
||||
document.getElementById('error-text').textContent = String(message || 'Ukendt fejl');
|
||||
errorDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// --- Character Counter ---
|
||||
const beskrInput = document.getElementById('beskrivelse');
|
||||
if (beskrInput) {
|
||||
@ -494,7 +647,7 @@
|
||||
|
||||
if (!source) {
|
||||
descriptionInput?.focus();
|
||||
alert('Skriv en beskrivelse først.');
|
||||
showCreateError('Skriv en beskrivelse først.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -529,7 +682,7 @@
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).show();
|
||||
} catch (error) {
|
||||
console.error('Case create rewrite failed:', error);
|
||||
alert(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
|
||||
showCreateError(`Kunne ikke renskrive beskrivelsen: ${error.message || 'Ukendt fejl'}`);
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
@ -545,10 +698,13 @@
|
||||
const titleInput = document.getElementById('titel');
|
||||
const descriptionInput = document.getElementById('beskrivelse');
|
||||
|
||||
if (suggestedTitle) titleInput.value = suggestedTitle;
|
||||
if (suggestedTitle) {
|
||||
titleInput.value = suggestedTitle;
|
||||
titleInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
if (suggestedDescription) {
|
||||
descriptionInput.value = suggestedDescription;
|
||||
descriptionInput.dispatchEvent(new Event('input'));
|
||||
descriptionInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('caseCreateRewriteModal')).hide();
|
||||
});
|
||||
@ -572,13 +728,20 @@
|
||||
const contactInput = document.getElementById('contactSearch');
|
||||
if (contactInput) {
|
||||
contactInput.addEventListener('input', (e) => handleSearch(e, 'contact'));
|
||||
contactInput.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
clearTimeout(contactSearchTimeout);
|
||||
document.getElementById('contactResults').classList.add('d-none');
|
||||
contactInput.blur();
|
||||
}
|
||||
});
|
||||
contactInput.addEventListener('focus', () => {
|
||||
if (selectedCustomer) {
|
||||
if (selectedCustomer && contactInput.value.trim().length >= 2) {
|
||||
renderCustomerContactResults(contactInput.value);
|
||||
}
|
||||
});
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.search-position-relative')) {
|
||||
if (!contactInput.closest('.search-position-relative').contains(e.target)) {
|
||||
const cr = document.getElementById('contactResults');
|
||||
if(cr) cr.classList.add('d-none');
|
||||
}
|
||||
@ -597,6 +760,12 @@
|
||||
|
||||
clearTimeout(timeoutVar);
|
||||
|
||||
if (query.length < 2) {
|
||||
resultsDiv.classList.add('d-none');
|
||||
resultsDiv.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === 'contact' && selectedCustomer) {
|
||||
renderCustomerContactResults(query);
|
||||
return;
|
||||
@ -608,6 +777,7 @@
|
||||
}
|
||||
|
||||
const timeout = setTimeout(async () => {
|
||||
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
|
||||
try {
|
||||
// Show loading state
|
||||
resultsDiv.classList.remove('d-none');
|
||||
@ -624,6 +794,7 @@
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (event.target.value.trim() !== query || document.activeElement !== event.target) return;
|
||||
if (!Array.isArray(data)) {
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Fejl ved søgning</div>';
|
||||
return;
|
||||
@ -686,6 +857,10 @@
|
||||
if (!resultsDiv || !selectedCustomer) return;
|
||||
|
||||
const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK');
|
||||
if (normalizedQuery.length < 2 || document.activeElement !== document.getElementById('contactSearch')) {
|
||||
resultsDiv.classList.add('d-none');
|
||||
return;
|
||||
}
|
||||
const contacts = selectedCustomerContacts.filter((contact) => {
|
||||
if (!normalizedQuery) return true;
|
||||
return [
|
||||
@ -731,9 +906,9 @@
|
||||
|
||||
selectedCustomerContacts = [];
|
||||
contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`;
|
||||
context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`;
|
||||
context.textContent = `Søg blandt kontakter hos ${selectedCustomer.name} (mindst 2 tegn).`;
|
||||
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.remove('d-none');
|
||||
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' });
|
||||
@ -747,7 +922,7 @@
|
||||
if (loadToken !== customerContactsLoadToken) return;
|
||||
console.error('Failed to load customer contacts:', error);
|
||||
resultsDiv.innerHTML = '<div class="p-3 text-danger small">Firmaets kontakter kunne ikke hentes.</div>';
|
||||
resultsDiv.classList.remove('d-none');
|
||||
resultsDiv.classList.toggle('d-none', document.activeElement !== contactInput || contactInput.value.trim().length < 2);
|
||||
}
|
||||
}
|
||||
|
||||
@ -827,6 +1002,7 @@
|
||||
}
|
||||
|
||||
loadHardwareForContacts();
|
||||
window.caseCreateUI?.contactsChanged();
|
||||
}
|
||||
|
||||
function readTelefoniPrefill() {
|
||||
@ -836,6 +1012,7 @@
|
||||
const callIdRaw = params.get('telefoni_opkald_id');
|
||||
const customerIdRaw = params.get('customer_id');
|
||||
const descriptionRaw = params.get('description');
|
||||
const internetConnectionIdRaw = params.get('internet_connection_id');
|
||||
|
||||
const contactId = contactIdRaw ? parseInt(contactIdRaw) : null;
|
||||
const customerId = customerIdRaw ? parseInt(customerIdRaw) : null;
|
||||
@ -844,6 +1021,8 @@
|
||||
telefoniPrefill.title = titleRaw ? String(titleRaw) : null;
|
||||
telefoniPrefill.callId = callIdRaw ? String(callIdRaw) : null;
|
||||
telefoniPrefill.description = descriptionRaw ? String(descriptionRaw) : null;
|
||||
const internetConnectionId = internetConnectionIdRaw ? parseInt(internetConnectionIdRaw) : null;
|
||||
telefoniPrefill.internetConnectionId = Number.isFinite(internetConnectionId) ? internetConnectionId : null;
|
||||
}
|
||||
|
||||
async function applyTelefoniPrefill() {
|
||||
@ -891,6 +1070,23 @@
|
||||
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) {
|
||||
@ -898,6 +1094,7 @@
|
||||
delete selectedContactsCompanies[id];
|
||||
renderSelections();
|
||||
loadHardwareForContacts();
|
||||
window.caseCreateUI?.contactsChanged();
|
||||
}
|
||||
|
||||
function renderSelections() {
|
||||
@ -999,7 +1196,7 @@
|
||||
if (!anydeskId) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(anydeskId);
|
||||
alert('AnyDesk ID kopieret');
|
||||
showSuccessAlert('AnyDesk ID kopieret');
|
||||
} catch (err) {
|
||||
console.error('Copy failed', err);
|
||||
}
|
||||
@ -1012,13 +1209,13 @@
|
||||
const anydeskLink = anydeskId ? `anydesk://${anydeskId}` : null;
|
||||
|
||||
if (!name) {
|
||||
alert('Navn er påkrævet');
|
||||
showCreateError('Navn er påkrævet');
|
||||
return;
|
||||
}
|
||||
|
||||
const customerId = selectedCustomer?.id || getSingleContactCompanyId();
|
||||
if (!customerId) {
|
||||
alert('Vælg et firma før du opretter hardware');
|
||||
showCreateError('Vælg et firma før du opretter hardware');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1044,7 +1241,7 @@
|
||||
document.getElementById('hardwareAnyDeskIdInput').value = '';
|
||||
await loadHardwareForContacts();
|
||||
} catch (err) {
|
||||
alert('Fejl: ' + err.message);
|
||||
showCreateError('Fejl: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1139,14 +1336,13 @@
|
||||
|
||||
function updateCaseTypeSections() {
|
||||
const type = document.getElementById('type')?.value || 'ticket';
|
||||
document.getElementById('hardwareSection')?.classList.toggle('d-none', type !== 'ticket');
|
||||
document.getElementById('pipelineSection')?.classList.toggle('d-none', type !== 'pipeline');
|
||||
document.getElementById('orderSection')?.classList.toggle('d-none', type !== 'ordre');
|
||||
['hardwareSection', 'pipelineSection', 'orderSection'].forEach(id => document.getElementById(id)?.classList.remove('d-none'));
|
||||
window.caseCreateUI?.typeChanged(type);
|
||||
const help = document.getElementById('caseTypeHelp');
|
||||
if (help) help.textContent = type === 'ticket' ? 'Hardware og AnyDesk vises for tickets.'
|
||||
if (help) help.textContent = type === 'ticket' ? 'Tilføj eventuelt hardware og AnyDesk til din ticket. Alle ekstra funktioner er tilgængelige.'
|
||||
: type === 'pipeline' ? 'Udfyld pipelineoplysninger for muligheden.'
|
||||
: type === 'ordre' ? 'Tilføj indkøbs- og salgslinjer til ordren.'
|
||||
: 'Denne sagstype bruger kun de fælles sagsfelter.';
|
||||
: 'Tilføj hardware, pipeline, tags og køb/salg efter behov.';
|
||||
}
|
||||
|
||||
function renderOrderLinesEmptyState() {
|
||||
@ -1221,7 +1417,7 @@
|
||||
const setting = typesRes.ok ? await typesRes.json() : { value: '[]' };
|
||||
const profile = profileRes.ok ? await profileRes.json() : {};
|
||||
const configured = JSON.parse(setting.value || '[]');
|
||||
const types = Array.isArray(configured) ? configured.map(type => String(type).toLowerCase()) : [];
|
||||
const types = Array.isArray(configured) && configured.length ? configured.map(type => String(type).trim().toLowerCase()).filter(Boolean) : Object.keys(caseTypeLabels);
|
||||
if (!types.includes('pipeline')) types.splice(1, 0, 'pipeline');
|
||||
if (!types.includes('abonnement')) types.push('abonnement');
|
||||
const finalTypes = types.length ? [...new Set(types)] : Object.keys(caseTypeLabels);
|
||||
@ -1251,18 +1447,18 @@
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initializeSearch();
|
||||
loadCaseTypesSelect();
|
||||
loadPipelineStages();
|
||||
selectCurrentUserAsResponsible();
|
||||
await Promise.all([loadCaseTypesSelect(), loadPipelineStages(), selectCurrentUserAsResponsible()]);
|
||||
document.getElementById('type')?.addEventListener('change', updateCaseTypeSections);
|
||||
applyTelefoniPrefill();
|
||||
await applyTelefoniPrefill();
|
||||
await window.caseCreateUI?.init();
|
||||
});
|
||||
|
||||
// --- Form Submission ---
|
||||
document.getElementById('createForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
if (window.caseCreateUI && !window.caseCreateUI.validate()) return;
|
||||
|
||||
// UI Reset
|
||||
const errorDiv = document.getElementById('error');
|
||||
@ -1335,10 +1531,15 @@
|
||||
customer_id: selectedCustomer ? selectedCustomer.id : null,
|
||||
ansvarlig_bruger_id: document.getElementById('ansvarlig_bruger_id').value ? parseInt(document.getElementById('ansvarlig_bruger_id').value) : null,
|
||||
assigned_group_id: document.getElementById('assigned_group_id').value ? parseInt(document.getElementById('assigned_group_id').value) : null,
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
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 (data.type === 'pipeline') {
|
||||
if (['pipeline_stage_id', 'pipeline_amount', 'pipeline_probability', 'pipeline_description'].some(id => document.getElementById(id).value !== '')) {
|
||||
data.pipeline = {
|
||||
stage_id: document.getElementById('pipeline_stage_id').value || null,
|
||||
amount: document.getElementById('pipeline_amount').value || null,
|
||||
@ -1346,9 +1547,10 @@
|
||||
description: document.getElementById('pipeline_description').value || null
|
||||
};
|
||||
}
|
||||
if (data.type === 'ordre') {
|
||||
if (document.querySelector('#orderLines .order-line')) {
|
||||
data.order_items = collectOrderItems();
|
||||
}
|
||||
Object.assign(data, window.caseCreateUI?.relations() || {});
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/sag', {
|
||||
@ -1360,64 +1562,11 @@
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
window.caseCreateUI?.created(result);
|
||||
|
||||
successDiv.classList.remove('d-none');
|
||||
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('success-text').textContent = "Sag oprettet";
|
||||
window.location.href = `/sag/${result.id}/v3`;
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
let errMsg = "Kunne ikke oprette sag";
|
||||
@ -1437,4 +1586,5 @@
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/case-create.js?v=5"></script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -299,8 +299,6 @@
|
||||
deadline: document.getElementById('deadline').value || null
|
||||
};
|
||||
|
||||
console.log('Updating case with data:', data);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
||||
method: 'PATCH',
|
||||
@ -310,17 +308,11 @@
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
console.log('Response status:', response.status);
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Updated case:', result);
|
||||
|
||||
document.getElementById('success').textContent = `✅ Sag opdateret! Omdirigerer...`;
|
||||
await response.json();
|
||||
document.getElementById('success').textContent = `✅ Sag opdateret`;
|
||||
document.getElementById('success').style.display = 'block';
|
||||
setTimeout(() => {
|
||||
window.location.href = `/sag/${caseId}/v3`;
|
||||
}, 1000);
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
console.error('Error response:', errorText);
|
||||
|
||||
@ -5,17 +5,54 @@
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.search-bar {
|
||||
position: relative;
|
||||
margin-bottom: 0;
|
||||
flex: 1 1 380px;
|
||||
flex: 1 1 460px;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.search-bar input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0,0,0,0.1);
|
||||
padding: 0.45rem 0.85rem;
|
||||
min-height: 46px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(15,76,117,.15);
|
||||
padding: 0.6rem 4.5rem 0.6rem 2.7rem;
|
||||
background: var(--bg-card);
|
||||
box-shadow: 0 4px 16px rgba(15, 76, 117, .06);
|
||||
transition: border-color .16s ease, box-shadow .16s ease;
|
||||
}
|
||||
|
||||
.search-bar input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(15,76,117,.12), 0 5px 18px rgba(15,76,117,.08);
|
||||
}
|
||||
|
||||
.search-bar .search-icon {
|
||||
position: absolute; left: .95rem; top: 50%; transform: translateY(-50%);
|
||||
color: var(--accent); pointer-events: none;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
position: absolute; right: .45rem; top: 50%; transform: translateY(-50%);
|
||||
width: 34px; height: 34px; border: 0; border-radius: 9px;
|
||||
background: transparent; color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.search-clear-btn:hover { background: rgba(15,76,117,.09); color: var(--accent); }
|
||||
|
||||
.search-shortcut {
|
||||
position:absolute; right:2.85rem; top:50%; transform:translateY(-50%);
|
||||
padding:.1rem .35rem; border:1px solid rgba(0,0,0,.12); border-radius:5px;
|
||||
color:var(--text-secondary); font-size:.68rem; background:rgba(255,255,255,.7);
|
||||
}
|
||||
|
||||
.sag-toolbar-card { padding: .85rem; margin-bottom: .9rem; background: var(--bg-card); border:1px solid rgba(15,76,117,.09); border-radius:16px; box-shadow:0 5px 20px rgba(15,76,117,.055); }
|
||||
.sag-quick-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.48rem; padding-top:.7rem; margin-top:.7rem; border-top:1px solid rgba(15,76,117,.08); }
|
||||
.sag-quick-filter { display:inline-flex; align-items:center; gap:.4rem; padding:.43rem .72rem; border:1px solid rgba(15,76,117,.14); border-radius:999px; background:rgba(15,76,117,.035); color:var(--text-primary); font-size:.8rem; font-weight:650; transition:all .16s ease; }
|
||||
.sag-quick-filter:hover { border-color:rgba(15,76,117,.32); background:rgba(15,76,117,.09); color:var(--accent); transform:translateY(-1px); }
|
||||
.sag-quick-filter.active { color:#fff; background:var(--accent); border-color:var(--accent); box-shadow:0 4px 12px rgba(15,76,117,.2); }
|
||||
.sag-quick-filter[data-quick-filter="overdue"].active { background:#c92a2a; border-color:#c92a2a; }
|
||||
.sag-advanced-filters { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:1rem; padding:.6rem .7rem; border-radius:12px; background:rgba(15,76,117,.035); border:1px solid rgba(15,76,117,.07); }
|
||||
|
||||
.top-controls-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@ -90,6 +127,21 @@
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.sag-table tbody tr.sag-deadline-overdue {
|
||||
background: rgba(201, 42, 42, 0.045);
|
||||
box-shadow: inset 5px 0 0 #c92a2a, inset 0 0 18px rgba(201, 42, 42, 0.11);
|
||||
}
|
||||
|
||||
.sag-table tbody tr.sag-deadline-overdue:hover {
|
||||
background: rgba(201, 42, 42, 0.09);
|
||||
box-shadow: inset 5px 0 0 #b42318, inset 0 0 22px rgba(201, 42, 42, 0.16);
|
||||
}
|
||||
|
||||
.sag-table tbody tr.sag-deadline-overdue td:last-child {
|
||||
color: #b42318 !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sag-table tbody td {
|
||||
padding: 0.5rem 0.75rem;
|
||||
vertical-align: top;
|
||||
@ -117,10 +169,75 @@
|
||||
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 {
|
||||
font-weight: 700;
|
||||
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-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.2;
|
||||
text-decoration: none;
|
||||
transition: background-color .16s ease, border-color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.sag-id:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(15, 76, 117, 0.14);
|
||||
border-color: rgba(15, 76, 117, 0.3);
|
||||
transform: translateY(-1px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sag-entity-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.38rem;
|
||||
max-width: 100%;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 7px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-decoration: none;
|
||||
transition: color .16s ease, background-color .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.sag-entity-link i {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.sag-entity-link span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sag-entity-link:hover {
|
||||
color: var(--accent);
|
||||
background: rgba(15, 76, 117, 0.09);
|
||||
text-decoration: none;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.sag-entity-link:hover i {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.sag-id:focus-visible,
|
||||
.sag-entity-link:focus-visible {
|
||||
outline: 3px solid rgba(15, 76, 117, 0.22);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.sag-unread-badge {
|
||||
@ -288,6 +405,64 @@
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.sag-inline-select {
|
||||
min-height: 34px;
|
||||
border: 1px solid rgba(15, 76, 117, 0.16);
|
||||
border-radius: 9px;
|
||||
background-color: rgba(15, 76, 117, 0.045);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
padding: 0.34rem 2rem 0.34rem 0.62rem;
|
||||
box-shadow: none;
|
||||
cursor: pointer;
|
||||
transition: border-color .16s ease, background-color .16s ease, box-shadow .16s ease, transform .16s ease;
|
||||
}
|
||||
|
||||
.sag-inline-select:hover:not(:disabled) {
|
||||
border-color: rgba(15, 76, 117, 0.38);
|
||||
background-color: rgba(15, 76, 117, 0.09);
|
||||
}
|
||||
|
||||
.sag-inline-select:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(15, 76, 117, 0.14);
|
||||
}
|
||||
|
||||
.sag-inline-select:disabled {
|
||||
opacity: .62;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.sag-owner-select {
|
||||
background-color: rgba(99, 102, 241, 0.055);
|
||||
border-color: rgba(99, 102, 241, 0.17);
|
||||
}
|
||||
|
||||
.sag-status-select.status-tone-open {
|
||||
color: #8a5a00;
|
||||
background-color: #fff8df;
|
||||
border-color: #efd991;
|
||||
}
|
||||
|
||||
.sag-status-select.status-tone-progress {
|
||||
color: #174a89;
|
||||
background-color: #eaf3ff;
|
||||
border-color: #b8d5fa;
|
||||
}
|
||||
|
||||
.sag-status-select.status-tone-waiting {
|
||||
color: #8a4600;
|
||||
background-color: #fff1df;
|
||||
border-color: #f0c58e;
|
||||
}
|
||||
|
||||
.sag-status-select.status-tone-done {
|
||||
color: #087044;
|
||||
background-color: #e5f8ef;
|
||||
border-color: #acdcbc;
|
||||
}
|
||||
|
||||
.filter-pills {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@ -649,7 +824,8 @@
|
||||
<div class="container-fluid" style="max-width: none; padding-top: 0.65rem;">
|
||||
<div id="sagTopAlerts" class="sag-top-alerts d-none"></div>
|
||||
|
||||
<div class="top-controls-row">
|
||||
<div class="sag-toolbar-card">
|
||||
<div class="top-controls-row mb-0">
|
||||
<h1 style="margin: 0; color: var(--accent); flex-shrink: 0;">
|
||||
<i class="bi bi-list-check"></i>
|
||||
</h1>
|
||||
@ -666,11 +842,14 @@
|
||||
</div>
|
||||
|
||||
<div class="search-bar">
|
||||
<i class="bi bi-search search-icon"></i>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="searchInput"
|
||||
placeholder="🔍 Søg efter sag ID, titel, beskrivelse..."
|
||||
placeholder="Søg på sagsnr., firma, kontakt, titel eller ansvarlig..."
|
||||
autocomplete="off">
|
||||
<span class="search-shortcut">/</span>
|
||||
<button class="search-clear-btn d-none" id="clearSearchBtn" type="button" title="Ryd søgning" aria-label="Ryd søgning"><i class="bi bi-x-lg"></i></button>
|
||||
</div>
|
||||
|
||||
<div class="top-controls-actions">
|
||||
@ -682,13 +861,17 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 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="type-filter-wrap">
|
||||
<div class="dropdown type-filter-dropdown mb-1">
|
||||
<button class="btn dropdown-toggle" type="button" id="typeFilterDropdownBtn" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
|
||||
@ -746,6 +929,19 @@
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{{ toggle_include_deferred_url }}">
|
||||
{% if include_deferred %}Skjul udsatte{% else %}Vis udsatte{% endif %}
|
||||
</a>
|
||||
<div class="dropdown ms-auto">
|
||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
|
||||
<i class="bi bi-layout-three-columns me-1"></i>Kolonner
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2 shadow">
|
||||
<div class="small text-muted px-2 pb-2">Træk for at ændre rækkefølge</div>
|
||||
<div id="sagColumnList" class="sag-column-list"></div>
|
||||
<div class="d-flex gap-2 border-top pt-2 mt-2">
|
||||
<button id="resetSagColumnsBtn" class="btn btn-sm btn-outline-secondary" type="button">Nulstil</button>
|
||||
<button id="saveSagColumnsBtn" class="btn btn-sm btn-primary ms-auto" type="button">Gem for mig</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@ -780,14 +976,15 @@
|
||||
data-status="{{ sag.status }}"
|
||||
data-type="{{ sag.template_key or sag.type or 'ticket' }}"
|
||||
data-assignee-id="{{ sag.ansvarlig_bruger_id if sag.ansvarlig_bruger_id else '' }}"
|
||||
data-group-id="{{ sag.assigned_group_id if sag.assigned_group_id else '' }}">
|
||||
data-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();">
|
||||
{% if has_relations %}
|
||||
<span class="tree-toggle" onclick="toggleTreeNode(event, {{ sag.id }})">+</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ sag.id }}/v3'">#{{ sag.id }}</span>
|
||||
<a class="sag-id" href="/sag/{{ sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ sag.id }}</a>
|
||||
{% if (sag.unread_email_count or 0) > 0 %}
|
||||
{% set unread_level = sag.unread_email_level or 'fresh' %}
|
||||
<span class="sag-unread-badge sag-unread-{{ unread_level }}" title="{{ sag.unread_email_count }} ulæste e-mails">
|
||||
@ -796,10 +993,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-company" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.customer_name if sag.customer_name else '-' }}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="col-contact" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ sag.kontakt_navn if sag.kontakt_navn and sag.kontakt_navn.strip() else '-' }}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="col-desc" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
<div class="sag-titel" {% if sag.beskrivelse %}title="{{ sag.beskrivelse }}"{% endif %}>{{ sag.titel }}</div>
|
||||
@ -812,13 +1009,15 @@
|
||||
</td>
|
||||
<td onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
{% set status_raw = sag.status if sag.status else 'åben' %}
|
||||
{% set status_class = status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ status_class }}">{{ status_raw }}</span>
|
||||
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'status', this.value, this)">
|
||||
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ sag.id }}/v3'">
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
|
||||
<option value="">Ikke tildelt</option>
|
||||
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<div class="owner-cell">
|
||||
@ -855,10 +1054,10 @@
|
||||
{% if related_sag and rel.target_id not in seen_targets %}
|
||||
{% set _ = seen_targets.append(rel.target_id) %}
|
||||
{% set all_rel_types = relations_map[sag.id]|selectattr('target_id', 'equalto', rel.target_id)|map(attribute='type')|list %}
|
||||
<tr class="tree-child" data-parent="{{ sag.id }}" data-status="{{ related_sag.status }}" data-type="{{ related_sag.template_key or related_sag.type or 'ticket' }}" data-assignee-id="{{ related_sag.ansvarlig_bruger_id if related_sag.ansvarlig_bruger_id else '' }}" data-group-id="{{ related_sag.assigned_group_id if related_sag.assigned_group_id else '' }}" 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 '' }}" data-deadline="{{ related_sag.deadline.isoformat() if related_sag.deadline else '' }}" style="display: none;">
|
||||
<td class="col-expand"><span class="child-branch">└</span></td>
|
||||
<td>
|
||||
<span class="sag-id" role="button" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">#{{ related_sag.id }}</span>
|
||||
<a class="sag-id" href="/sag/{{ related_sag.id }}/v3"><i class="bi bi-folder2-open"></i>#{{ related_sag.id }}</a>
|
||||
{% if (related_sag.unread_email_count or 0) > 0 %}
|
||||
{% set child_unread_level = related_sag.unread_email_level or 'fresh' %}
|
||||
<span class="sag-unread-badge sag-unread-{{ child_unread_level }}" title="{{ related_sag.unread_email_count }} ulæste e-mails">
|
||||
@ -867,10 +1066,10 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-company" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.customer_name if related_sag.customer_name else '-' }}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="col-contact" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{{ related_sag.kontakt_navn if related_sag.kontakt_navn and related_sag.kontakt_navn.strip() else '-' }}
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td class="col-desc" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
{% for rt in all_rel_types %}
|
||||
@ -886,13 +1085,15 @@
|
||||
</td>
|
||||
<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_class = related_status_raw|lower|replace(' ', '-') %}
|
||||
<span class="status-badge status-{{ related_status_class }}">{{ related_status_raw }}</span>
|
||||
<select class="form-select form-select-sm sag-inline-select sag-status-select" style="min-width:120px" data-previous="{{ related_status_raw }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'status', this.value, this)">
|
||||
{% for status_option in status_options %}<option value="{{ status_option }}" {% if status_option == related_status_raw %}selected{% endif %}>{{ status_option }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-owner" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'">
|
||||
<div class="owner-cell">
|
||||
{{ initials_bubble(related_sag.ansvarlig_navn) }}
|
||||
</div>
|
||||
<select class="form-select form-select-sm sag-inline-select sag-owner-select" style="min-width:150px" data-previous="{{ related_sag.ansvarlig_bruger_id or '' }}" onclick="event.stopPropagation()" onchange="updateCaseListField({{ related_sag.id }}, 'ansvarlig_bruger_id', this.value || null, this)">
|
||||
<option value="">Ikke tildelt</option>
|
||||
{% for user in assignment_users or [] %}<option value="{{ user.user_id }}" {% if related_sag.ansvarlig_bruger_id == user.user_id %}selected{% endif %}>{{ user.display_name }}</option>{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td class="col-group" onclick="window.location.href='/sag/{{ related_sag.id }}/v3'" style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
<div class="owner-cell">
|
||||
@ -938,9 +1139,127 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="closeCaseWithoutTimeModal" tabindex="-1" aria-labelledby="closeCaseWithoutTimeTitle" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
|
||||
<div class="modal-dialog modal-dialog-centered modal-lg">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header bg-danger text-white border-0 py-4">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-exclamation-triangle-fill" style="font-size:3rem;line-height:1"></i>
|
||||
<div>
|
||||
<div class="text-uppercase fw-bold small opacity-75 mb-1">Vigtig advarsel</div>
|
||||
<h2 class="modal-title fw-bold mb-0" id="closeCaseWithoutTimeTitle">Der er ikke registreret tid på sagen</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body p-4 p-md-5 text-center">
|
||||
<p class="fs-4 fw-semibold mb-3">Du er ved at lukke sag <span id="closeWithoutTimeCaseNumber"></span> uden tidsregistrering.</p>
|
||||
<p class="fs-5 text-muted mb-0">Kontrollér, at det er korrekt. Sagen bliver kun lukket, hvis du aktivt bekræfter nedenfor.</p>
|
||||
</div>
|
||||
<div class="modal-footer border-0 bg-light p-4 justify-content-center gap-2">
|
||||
<button type="button" class="btn btn-lg btn-outline-secondary px-4" data-bs-dismiss="modal">
|
||||
<i class="bi bi-arrow-left me-2"></i>Gå tilbage
|
||||
</button>
|
||||
<button type="button" class="btn btn-lg btn-danger px-4" id="confirmCloseCaseWithoutTimeBtn">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>Ja, luk sagen uden tid
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const topAlertCustomerId = {{ current_customer_id if current_customer_id else 'null' }};
|
||||
|
||||
function applyStatusSelectTone(control) {
|
||||
if (!control) return;
|
||||
const status = String(control.value || '').trim().toLowerCase();
|
||||
control.classList.remove('status-tone-open', 'status-tone-progress', 'status-tone-waiting', 'status-tone-done');
|
||||
let tone = 'status-tone-open';
|
||||
if (['under behandling', 'i gang', 'in progress'].includes(status)) tone = 'status-tone-progress';
|
||||
else if (['afventer', 'on hold'].includes(status)) tone = 'status-tone-waiting';
|
||||
else if (['løst', 'lukket', 'afsluttet', 'resolved', 'closed', 'done'].includes(status)) tone = 'status-tone-done';
|
||||
control.classList.add(tone);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sag-status-select').forEach(applyStatusSelectTone);
|
||||
|
||||
function confirmCloseCaseWithoutTime(caseId, message) {
|
||||
return new Promise(resolve => {
|
||||
const modalElement = document.getElementById('closeCaseWithoutTimeModal');
|
||||
const confirmButton = document.getElementById('confirmCloseCaseWithoutTimeBtn');
|
||||
const caseNumber = document.getElementById('closeWithoutTimeCaseNumber');
|
||||
if (!modalElement || !confirmButton || typeof bootstrap === 'undefined') {
|
||||
resolve(window.confirm(`${message}\n\nVil du lukke sagen uden tidsregistrering?`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (caseNumber) caseNumber.textContent = `#${caseId}`;
|
||||
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||
let confirmed = false;
|
||||
const handleConfirm = () => {
|
||||
confirmed = true;
|
||||
modal.hide();
|
||||
};
|
||||
const handleHidden = () => {
|
||||
confirmButton.removeEventListener('click', handleConfirm);
|
||||
modalElement.removeEventListener('hidden.bs.modal', handleHidden);
|
||||
resolve(confirmed);
|
||||
};
|
||||
confirmButton.addEventListener('click', handleConfirm, { once: true });
|
||||
modalElement.addEventListener('hidden.bs.modal', handleHidden, { once: true });
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
|
||||
async function updateCaseListField(caseId, field, value, control) {
|
||||
const previous = control?.dataset?.previous ?? '';
|
||||
if (control) control.disabled = true;
|
||||
try {
|
||||
const saveField = async (confirmedWithoutTime = false) => {
|
||||
const body = { [field]: value === '' ? null : value };
|
||||
if (confirmedWithoutTime) body.confirm_close_without_time = true;
|
||||
const response = await fetch(`/api/v1/sag/${caseId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
return { response, payload };
|
||||
};
|
||||
|
||||
let { response, payload } = await saveField();
|
||||
const detail = payload?.detail;
|
||||
if (response.status === 409 && detail?.code === 'close_without_time_confirmation_required') {
|
||||
const confirmed = await confirmCloseCaseWithoutTime(caseId, detail.message);
|
||||
if (!confirmed) {
|
||||
if (control) control.value = previous;
|
||||
if (control && field === 'status') applyStatusSelectTone(control);
|
||||
return;
|
||||
}
|
||||
({ response, payload } = await saveField(true));
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message = typeof payload.detail === 'string' ? payload.detail : payload.detail?.message;
|
||||
throw new Error(message || 'Ændringen kunne ikke gemmes');
|
||||
}
|
||||
if (control) control.dataset.previous = String(value ?? '');
|
||||
if (control && field === 'status') applyStatusSelectTone(control);
|
||||
const row = control?.closest('tr');
|
||||
if (row && field === 'status') row.dataset.status = String(value || '');
|
||||
if (row && field === 'status') updateOverdueDeadlineMarker(row);
|
||||
if (row && field === 'ansvarlig_bruger_id') row.dataset.assigneeId = String(value || '');
|
||||
if (typeof applyFilters === 'function') applyFilters();
|
||||
} catch (error) {
|
||||
if (control) control.value = previous;
|
||||
if (control && field === 'status') applyStatusSelectTone(control);
|
||||
if (typeof showNotification === 'function') showNotification(error.message, 'error');
|
||||
else window.alert(error.message);
|
||||
} finally {
|
||||
if (control) control.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeTopAlertHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
@ -1026,11 +1345,35 @@
|
||||
const allRows = document.querySelectorAll('.tree-row');
|
||||
let currentSearch = '';
|
||||
let currentFilter = 'all';
|
||||
let currentQuickFilter = {{ (current_quick_filter or 'all')|tojson }};
|
||||
let currentEmployeeId = '';
|
||||
let currentEmployeeGroupIds = new Set();
|
||||
let currentTypes = new Set();
|
||||
let currentAssignees = new Set();
|
||||
let currentGroups = new Set();
|
||||
const closedStatuses = new Set({{ (closed_statuses or ['lukket', 'løst', 'afsluttet', 'closed', 'resolved', 'done'])|tojson }});
|
||||
|
||||
function isDeadlinePast(deadlineValue) {
|
||||
if (!deadlineValue) return false;
|
||||
const deadline = new Date(deadlineValue);
|
||||
if (Number.isNaN(deadline.getTime())) return false;
|
||||
deadline.setHours(0, 0, 0, 0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return deadline.getTime() < today.getTime();
|
||||
}
|
||||
|
||||
function updateOverdueDeadlineMarker(row) {
|
||||
if (!row) return;
|
||||
const status = String(row.dataset.status || '').trim().toLowerCase();
|
||||
const deadlineValue = String(row.dataset.deadline || '').trim();
|
||||
const isOverdue = isDeadlinePast(deadlineValue)
|
||||
&& !closedStatuses.has(status);
|
||||
row.classList.toggle('sag-deadline-overdue', isOverdue);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.sag-table tbody tr[data-deadline]').forEach(updateOverdueDeadlineMarker);
|
||||
|
||||
const assigneeFilter = document.getElementById('assigneeFilter');
|
||||
const groupFilter = document.getElementById('groupFilter');
|
||||
const assigneeFilterList = document.getElementById('assigneeFilterList');
|
||||
@ -1043,6 +1386,20 @@
|
||||
function applyFilters() {
|
||||
const search = currentSearch;
|
||||
|
||||
const matchesQuickFilter = (row, isClosed) => {
|
||||
const assigneeId = String(row.dataset.assigneeId || '').trim();
|
||||
const groupId = String(row.dataset.groupId || '').trim();
|
||||
if (currentQuickFilter === 'mine-open') return !isClosed && !!currentEmployeeId && assigneeId === currentEmployeeId;
|
||||
if (currentQuickFilter === 'my-groups') return !isClosed && currentEmployeeGroupIds.has(groupId);
|
||||
if (currentQuickFilter === 'unassigned') return !isClosed && !assigneeId;
|
||||
if (currentQuickFilter === 'closed') return isClosed;
|
||||
if (currentQuickFilter === 'overdue') {
|
||||
const deadline = String(row.dataset.deadline || '');
|
||||
return !isClosed && isDeadlinePast(deadline);
|
||||
}
|
||||
return !isClosed;
|
||||
};
|
||||
|
||||
allRows.forEach(row => {
|
||||
const text = row.textContent.toLowerCase();
|
||||
const status = String(row.dataset.status || '').toLowerCase();
|
||||
@ -1051,7 +1408,7 @@
|
||||
const groupRaw = String(row.dataset.groupId || '').trim();
|
||||
const assigneeId = assigneeRaw || '__UNASSIGNED__';
|
||||
const groupId = groupRaw;
|
||||
const matchesSearch = text.includes(search);
|
||||
const matchesSearch = search.split(/\s+/).filter(Boolean).every(term => text.includes(term));
|
||||
const isClosed = closedStatuses.has(status);
|
||||
const matchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !isClosed)
|
||||
@ -1060,7 +1417,7 @@
|
||||
const matchesType = currentTypes.size === 0 || currentTypes.has(type);
|
||||
const matchesAssignee = currentAssignees.size === 0 || currentAssignees.has(assigneeId);
|
||||
const matchesGroup = currentGroups.size === 0 || currentGroups.has(groupId);
|
||||
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup;
|
||||
const visible = matchesSearch && matchesFilter && matchesType && matchesAssignee && matchesGroup && matchesQuickFilter(row, isClosed);
|
||||
|
||||
row.style.display = visible ? '' : 'none';
|
||||
|
||||
@ -1075,7 +1432,7 @@
|
||||
const childGroupRaw = String(child.dataset.groupId || '').trim();
|
||||
const childAssigneeId = childAssigneeRaw || '__UNASSIGNED__';
|
||||
const childGroupId = childGroupRaw;
|
||||
const childMatchesSearch = childText.includes(search);
|
||||
const childMatchesSearch = search.split(/\s+/).filter(Boolean).every(term => childText.includes(term));
|
||||
const childIsClosed = closedStatuses.has(childStatus);
|
||||
const childMatchesFilter = currentFilter === 'all'
|
||||
|| (currentFilter === 'åben' && !childIsClosed)
|
||||
@ -1084,7 +1441,7 @@
|
||||
const childMatchesType = currentTypes.size === 0 || currentTypes.has(childType);
|
||||
const childMatchesAssignee = currentAssignees.size === 0 || currentAssignees.has(childAssigneeId);
|
||||
const childMatchesGroup = currentGroups.size === 0 || currentGroups.has(childGroupId);
|
||||
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup;
|
||||
const childVisible = visible && row.classList.contains('expanded') && childMatchesSearch && childMatchesFilter && childMatchesType && childMatchesAssignee && childMatchesGroup && matchesQuickFilter(child, childIsClosed);
|
||||
child.style.display = childVisible ? '' : 'none';
|
||||
});
|
||||
}
|
||||
@ -1195,11 +1552,55 @@
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', function(e) {
|
||||
currentSearch = e.target.value.toLowerCase();
|
||||
currentSearch = e.target.value.trim().toLowerCase();
|
||||
document.getElementById('clearSearchBtn')?.classList.toggle('d-none', !currentSearch);
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('clearSearchBtn')?.addEventListener('click', () => {
|
||||
searchInput.value = '';
|
||||
currentSearch = '';
|
||||
document.getElementById('clearSearchBtn')?.classList.add('d-none');
|
||||
searchInput.focus();
|
||||
applyFilters();
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) {
|
||||
event.preventDefault();
|
||||
searchInput?.focus();
|
||||
}
|
||||
if (event.key === 'Escape' && document.activeElement === searchInput && searchInput.value) {
|
||||
document.getElementById('clearSearchBtn')?.click();
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.sag-quick-filter').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const quick = button.dataset.quickFilter || 'all';
|
||||
const url = new URL(window.location.href);
|
||||
if (quick === 'all') url.searchParams.delete('quick');
|
||||
else url.searchParams.set('quick', quick);
|
||||
url.searchParams.delete('status');
|
||||
window.location.assign(url.toString());
|
||||
});
|
||||
});
|
||||
|
||||
async function loadQuickFilterContext() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/sag/me/quick-filter-context', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
currentEmployeeId = String(data.user_id || '');
|
||||
currentEmployeeGroupIds = new Set((data.group_ids || []).map(String));
|
||||
applyFilters();
|
||||
} catch (error) {
|
||||
console.error('Kunne ikke hente hurtigfilter-kontekst', error);
|
||||
}
|
||||
}
|
||||
loadQuickFilterContext();
|
||||
|
||||
// Filter functionality
|
||||
const filterPills = document.querySelectorAll('.filter-pill');
|
||||
|
||||
@ -1328,6 +1729,121 @@
|
||||
}
|
||||
}
|
||||
|
||||
const SAG_COLUMN_DEFINITIONS = [
|
||||
['id', 'SagsID'], ['company', 'Virksomhed'], ['contact', 'Kontakt'],
|
||||
['description', 'Beskrivelse'], ['type', 'Type'], ['priority', 'Prioritet'],
|
||||
['status', 'Status'], ['owner', 'Ansvarlig'], ['group', 'Gruppe/Level'],
|
||||
['next_todo', 'Næste todo'], ['created', 'Oprettet'], ['start', 'Arbejdsstart'],
|
||||
['deferred', 'Start senest'], ['deadline', 'Deadline']
|
||||
];
|
||||
const SAG_DEFAULT_COLUMN_ORDER = SAG_COLUMN_DEFINITIONS.map(([key]) => key);
|
||||
let sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
|
||||
let sagHiddenColumns = new Set();
|
||||
|
||||
function normalizeSagColumnOrder(order) {
|
||||
const allowed = new Set(SAG_DEFAULT_COLUMN_ORDER);
|
||||
const normalized = Array.from(new Set((Array.isArray(order) ? order : []).filter(key => allowed.has(key))));
|
||||
SAG_DEFAULT_COLUMN_ORDER.forEach(key => { if (!normalized.includes(key)) normalized.push(key); });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function applySagColumnPreferences() {
|
||||
document.querySelectorAll('.sag-table tr').forEach(row => {
|
||||
const cells = Array.from(row.children).filter(cell => cell.matches('th,td'));
|
||||
if (!cells.length) return;
|
||||
const expandCell = cells.find(cell => cell.dataset.columnKey === 'expand') || cells[0];
|
||||
if (!expandCell.dataset.columnKey) {
|
||||
expandCell.dataset.columnKey = 'expand';
|
||||
cells.slice(1).forEach((cell, index) => {
|
||||
if (SAG_DEFAULT_COLUMN_ORDER[index]) cell.dataset.columnKey = SAG_DEFAULT_COLUMN_ORDER[index];
|
||||
});
|
||||
}
|
||||
const byKey = new Map(Array.from(row.children).filter(cell => cell.dataset.columnKey).map(cell => [cell.dataset.columnKey, cell]));
|
||||
if (byKey.get('expand')) row.appendChild(byKey.get('expand'));
|
||||
sagColumnOrder.forEach(key => {
|
||||
const cell = byKey.get(key);
|
||||
if (!cell) return;
|
||||
cell.style.display = sagHiddenColumns.has(key) ? 'none' : '';
|
||||
row.appendChild(cell);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderSagColumnChooser() {
|
||||
const host = document.getElementById('sagColumnList');
|
||||
if (!host) return;
|
||||
const labels = Object.fromEntries(SAG_COLUMN_DEFINITIONS);
|
||||
host.innerHTML = sagColumnOrder.map(key => `
|
||||
<div class="sag-column-item" draggable="true" data-column-key="${key}">
|
||||
<i class="bi bi-grip-vertical drag-handle"></i>
|
||||
<input class="form-check-input sag-column-visible" type="checkbox" ${sagHiddenColumns.has(key) ? '' : 'checked'}>
|
||||
<span>${labels[key]}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function applySagColumnPayload(data) {
|
||||
sagColumnOrder = normalizeSagColumnOrder(data?.column_order);
|
||||
sagHiddenColumns = new Set((Array.isArray(data?.hidden_columns) ? data.hidden_columns : []).filter(key => SAG_DEFAULT_COLUMN_ORDER.includes(key)));
|
||||
renderSagColumnChooser();
|
||||
applySagColumnPreferences();
|
||||
}
|
||||
|
||||
async function saveSagColumnPreferences() {
|
||||
const button = document.getElementById('saveSagColumnsBtn');
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
const response = await fetch('/api/v1/sag/me/list-preferences', {
|
||||
method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type_filters: getSelectedTypesFromUi(),
|
||||
column_order: sagColumnOrder,
|
||||
hidden_columns: Array.from(sagHiddenColumns)
|
||||
})
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.detail || 'Kolonnerne kunne ikke gemmes');
|
||||
applySagColumnPayload(payload);
|
||||
if (typeof showNotification === 'function') showNotification('Kolonner gemt for din bruger', 'success');
|
||||
} catch (error) {
|
||||
if (typeof showNotification === 'function') showNotification(error.message, 'error');
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const sagColumnList = document.getElementById('sagColumnList');
|
||||
let draggedSagColumn = null;
|
||||
sagColumnList?.addEventListener('change', event => {
|
||||
const item = event.target.closest('.sag-column-item');
|
||||
if (!item || !event.target.matches('.sag-column-visible')) return;
|
||||
if (event.target.checked) sagHiddenColumns.delete(item.dataset.columnKey);
|
||||
else sagHiddenColumns.add(item.dataset.columnKey);
|
||||
applySagColumnPreferences();
|
||||
});
|
||||
sagColumnList?.addEventListener('dragstart', event => {
|
||||
draggedSagColumn = event.target.closest('.sag-column-item')?.dataset.columnKey || null;
|
||||
event.target.closest('.sag-column-item')?.classList.add('dragging');
|
||||
});
|
||||
sagColumnList?.addEventListener('dragend', event => event.target.closest('.sag-column-item')?.classList.remove('dragging'));
|
||||
sagColumnList?.addEventListener('dragover', event => event.preventDefault());
|
||||
sagColumnList?.addEventListener('drop', event => {
|
||||
event.preventDefault();
|
||||
const targetKey = event.target.closest('.sag-column-item')?.dataset.columnKey;
|
||||
if (!draggedSagColumn || !targetKey || draggedSagColumn === targetKey) return;
|
||||
sagColumnOrder = sagColumnOrder.filter(key => key !== draggedSagColumn);
|
||||
sagColumnOrder.splice(sagColumnOrder.indexOf(targetKey), 0, draggedSagColumn);
|
||||
draggedSagColumn = null;
|
||||
renderSagColumnChooser();
|
||||
applySagColumnPreferences();
|
||||
});
|
||||
document.getElementById('resetSagColumnsBtn')?.addEventListener('click', () => {
|
||||
sagColumnOrder = [...SAG_DEFAULT_COLUMN_ORDER];
|
||||
sagHiddenColumns = new Set();
|
||||
renderSagColumnChooser();
|
||||
applySagColumnPreferences();
|
||||
});
|
||||
document.getElementById('saveSagColumnsBtn')?.addEventListener('click', saveSagColumnPreferences);
|
||||
|
||||
async function loadTypeFilterPreferences() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/sag/me/list-preferences', { credentials: 'include' });
|
||||
@ -1335,6 +1851,7 @@
|
||||
const data = await res.json();
|
||||
const fromServer = Array.isArray(data?.type_filters) ? data.type_filters : [];
|
||||
currentTypes = new Set(fromServer.map((v) => String(v || '').trim().toLowerCase()).filter(Boolean));
|
||||
applySagColumnPayload(data);
|
||||
applySelectedTypesToUi();
|
||||
applyFilters();
|
||||
} catch (err) {
|
||||
@ -1351,7 +1868,7 @@
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ type_filters: selected }),
|
||||
body: JSON.stringify({ type_filters: selected, column_order: sagColumnOrder, hidden_columns: Array.from(sagHiddenColumns) }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
if (typeof showNotification === 'function') {
|
||||
|
||||
18
app/modules/sag/templates/knowledge_detail.html
Normal file
18
app/modules/sag/templates/knowledge_detail.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% 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 %}
|
||||
36
app/modules/sag/templates/knowledge_index.html
Normal file
36
app/modules/sag/templates/knowledge_index.html
Normal file
@ -0,0 +1,36 @@
|
||||
{% 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||
15
app/modules/sag/templates/procurement_overview.html
Normal file
15
app/modules/sag/templates/procurement_overview.html
Normal file
@ -0,0 +1,15 @@
|
||||
{% 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 %}
|
||||
19
app/modules/sag/templates/reminder_rules.html
Normal file
19
app/modules/sag/templates/reminder_rules.html
Normal file
@ -0,0 +1,19 @@
|
||||
{% 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||
58
app/modules/sag/templates/solutions_management.html
Normal file
58
app/modules/sag/templates/solutions_management.html
Normal file
@ -0,0 +1,58 @@
|
||||
{% 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=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[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 %}
|
||||
@ -145,7 +145,7 @@
|
||||
<input type="date" class="form-control" id="ordersDateTo">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" onclick="loadOrders()"><i class="bi bi-search me-1"></i>Filtrér</button>
|
||||
<button class="btn btn-outline-secondary w-100" onclick="resetOrderFilters()"><i class="bi bi-x-lg me-1"></i>Ryd filtre</button>
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
|
||||
@ -358,7 +358,26 @@
|
||||
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
|
||||
}
|
||||
|
||||
let ordersFilterTimer = null;
|
||||
|
||||
function resetOrderFilters() {
|
||||
['ordersSearch', 'ordersStatus', 'ordersCaseId', 'ordersCustomerId', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
|
||||
const element = document.getElementById(id);
|
||||
if (element) element.value = '';
|
||||
});
|
||||
loadOrders();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
['ordersStatus', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('change', loadOrders);
|
||||
});
|
||||
['ordersSearch', 'ordersCaseId', 'ordersCustomerId'].forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', () => {
|
||||
window.clearTimeout(ordersFilterTimer);
|
||||
ordersFilterTimer = window.setTimeout(loadOrders, 300);
|
||||
});
|
||||
});
|
||||
loadOrders();
|
||||
});
|
||||
</script>
|
||||
|
||||
@ -7,10 +7,10 @@ router = APIRouter()
|
||||
async def search_customers(q: str = Query(..., min_length=2)):
|
||||
"""
|
||||
Autocomplete search for customers.
|
||||
Returns list of {id, name, cvr_nummer, email}
|
||||
Returns customer identity and registered address for address-aware workflows.
|
||||
"""
|
||||
sql = """
|
||||
SELECT id, name, cvr_number as cvr_nummer, email
|
||||
SELECT id, name, cvr_number as cvr_nummer, email, address, city, postal_code
|
||||
FROM customers
|
||||
WHERE (name ILIKE %s OR cvr_number ILIKE %s)
|
||||
AND deleted_at IS NULL
|
||||
@ -45,6 +45,19 @@ async def search_contacts(q: str = Query(..., min_length=2)):
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS user_company,
|
||||
(
|
||||
SELECT cu.id
|
||||
FROM contact_companies cc
|
||||
JOIN customers cu ON cu.id = cc.customer_id
|
||||
WHERE cc.contact_id = c.id
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS user_company_id,
|
||||
ARRAY(
|
||||
SELECT cc.customer_id
|
||||
FROM contact_companies cc
|
||||
WHERE cc.contact_id = c.id
|
||||
) AS customer_ids,
|
||||
(
|
||||
SELECT NULLIF(NULLIF(TRIM(COALESCE(cu.mobile_phone, cu.phone)), ''), 'null')
|
||||
FROM contact_companies cc
|
||||
@ -61,11 +74,17 @@ async def search_contacts(q: str = Query(..., min_length=2)):
|
||||
OR CONCAT(c.first_name, ' ', c.last_name) ILIKE %s
|
||||
OR c.phone ILIKE %s
|
||||
OR c.mobile ILIKE %s
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM contact_companies cc_search
|
||||
JOIN customers cu_search ON cu_search.id = cc_search.customer_id
|
||||
WHERE cc_search.contact_id = c.id AND cu_search.name ILIKE %s
|
||||
)
|
||||
ORDER BY c.first_name ASC, c.last_name ASC
|
||||
LIMIT 20
|
||||
"""
|
||||
term = f"%{q}%"
|
||||
results = execute_query(sql, (term, term, term, term, term, term))
|
||||
results = execute_query(sql, (term, term, term, term, term, term, term))
|
||||
return results
|
||||
|
||||
@router.get("/search/hardware")
|
||||
|
||||
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_permission
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.services.sms_service import SmsService
|
||||
from app.services.system_event_logger import log_system_event
|
||||
|
||||
from .schemas import TelefoniCallLinkUpdate, TelefoniUserMappingUpdate, TelefoniClickToCallRequest, SmsSendRequest
|
||||
from .service import TelefoniService
|
||||
@ -380,6 +381,13 @@ async def yealink_established(
|
||||
raw_payload=json.dumps(payload),
|
||||
started_at=datetime.utcnow(),
|
||||
)
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.established", status="success",
|
||||
summary=f"{direction} opkald etableret: {ekstern_value or 'ukendt nummer'}",
|
||||
correlation_id=resolved_callid, user_id=primary_user_id,
|
||||
details={"call_row_id": row.get("id"), "extension": local_extension,
|
||||
"external_number": ekstern_value, "client_ip": payload["client_ip"], "payload": payload},
|
||||
)
|
||||
|
||||
# Send websocket notification to ALL users with this extension
|
||||
if user_ids:
|
||||
@ -440,10 +448,19 @@ async def yealink_terminated(
|
||||
if duration_value is None:
|
||||
logger.info("⚠️ Telefoni terminated with unparseable duration='%s' (callid=%s)", duration_raw, resolved_callid)
|
||||
|
||||
existed = execute_query_single("SELECT id,bruger_id FROM telefoni_opkald WHERE callid=%s", (resolved_callid,))
|
||||
updated = TelefoniService.terminate_call(resolved_callid, duration_value)
|
||||
if not updated:
|
||||
logger.info("⚠️ Telefoni terminated without established (callid=%s)", resolved_callid)
|
||||
time_entry_id = _register_completed_call_time(resolved_callid) if updated else None
|
||||
log_system_event(
|
||||
source="voip", event_type="voip.terminated" if existed else "voip.orphan_hangup",
|
||||
status="success" if existed else "warning",
|
||||
summary="Opkald afsluttet" if existed else "Hangup modtaget uden etablerings-event",
|
||||
correlation_id=resolved_callid, user_id=(existed or {}).get("bruger_id"),
|
||||
details={"duration_raw": duration_raw, "duration_sec": duration_value, "time_entry_id": time_entry_id,
|
||||
"client_ip": _get_client_ip(request)},
|
||||
)
|
||||
return {"status": "ok", "time_entry_id": time_entry_id}
|
||||
|
||||
|
||||
@ -843,6 +860,7 @@ async def list_calls(
|
||||
limit: int = Query(200, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
TelefoniService.reconcile_stale_calls()
|
||||
where = []
|
||||
params = []
|
||||
|
||||
|
||||
@ -9,6 +9,24 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelefoniService:
|
||||
@staticmethod
|
||||
def reconcile_stale_calls(max_age_hours: int = 12) -> int:
|
||||
"""Close the lifecycle state for calls whose phone never sent hangup."""
|
||||
rows = execute_query(
|
||||
"""WITH stale AS (
|
||||
UPDATE telefoni_opkald SET lifecycle_status='missing_hangup',last_event_at=NOW()
|
||||
WHERE ended_at IS NULL AND lifecycle_status='active'
|
||||
AND started_at < NOW()-(%s * INTERVAL '1 hour')
|
||||
RETURNING id,callid,bruger_id,intern_extension,ekstern_nummer,started_at
|
||||
), logged AS (
|
||||
INSERT INTO system_event_log(source,event_type,status,summary,correlation_id,user_id,details,occurred_at)
|
||||
SELECT 'voip','voip.stale_call','warning','Opkald mangler hangup-event',callid,bruger_id,
|
||||
jsonb_build_object('call_row_id',id,'extension',intern_extension,'external_number',ekstern_nummer),NOW()
|
||||
FROM stale RETURNING id
|
||||
) SELECT COUNT(*)::int AS count FROM stale""", (max_age_hours,),
|
||||
) or []
|
||||
return int(rows[0].get("count") or 0) if rows else 0
|
||||
|
||||
@staticmethod
|
||||
def find_user_by_extension(extension: Optional[str]) -> list[int]:
|
||||
"""Find all users with the given extension - returns list of user_ids."""
|
||||
@ -183,6 +201,7 @@ class TelefoniService:
|
||||
bruger_id = COALESCE(telefoni_opkald.bruger_id, EXCLUDED.bruger_id),
|
||||
kontakt_id = COALESCE(telefoni_opkald.kontakt_id, EXCLUDED.kontakt_id),
|
||||
started_at = LEAST(telefoni_opkald.started_at, EXCLUDED.started_at)
|
||||
, lifecycle_status = 'active', last_event_at = NOW()
|
||||
RETURNING *
|
||||
"""
|
||||
rows = execute_query(
|
||||
@ -208,9 +227,9 @@ class TelefoniService:
|
||||
rows = execute_query(
|
||||
"""
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload)
|
||||
(callid, direction, started_at, ended_at, duration_sec, raw_payload, lifecycle_status, termination_source, last_event_at)
|
||||
VALUES
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb)
|
||||
(%s, 'inbound', NOW(), NOW(), %s, '{}'::jsonb, 'orphan_terminated', 'callback_without_start', NOW())
|
||||
ON CONFLICT (callid)
|
||||
DO UPDATE SET
|
||||
ended_at = COALESCE(telefoni_opkald.ended_at, NOW()),
|
||||
@ -222,7 +241,7 @@ class TelefoniService:
|
||||
THEN EXTRACT(EPOCH FROM (NOW() - telefoni_opkald.started_at))::int
|
||||
ELSE NULL
|
||||
END
|
||||
)
|
||||
), lifecycle_status = 'completed', termination_source = 'callback', last_event_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(callid, duration_sec),
|
||||
|
||||
58
app/products/backend/economic_access.py
Normal file
58
app/products/backend/economic_access.py
Normal file
@ -0,0 +1,58 @@
|
||||
"""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
|
||||
397
app/products/backend/economic_catalog.py
Normal file
397
app/products/backend/economic_catalog.py
Normal file
@ -0,0 +1,397 @@
|
||||
"""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 1–25 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()}
|
||||
255
app/products/backend/economic_documents.py
Normal file
255
app/products/backend/economic_documents.py
Normal file
@ -0,0 +1,255 @@
|
||||
"""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
|
||||
77
app/products/backend/economic_pricing.py
Normal file
77
app/products/backend/economic_pricing.py
Normal file
@ -0,0 +1,77 @@
|
||||
"""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}
|
||||
579
app/products/backend/economic_routes.py
Normal file
579
app/products/backend/economic_routes.py
Normal file
@ -0,0 +1,579 @@
|
||||
"""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'])
|
||||
@ -13,7 +13,8 @@ import json
|
||||
import asyncio
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
from app.products.backend.economic_access import ProductAccessRoute
|
||||
router = APIRouter(route_class=ProductAccessRoute)
|
||||
|
||||
|
||||
def _apigw_headers() -> Dict[str, str]:
|
||||
@ -78,9 +79,25 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
supplier_name = payload.get("supplier_name")
|
||||
supplier_code = payload.get("supplier_code")
|
||||
supplier_sku = payload.get("supplier_sku") or payload.get("sku")
|
||||
supplier_price = payload.get("supplier_price") or payload.get("price")
|
||||
supplier_price = payload.get("supplier_price") if payload.get("supplier_price") is not None else payload.get("price")
|
||||
supplier_currency = payload.get("supplier_currency") or payload.get("currency") or "DKK"
|
||||
supplier_stock = payload.get("supplier_stock") or payload.get("stock_qty")
|
||||
supplier_stock = payload.get("supplier_stock") if payload.get("supplier_stock") is not None else 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_product_url = (
|
||||
payload.get("supplier_product_url")
|
||||
@ -115,6 +132,8 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
|
||||
existing = 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)
|
||||
|
||||
if existing:
|
||||
@ -129,6 +148,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
supplier_url = %s,
|
||||
supplier_product_url = %s,
|
||||
source = %s,
|
||||
variant_key = %s, license_term = %s, billing_interval = %s, market = %s, valid_until = %s,
|
||||
last_updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s
|
||||
RETURNING *
|
||||
@ -145,6 +165,7 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
supplier_url,
|
||||
supplier_product_url,
|
||||
source,
|
||||
variant_key, license_term, billing_interval, market, valid_until,
|
||||
existing.get("id"),
|
||||
)
|
||||
)
|
||||
@ -162,8 +183,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
supplier_url,
|
||||
supplier_product_url,
|
||||
source,
|
||||
variant_key, license_term, billing_interval, market, valid_until,
|
||||
last_updated_at
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING *
|
||||
"""
|
||||
result = execute_query(
|
||||
@ -179,9 +202,10 @@ def _upsert_product_supplier(product_id: int, payload: Dict[str, Any], source: s
|
||||
supplier_url,
|
||||
supplier_product_url,
|
||||
source,
|
||||
variant_key, license_term, billing_interval, market, valid_until,
|
||||
)
|
||||
)
|
||||
return result[0] if result else {}
|
||||
return result[0] if result else (execute_query_single(match_query, match_params) if match_query else {})
|
||||
|
||||
|
||||
def _log_product_audit(product_id: int, event_type: str, user_id: Optional[int], changes: Dict[str, Any]) -> None:
|
||||
@ -959,11 +983,13 @@ async def update_product(
|
||||
):
|
||||
"""Update product fields for core metadata and billing validation flags."""
|
||||
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")
|
||||
if name is not None:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name cannot be empty")
|
||||
if not name or len(name) > 255:
|
||||
raise HTTPException(status_code=400, detail="Navnet skal være 1–255 tegn")
|
||||
|
||||
serial_number_required = payload.get("serial_number_required")
|
||||
asset_required = payload.get("asset_required")
|
||||
@ -980,8 +1006,8 @@ async def update_product(
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
updates = ["updated_at = CURRENT_TIMESTAMP"]
|
||||
values: List[Any] = []
|
||||
updates = ["updated_at = CURRENT_TIMESTAMP", "updated_by = %s"]
|
||||
values: List[Any] = [current_user.get("id")]
|
||||
|
||||
if name is not None:
|
||||
updates.append("name = %s")
|
||||
|
||||
@ -82,7 +82,7 @@
|
||||
<div class="badge-soft" id="productStatus">-</div>
|
||||
<div class="fs-3 fw-semibold mt-2" id="productPrice">-</div>
|
||||
<div class="product-muted" id="productSku">-</div>
|
||||
<div class="product-muted" id="productSupplierPrice">-</div>
|
||||
<div class="product-muted" id="productSupplierPrice" data-cost-section>-</div>
|
||||
<div class="badge-soft mt-2" id="productBestPrice" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@ -116,7 +116,7 @@
|
||||
<button class="btn btn-primary w-100" onclick="submitPriceUpdate()">Gem pris</button>
|
||||
<div class="small mt-3" id="priceUpdateMessage"></div>
|
||||
</div>
|
||||
<div class="product-card mt-3">
|
||||
<div class="product-card mt-3" data-cost-section>
|
||||
<h5 class="mb-3">Opdater leverandør</h5>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Leverandør</label>
|
||||
@ -191,7 +191,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="product-card mt-3">
|
||||
<div class="product-card mt-3" data-cost-section>
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2 mb-2">
|
||||
<h5 class="mb-0">Grosister og priser</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@ -258,6 +258,13 @@
|
||||
<div class="col-md-3">
|
||||
<button class="btn btn-outline-primary w-100" onclick="submitSupplierList()">Tilføj/opdater</button>
|
||||
</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="small" id="supplierListMessage"></div>
|
||||
</div>
|
||||
@ -389,6 +396,10 @@ async function loadProductDetail() {
|
||||
const res = await fetch(`/api/v1/products/${productId}`);
|
||||
if (!res.ok) throw new Error('Kunne ikke hente produkt');
|
||||
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('productMeta').textContent = [
|
||||
product.manufacturer,
|
||||
@ -482,11 +493,11 @@ async function loadSupplierList() {
|
||||
return `
|
||||
<tr>
|
||||
<td>${escapeHtml(entry.supplier_name || entry.supplier_code || '-')}</td>
|
||||
<td>${escapeHtml(entry.supplier_sku || '-')}</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>${entry.supplier_price != null ? formatCurrency(entry.supplier_price) : '-'}</td>
|
||||
<td>${entry.supplier_stock != null ? entry.supplier_stock : '-'}</td>
|
||||
<td>${link ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener">Link</a>` : '-'}</td>
|
||||
<td>${entry.last_updated_at ? formatDate(entry.last_updated_at) : '-'}</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><button class="btn btn-sm btn-outline-danger" onclick="deleteSupplier(${entry.id})">Slet</button></td>
|
||||
</tr>
|
||||
`;
|
||||
@ -525,6 +536,11 @@ async function submitSupplierList() {
|
||||
supplier_currency: document.getElementById('supplierListCurrency').value.trim() || null,
|
||||
supplier_stock: document.getElementById('supplierListStock').value ? Number(document.getElementById('supplierListStock').value) : 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'
|
||||
};
|
||||
|
||||
@ -708,7 +724,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadProductDetail();
|
||||
loadPriceHistory();
|
||||
loadSalesHistory();
|
||||
loadSupplierList();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
41
app/products/frontend/economic.html
Normal file
41
app/products/frontend/economic.html
Normal file
@ -0,0 +1,41 @@
|
||||
{% 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 %}
|
||||
@ -324,6 +324,7 @@
|
||||
<p>Opret og vedligehold produkter til abonnementer, service og salgslinjer. Alt samlet, sporbar og klar til brug i sager.</p>
|
||||
</div>
|
||||
<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()">
|
||||
<i class="bi bi-plus-circle"></i> Opret Produkt
|
||||
</button>
|
||||
|
||||
@ -287,9 +287,8 @@ class EconomicService:
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
|
||||
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}")
|
||||
# Customer creation is allowed, updates are deliberately not.
|
||||
logger.warning('e-conomic customer update blocked by the Hub write policy')
|
||||
return False
|
||||
|
||||
try:
|
||||
@ -379,7 +378,7 @@ class EconomicService:
|
||||
Returns:
|
||||
Created supplier data with supplierNumber or None if failed
|
||||
"""
|
||||
if not self._check_write_permission("create_supplier"):
|
||||
logger.warning('e-conomic supplier creation blocked by the Hub write policy')
|
||||
return None
|
||||
|
||||
try:
|
||||
@ -824,8 +823,8 @@ class EconomicService:
|
||||
Dict with voucher details or error info
|
||||
"""
|
||||
# 🚨 SAFETY CHECK
|
||||
if not self._check_write_permission("create_journal_supplier_invoice"):
|
||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
||||
logger.warning('e-conomic voucher posting blocked by the Hub write policy')
|
||||
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||
|
||||
try:
|
||||
# Extract year from invoice date for accounting year
|
||||
@ -1052,8 +1051,8 @@ class EconomicService:
|
||||
Dict with success status
|
||||
"""
|
||||
# 🚨 SAFETY CHECK
|
||||
if not self._check_write_permission("upload_voucher_attachment"):
|
||||
return {"error": True, "message": "Write operations blocked by READ_ONLY or DRY_RUN mode"}
|
||||
logger.warning('e-conomic voucher attachment blocked by the Hub write policy')
|
||||
return {"error": True, "message": "e-conomic er låst: Hub må kun oprette kunder, varer og ordrekladder"}
|
||||
|
||||
try:
|
||||
# Read PDF file
|
||||
|
||||
@ -136,6 +136,23 @@ class EmailProcessorService:
|
||||
}
|
||||
|
||||
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
|
||||
# This is done BEFORE classification so the AI can "read" the voice note
|
||||
if settings.WHISPER_ENABLED:
|
||||
|
||||
@ -34,6 +34,7 @@ from aiohttp import ClientSession, BasicAuth
|
||||
import msal
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -447,6 +448,11 @@ class EmailService:
|
||||
)
|
||||
parsed_email['attachments'] = 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:
|
||||
parsed_email['attachments'] = []
|
||||
|
||||
@ -891,7 +897,24 @@ class EmailService:
|
||||
import base64
|
||||
content = base64.b64decode(content_bytes)
|
||||
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''
|
||||
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)
|
||||
filename = att.get('name')
|
||||
@ -905,6 +928,10 @@ class EmailService:
|
||||
filename = f"audio_attachment{ext}"
|
||||
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({
|
||||
'filename': filename or 'unknown',
|
||||
'content': content,
|
||||
@ -919,6 +946,90 @@ class EmailService:
|
||||
|
||||
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:
|
||||
"""Decode email header (handles MIME encoding)"""
|
||||
if not header:
|
||||
@ -1167,6 +1278,20 @@ class EmailService:
|
||||
if email_data.get('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
|
||||
|
||||
except Exception as e:
|
||||
@ -1477,9 +1602,47 @@ class EmailService:
|
||||
existing = execute_query(check_query, (email_data["message_id"],))
|
||||
|
||||
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']}")
|
||||
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
|
||||
thread_key = self._derive_thread_key(email_data)
|
||||
try:
|
||||
@ -1609,6 +1772,11 @@ class EmailService:
|
||||
)
|
||||
if graph_ok:
|
||||
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
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1669,11 +1837,21 @@ class EmailService:
|
||||
)
|
||||
|
||||
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)"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error: {str(e)}"
|
||||
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:
|
||||
return False, f"Graph failed: {graph_failure_message}; SMTP fallback failed: {str(e)}"
|
||||
return False, error_msg
|
||||
@ -1735,6 +1913,11 @@ class EmailService:
|
||||
subject,
|
||||
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
|
||||
graph_failure_message = graph_message
|
||||
logger.warning("⚠️ Graph send with attachments failed, falling back to SMTP: %s", graph_message)
|
||||
@ -1814,11 +1997,21 @@ class EmailService:
|
||||
len(to_addresses),
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"❌ SMTP send error (attachments): {str(e)}"
|
||||
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:
|
||||
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
|
||||
|
||||
@ -1370,6 +1370,7 @@ class EmailWorkflowService:
|
||||
steps = workflow['workflow_steps']
|
||||
steps_completed = 0
|
||||
step_results = []
|
||||
has_failed_step = False
|
||||
|
||||
try:
|
||||
# Execute each step
|
||||
@ -1377,6 +1378,18 @@ class EmailWorkflowService:
|
||||
action = step.get('action')
|
||||
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}")
|
||||
|
||||
step_result = await self._execute_action(action, params, email_data)
|
||||
@ -1389,6 +1402,7 @@ class EmailWorkflowService:
|
||||
})
|
||||
|
||||
if step_result['status'] == 'failed':
|
||||
has_failed_step = True
|
||||
logger.error(f" ❌ Step failed: {step_result.get('error')}")
|
||||
# Continue to next step even on failure (configurable later)
|
||||
else:
|
||||
@ -1396,37 +1410,49 @@ class EmailWorkflowService:
|
||||
|
||||
steps_completed += 1
|
||||
|
||||
# Mark execution as completed
|
||||
# Preserve a real failure in the execution record instead of
|
||||
# reporting a green workflow with red individual steps.
|
||||
completed_at = datetime.now()
|
||||
execution_time_ms = int((completed_at - started_at).total_seconds() * 1000)
|
||||
execution_status = 'failed' if has_failed_step else 'completed'
|
||||
|
||||
execute_update(
|
||||
"""UPDATE email_workflow_executions
|
||||
SET status = 'completed', steps_completed = %s,
|
||||
SET status = %s, steps_completed = %s,
|
||||
result_json = %s, completed_at = CURRENT_TIMESTAMP,
|
||||
execution_time_ms = %s
|
||||
WHERE id = %s""",
|
||||
(steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
|
||||
(execution_status, steps_completed, json.dumps(step_results), execution_time_ms, execution_id)
|
||||
)
|
||||
|
||||
# Update workflow statistics
|
||||
execute_update(
|
||||
"""UPDATE email_workflows
|
||||
SET execution_count = execution_count + 1,
|
||||
success_count = success_count + 1,
|
||||
success_count = success_count + %s,
|
||||
failure_count = failure_count + %s,
|
||||
last_executed_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s""",
|
||||
(workflow_id,)
|
||||
(0 if has_failed_step else 1, 1 if has_failed_step else 0, workflow_id)
|
||||
)
|
||||
|
||||
logger.info(f"✅ Workflow '{workflow_name}' completed ({execution_time_ms}ms)")
|
||||
if has_failed_step:
|
||||
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
|
||||
await email_activity_logger.log_workflow_executed(
|
||||
email_id=email_id,
|
||||
workflow_id=workflow_id,
|
||||
workflow_name=workflow_name,
|
||||
status='completed',
|
||||
status=execution_status,
|
||||
steps_completed=steps_completed,
|
||||
execution_time_ms=execution_time_ms
|
||||
)
|
||||
@ -1435,7 +1461,7 @@ class EmailWorkflowService:
|
||||
'workflow_id': workflow_id,
|
||||
'workflow_name': workflow_name,
|
||||
'execution_id': execution_id,
|
||||
'status': 'completed',
|
||||
'status': execution_status,
|
||||
'steps_completed': steps_completed,
|
||||
'steps_total': len(steps),
|
||||
'execution_time_ms': execution_time_ms,
|
||||
@ -1515,6 +1541,12 @@ class EmailWorkflowService:
|
||||
}
|
||||
|
||||
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 {
|
||||
'status': 'success',
|
||||
'result': result
|
||||
@ -1817,7 +1849,9 @@ class EmailWorkflowService:
|
||||
attachments = execute_query(
|
||||
"""SELECT filename, file_path, size_bytes, content_type
|
||||
FROM email_attachments
|
||||
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
||||
WHERE email_id = %s
|
||||
AND (LOWER(COALESCE(content_type, '')) = 'application/pdf'
|
||||
OR LOWER(filename) LIKE '%%.pdf')""",
|
||||
(email_id,)
|
||||
)
|
||||
|
||||
@ -1826,6 +1860,29 @@ class EmailWorkflowService:
|
||||
elif not isinstance(attachments, list):
|
||||
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:
|
||||
logger.warning(f"⚠️ No PDF attachments found for email {email_id}")
|
||||
return {
|
||||
@ -1870,11 +1927,17 @@ class EmailWorkflowService:
|
||||
|
||||
# Check if file already exists
|
||||
existing = execute_query(
|
||||
"SELECT file_id FROM incoming_files WHERE checksum = %s",
|
||||
"SELECT file_id, status FROM incoming_files WHERE checksum = %s",
|
||||
(checksum,))
|
||||
|
||||
if existing:
|
||||
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
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
@ -1919,12 +1982,43 @@ class EmailWorkflowService:
|
||||
continue
|
||||
|
||||
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 {
|
||||
'action': 'extract_invoice_data',
|
||||
'success': True,
|
||||
'files_uploaded': len(uploaded_files),
|
||||
'file_ids': [f['file_id'] for f in uploaded_files],
|
||||
'note': f"{len(uploaded_files)} PDF(er) gemt i 'Mangler Behandling'"
|
||||
'processing_results': processing_results,
|
||||
'note': f"{len(uploaded_files)} PDF(er) gemt og behandlet"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
|
||||
@ -413,6 +413,7 @@ REGLER FOR title:
|
||||
"model": self.model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {
|
||||
"temperature": 0.1,
|
||||
"top_p": 0.9,
|
||||
@ -459,6 +460,18 @@ REGLER FOR title:
|
||||
# Parse JSON from 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
|
||||
# LLM sometimes returns negative amounts for invoices - fix this!
|
||||
document_type = extraction.get('document_type', 'invoice')
|
||||
@ -527,6 +540,39 @@ REGLER FOR title:
|
||||
"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:
|
||||
"""Parse JSON from LLM response with aggressive fallback strategies"""
|
||||
logger.info(f"🔍 Response length: {len(response)}, preview: {response[:200]}")
|
||||
|
||||
@ -112,7 +112,22 @@ def billing_date_for_period(
|
||||
target = period_start - relativedelta(months=max(0, int(lead_months or 0)))
|
||||
if schedule_type == "interval_anchor":
|
||||
return target
|
||||
return resolve_month_date(target.year, target.month, schedule_type, billing_day)
|
||||
resolved = 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:
|
||||
|
||||
22
app/services/system_event_logger.py
Normal file
22
app/services/system_event_logger.py
Normal file
@ -0,0 +1,22 @@
|
||||
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)
|
||||
@ -7,6 +7,7 @@ import json
|
||||
import re
|
||||
import html as html_lib
|
||||
import aiohttp
|
||||
import base64
|
||||
from decimal import Decimal
|
||||
from typing import List, Dict, Optional, Any
|
||||
from app.core.config import settings
|
||||
@ -165,6 +166,40 @@ class VTigerService:
|
||||
logger.error(f"❌ vTiger query error: {e}")
|
||||
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]:
|
||||
"""
|
||||
Fetch a single account by ID from vTiger
|
||||
|
||||
278
app/settings/backend/ai_benchmark.py
Normal file
278
app/settings/backend/ai_benchmark.py
Normal file
@ -0,0 +1,278 @@
|
||||
"""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 []
|
||||
@ -2,13 +2,13 @@
|
||||
Settings and User Management API Router
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Depends
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request, Depends, Query
|
||||
from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from app.core.database import execute_query
|
||||
from datetime import datetime, timezone
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.core.config import settings
|
||||
from app.core.auth_dependencies import require_superadmin
|
||||
from app.core.auth_dependencies import require_any_permission, require_superadmin
|
||||
from app.core.auth_service import AuthService
|
||||
import argparse
|
||||
import asyncio
|
||||
@ -70,6 +70,11 @@ class SagTestRunRequest(BaseModel):
|
||||
customer_id: Optional[int] = None
|
||||
|
||||
|
||||
class AIBenchmarkRunRequest(BaseModel):
|
||||
models: List[str]
|
||||
test_keys: Optional[List[str]] = None
|
||||
|
||||
|
||||
MATTERMOST_SETTING_DEFAULTS = (
|
||||
("mattermost_reminders_enabled", "false", "Send reminders to Mattermost", "boolean"),
|
||||
("mattermost_webhook_url", "", "Mattermost incoming webhook URL", "string"),
|
||||
@ -182,6 +187,91 @@ async def run_sag_test(
|
||||
_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
|
||||
@router.get("/settings", response_model=List[Setting], tags=["Settings"])
|
||||
async def get_settings(category: Optional[str] = None):
|
||||
@ -273,7 +363,7 @@ async def get_setting(key: str):
|
||||
query = "SELECT * FROM settings WHERE key = %s"
|
||||
result = execute_query(query, (key,))
|
||||
|
||||
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses"}:
|
||||
if not result and key in {"case_types", "case_type_module_defaults", "case_statuses", "time_multiplier_presets"}:
|
||||
seed_query = """
|
||||
INSERT INTO settings (key, value, category, description, value_type, is_public)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
@ -325,6 +415,19 @@ 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,))
|
||||
|
||||
if not result and key == "email_default_signature_template":
|
||||
@ -1185,3 +1288,76 @@ async def test_ai_prompt(key: str, payload: PromptTestRequest, http_request: Req
|
||||
logger.error(f"❌ AI prompt test failed for {key}: {repr(e)}")
|
||||
err = str(e) or e.__class__.__name__
|
||||
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])}
|
||||
|
||||
@ -18,6 +18,7 @@ from app.core.auth_dependencies import require_any_permission
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
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(
|
||||
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
||||
@ -265,6 +266,11 @@ 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"])
|
||||
async def migrations_page(request: Request):
|
||||
"""Render database migrations page"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user