Compare commits
27 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 | ||
|
|
ffdc9ac62c | ||
|
|
2c2db54d16 | ||
|
|
4daca03a55 | ||
|
|
b755d61a5e | ||
|
|
147451c259 | ||
|
|
d0f27b78d5 |
31
.env.example
31
.env.example
@ -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
|
||||
@ -10,6 +11,12 @@ POSTGRES_PASSWORD=bmc_hub
|
||||
POSTGRES_DB=bmc_hub
|
||||
POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services
|
||||
|
||||
# Public website content administration API
|
||||
WEBSITE_CONTENT_API_URL=https://isp.bmcnetworks.dk/api/admin-content.php
|
||||
WEBSITE_CONTENT_API_HOST_HEADER=ct.bmcnetworks.dk
|
||||
WEBSITE_CONTENT_API_TOKEN=
|
||||
WEBSITE_CONTENT_API_TIMEOUT_SECONDS=20
|
||||
|
||||
# =====================================================
|
||||
# API CONFIGURATION
|
||||
# =====================================================
|
||||
@ -97,6 +104,30 @@ FEDEX_TIMEOUT_SECONDS=20
|
||||
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
|
||||
FEDEX_READ_ONLY=true
|
||||
FEDEX_DRY_RUN=true
|
||||
|
||||
# =====================================================
|
||||
# Shipmondo Integration (Optional)
|
||||
# Opret API-bruger og API-nøgle i Shipmondo under Indstillinger > API.
|
||||
# Brug https://sandbox.shipmondo.com/api/public/v3 til sandbox.
|
||||
# =====================================================
|
||||
SHIPMONDO_ENABLED=false
|
||||
SHIPMONDO_API_BASE_URL=https://app.shipmondo.com/api/public/v3
|
||||
SHIPMONDO_API_USER=
|
||||
SHIPMONDO_API_KEY=
|
||||
SHIPMONDO_TIMEOUT_SECONDS=30
|
||||
SHIPMONDO_SENDER_NAME=BMC Networks
|
||||
SHIPMONDO_SENDER_ATTENTION=
|
||||
SHIPMONDO_SENDER_ADDRESS1=
|
||||
SHIPMONDO_SENDER_ADDRESS2=
|
||||
SHIPMONDO_SENDER_POSTAL_CODE=
|
||||
SHIPMONDO_SENDER_CITY=
|
||||
SHIPMONDO_SENDER_COUNTRY_CODE=DK
|
||||
SHIPMONDO_SENDER_EMAIL=
|
||||
SHIPMONDO_SENDER_PHONE=
|
||||
|
||||
# Start sikkert: drafts er tilladt, men booking er blokeret.
|
||||
SHIPMONDO_READ_ONLY=true
|
||||
SHIPMONDO_DRY_RUN=true
|
||||
# =====================================================
|
||||
# Nextcloud Integration (Optional)
|
||||
# =====================================================
|
||||
|
||||
@ -117,6 +117,28 @@ FEDEX_TIMEOUT_SECONDS=20
|
||||
FEDEX_READ_ONLY=true
|
||||
FEDEX_DRY_RUN=true
|
||||
|
||||
# =====================================================
|
||||
# Shipmondo Integration - Production
|
||||
# =====================================================
|
||||
SHIPMONDO_ENABLED=false
|
||||
SHIPMONDO_API_BASE_URL=https://app.shipmondo.com/api/public/v3
|
||||
SHIPMONDO_API_USER=
|
||||
SHIPMONDO_API_KEY=
|
||||
SHIPMONDO_TIMEOUT_SECONDS=30
|
||||
SHIPMONDO_SENDER_NAME=BMC Networks
|
||||
SHIPMONDO_SENDER_ATTENTION=
|
||||
SHIPMONDO_SENDER_ADDRESS1=
|
||||
SHIPMONDO_SENDER_ADDRESS2=
|
||||
SHIPMONDO_SENDER_POSTAL_CODE=
|
||||
SHIPMONDO_SENDER_CITY=
|
||||
SHIPMONDO_SENDER_COUNTRY_CODE=DK
|
||||
SHIPMONDO_SENDER_EMAIL=
|
||||
SHIPMONDO_SENDER_PHONE=
|
||||
|
||||
# Start ALTID med begge sat til true.
|
||||
SHIPMONDO_READ_ONLY=true
|
||||
SHIPMONDO_DRY_RUN=true
|
||||
|
||||
# =====================================================
|
||||
# Links / Endpoints Module - Production (Optional)
|
||||
# =====================================================
|
||||
|
||||
34
MDfile/RELEASE_NOTES_v2.6.0.md
Normal file
34
MDfile/RELEASE_NOTES_v2.6.0.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Release Notes: v2.6.0
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.6.0 udvider BMC Hub med Shipmondo-forsendelser, en samlet afregningsgang for tidskøen og en mere robust produktintegration. Sagsvisningen har samtidig fået forbedret historik og arbejdsgange omkring produkter og forsendelser.
|
||||
|
||||
## Shipmondo
|
||||
|
||||
- Ny Shipmondo-integration med produktoversigt, lokale bookingkladder, afsendelse, tracking og PDF-labels.
|
||||
- Forsendelser knyttes til sag, kunde og kontakt og gemmes med pakker, status og API-resultat.
|
||||
- Sikker standardopsætning med integrationen deaktiveret samt `read-only` og `dry-run` aktiveret.
|
||||
- Nye miljøindstillinger og Docker-konfiguration til API-adgang og afsenderoplysninger.
|
||||
|
||||
## Tidskø og afregning
|
||||
|
||||
- Valgte tidsregistreringer kan forhåndsvalideres og afregnes samlet.
|
||||
- Understøttelse af ordrekladde, klippekort og ikke-fakturerbar afregning uden at blande betalingsmetoder.
|
||||
- Oprettede ordrekladder og afregningsoplysninger spores direkte på tidsregistreringerne, så dobbeltbehandling undgås.
|
||||
- Kundeidentifikation prioriterer sagens virksomhed og håndterer ældre trackingdata som fallback.
|
||||
|
||||
## Produkter og sager
|
||||
|
||||
- Produktdata fra API Gateway normaliseres på tværs af leverandørernes forskellige feltnavne og payload-formater.
|
||||
- Produktsøgning, sortering og import er gjort mere robust ved manglende eller indlejrede data.
|
||||
- Købs- og salgslinjer kan oprettes atomisk på en sag fra én produkthandling.
|
||||
- Sagsdetaljen og historikken viser flere relevante handlinger og tidsstempler.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Målrettede tests for sager, tidskø, produkter og Shipmondo: **45 bestået**.
|
||||
- Python-kompilering og diff-kontrol er gennemført.
|
||||
- Den samlede vedligeholdte testsuite har 131 beståede og 7 eksisterende fejl i databaseafhængige eller andre ikke-berørte moduler.
|
||||
21
MDfile/RELEASE_NOTES_v2.6.1.md
Normal file
21
MDfile/RELEASE_NOTES_v2.6.1.md
Normal file
@ -0,0 +1,21 @@
|
||||
# Release Notes: v2.6.1
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.6.1 retter genkørsel af fem ældre migrationer, som produktionsserverens schema-detektion korrekt fandt, men som ikke var fuldt idempotente.
|
||||
|
||||
## Databasemigrationer
|
||||
|
||||
- Settings- og ticket-triggers fjernes sikkert, før de genoprettes.
|
||||
- `sag_salgsvarer` oprettes fra rod-migrationerne, hvis den modul-lokale grundmigration ikke tidligere er kørt.
|
||||
- Produkt-, leverandørfaktura- og sporbarhedskolonner kan derefter tilføjes sikkert.
|
||||
- Ticket-views med udvidede tabelkolonner genskabes sikkert ved genkørsel.
|
||||
- Den forældede unikke CVR-regel er fjernet i overensstemmelse med den nuværende datamodel, hvor flere kundeposter må dele CVR-nummer.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- De fem berørte migrationer er kørt samlet mod lokal PostgreSQL: **5 bestået**.
|
||||
- Hele sættet er derefter genkørt: **5 bestået**, hvilket bekræfter idempotens.
|
||||
- Diff-kontrol er gennemført.
|
||||
19
MDfile/RELEASE_NOTES_v2.6.2.md
Normal file
19
MDfile/RELEASE_NOTES_v2.6.2.md
Normal file
@ -0,0 +1,19 @@
|
||||
# Release Notes: v2.6.2
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.6.2 retter udgående opkald fra kontakt-popupen på en sag.
|
||||
|
||||
## Telefoni
|
||||
|
||||
- En manglende afsluttende klamme i SMS-eventlisteneren gjorde den tilhørende JavaScript-blok ugyldig.
|
||||
- Click-to-call-funktionen indlæses nu korrekt igen.
|
||||
- Ring-knappen bruger fortsat kontaktens mobilnummer som førstevalg og telefonnummeret som fallback.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- JavaScript-syntakskontrol bestået for den berørte blok.
|
||||
- Sag-modulets tests: **31 bestået**.
|
||||
- Diff-kontrol gennemført.
|
||||
25
MDfile/RELEASE_NOTES_v2.7.0.md
Normal file
25
MDfile/RELEASE_NOTES_v2.7.0.md
Normal file
@ -0,0 +1,25 @@
|
||||
# Release Notes: v2.7.0
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.7.0 gør linkede telefonopkald og AnyDesk-sessioner synlige i både sagens tidsregistrering og samlede historik.
|
||||
|
||||
## Tidsregistrering
|
||||
|
||||
- Ny oversigt over linkede opkald og AnyDesk-sessioner på sagen.
|
||||
- Aktiviteter viser type, tidspunkt, varighed, kontakt/nummer og medarbejder.
|
||||
- Aktiviteterne vises som kildedata og opretter ikke automatisk fakturerbar tid.
|
||||
|
||||
## Historik
|
||||
|
||||
- Linkede indgående og udgående opkald indgår som selvstændige historikhændelser.
|
||||
- Linkede AnyDesk-sessioner indgår med status, varighed og ansvarlig medarbejder.
|
||||
- Historik med undersager inkluderer også aktiviteter linket til undersagerne.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Sag-modul: **33 bestået**.
|
||||
- Telefoni: **5 bestået**.
|
||||
- Python-kompilering og diff-kontrol gennemført.
|
||||
20
MDfile/RELEASE_NOTES_v2.7.1.md
Normal file
20
MDfile/RELEASE_NOTES_v2.7.1.md
Normal file
@ -0,0 +1,20 @@
|
||||
# Release Notes: v2.7.1
|
||||
|
||||
**Dato:** 25. august 2026
|
||||
|
||||
## Overblik
|
||||
|
||||
Version 2.7.1 retter linkning og varighed for ældre telefonopkald fra Mission Control.
|
||||
|
||||
## Telefoni
|
||||
|
||||
- Ældre opkald fra `mission_call_state` flyttes til den almindelige telefonilog, når en kontakt eller sag linkes.
|
||||
- Alle linkede opkald bliver dermed synlige på sagen samt i Tidregistrering og Historik.
|
||||
- Den stabile eksterne call-id og kildetype sendes med ved linkning.
|
||||
- Forsinkede afslutningshændelser kan ikke længere skabe varigheder på flere måneder.
|
||||
- Eksisterende urimelige varigheder over 12 timer skjules som ukendt varighed.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Telefoni- og sag-tests: **41 bestået**.
|
||||
- Python-kompilering og diff-kontrol gennemført.
|
||||
30
MDfile/RELEASE_NOTES_v2.7.2.md
Normal file
30
MDfile/RELEASE_NOTES_v2.7.2.md
Normal file
@ -0,0 +1,30 @@
|
||||
# BMC Hub v2.7.2
|
||||
|
||||
## CRM
|
||||
|
||||
- Kunde- og kontaktkort har nye faner til direkte relaterede sager og e-mails.
|
||||
- Kontaktsøgning understøtter navn i flere rækkefølger, firma, titel, afdeling, e-mail og normaliserede telefonnumre.
|
||||
- Kontakter kan oprettes eller opdateres fra Outlook `.msg`/`.eml` med godkendelse før lagring.
|
||||
- CVR fra mails valideres gennem den eksisterende FirmaAPI-integration, og eksisterende virksomheder genbruges.
|
||||
- Leverandørservice kan administreres direkte fra kundens økonomiske oplysninger.
|
||||
|
||||
## Sager og telefoni
|
||||
|
||||
- Nye sager forvælger den aktuelle bruger som ansvarlig.
|
||||
- Kontaktkort på sager har direkte handlinger til opkald og SMS.
|
||||
- Opkald startet fra en sag knyttes til sagen og vises i opkaldshistorikken.
|
||||
- Firmaet i sagens topområde linker direkte til kundekortet.
|
||||
|
||||
## Abonnementer
|
||||
|
||||
- Udvidet aftale-, faktureringskalender- og ændringsflow.
|
||||
- Nye migreringer til første fakturalinjer, periodisering og integritetskontrol.
|
||||
|
||||
## Websiteindhold
|
||||
|
||||
- Nyt administrationsmodul til kundereferencer og driftsinformation.
|
||||
- Rettighedsstyret visning og redigering samt website-endpoints til indhold og logoer.
|
||||
|
||||
## Verifikation
|
||||
|
||||
- Målrettede CRM-, sag-, telefoni-, abonnements- og website-tests er kørt før release.
|
||||
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")
|
||||
|
||||
@ -3,8 +3,8 @@ Contact API Router - Simplified (Read-Only)
|
||||
Only GET endpoints for now
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, status
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
|
||||
from typing import Any, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import (
|
||||
execute_query,
|
||||
@ -15,6 +15,7 @@ from app.core.database import (
|
||||
)
|
||||
from psycopg2.extras import RealDictCursor
|
||||
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id
|
||||
from app.services.cvr_service import get_cvr_service
|
||||
from app.customers.backend.router import (
|
||||
get_customer_subscriptions,
|
||||
lock_customer_subscriptions,
|
||||
@ -22,9 +23,14 @@ from app.customers.backend.router import (
|
||||
get_subscription_comment,
|
||||
get_subscription_billing_matrix,
|
||||
SubscriptionComment,
|
||||
CustomerCreate,
|
||||
create_customer,
|
||||
)
|
||||
import logging
|
||||
import json
|
||||
import re
|
||||
import html
|
||||
from email.utils import parseaddr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -63,6 +69,17 @@ class ContactMergeRequest(BaseModel):
|
||||
source_contact_id: int = Field(..., gt=0)
|
||||
|
||||
|
||||
class EmailCompanyResolve(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
cvr_number: str = Field(..., pattern=r"^\d{8}$")
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
postal_code: Optional[str] = None
|
||||
website: Optional[str] = None
|
||||
|
||||
|
||||
CONTACT_MERGE_RELATIONS = (
|
||||
("Firmaer", "contact_companies", "contact_id"),
|
||||
("Sager", "sag_kontakter", "contact_id"),
|
||||
@ -79,6 +96,325 @@ CONTACT_MERGE_RELATIONS = (
|
||||
)
|
||||
|
||||
|
||||
def _exact_email_pattern(address: str) -> str:
|
||||
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
|
||||
|
||||
|
||||
def _plain_email_body(parsed: dict) -> str:
|
||||
text = str(parsed.get("body_text") or "").strip()
|
||||
if text:
|
||||
return html.unescape(text).replace("\xa0", " ")
|
||||
raw_html = str(parsed.get("body_html") or "")
|
||||
raw_html = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw_html)
|
||||
raw_html = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", raw_html)
|
||||
return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ")
|
||||
|
||||
|
||||
def _signature_lines(body: str) -> list[str]:
|
||||
"""Normalize common Outlook/Markdown decoration without losing line structure."""
|
||||
lines = []
|
||||
for raw_line in body.splitlines():
|
||||
line = re.sub(r"[*_`]+", "", raw_line)
|
||||
line = re.sub(r"\s+", " ", line).strip(" \\|")
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _labelled_phone(lines: list[str], labels: str) -> Optional[str]:
|
||||
phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}")
|
||||
label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$")
|
||||
for line in lines:
|
||||
labelled = label_pattern.search(line)
|
||||
if labelled:
|
||||
phone = phone_pattern.search(labelled.group(1))
|
||||
if phone:
|
||||
return phone.group(0).strip()
|
||||
return None
|
||||
|
||||
|
||||
def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]:
|
||||
"""Conservative, review-only extraction from headers and labelled signature lines."""
|
||||
body = _plain_email_body(parsed)
|
||||
signature_lines = _signature_lines(body)
|
||||
sender_email = str(parsed.get("sender_email") or "").strip().lower()
|
||||
sender_name = str(parsed.get("sender_name") or "").strip()
|
||||
parsed_sender_name, parsed_sender_address = parseaddr(sender_name)
|
||||
if parsed_sender_name:
|
||||
sender_name = parsed_sender_name
|
||||
if not sender_email and parsed_sender_address:
|
||||
sender_email = parsed_sender_address.strip().lower()
|
||||
current_email = str(contact.get("email") or "").strip().lower()
|
||||
candidates: dict[str, tuple[str, str]] = {}
|
||||
|
||||
# A received Outlook message normally represents the sender. If this looks
|
||||
# like a sent message to the current contact, do not suggest our own sender.
|
||||
recipient_text = str(parsed.get("recipient_email") or "").lower()
|
||||
sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text
|
||||
if sender_represents_contact and sender_email:
|
||||
candidates["email"] = (sender_email, "Mailens afsender")
|
||||
if sender_represents_contact and sender_name and "@" not in sender_name:
|
||||
name_parts = sender_name.split()
|
||||
if name_parts:
|
||||
candidates["first_name"] = (name_parts[0], "Afsendernavn")
|
||||
if len(name_parts) > 1:
|
||||
candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn")
|
||||
|
||||
mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?")
|
||||
phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte")
|
||||
if mobile:
|
||||
candidates["mobile"] = (mobile, "Mailens signatur")
|
||||
if phone:
|
||||
candidates["phone"] = (phone, "Mailens signatur")
|
||||
|
||||
for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")):
|
||||
pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$")
|
||||
for line in signature_lines:
|
||||
match = pattern.search(line)
|
||||
if match:
|
||||
candidates[field] = (match.group(1).strip(), "Mailens signatur")
|
||||
break
|
||||
|
||||
# Outlook signatures often put an unlabelled job title directly below the
|
||||
# sender's name. This remains a review-only suggestion.
|
||||
contact_full_name = " ".join(
|
||||
part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part
|
||||
)
|
||||
signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2]
|
||||
rejected_titles = {
|
||||
"kind regards", "best regards", "regards", "med venlig hilsen",
|
||||
"venlig hilsen", "mvh", "thanks", "thank you", "tak",
|
||||
}
|
||||
for signature_name in dict.fromkeys(signature_names):
|
||||
for index in range(len(signature_lines) - 2, -1, -1):
|
||||
if signature_lines[index].casefold() != signature_name:
|
||||
continue
|
||||
possible_title = signature_lines[index + 1]
|
||||
if (
|
||||
"title" not in candidates
|
||||
and 2 <= len(possible_title) <= 100
|
||||
and possible_title.casefold().rstrip(",.! ") not in rejected_titles
|
||||
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title)
|
||||
and not re.search(r"[@\d]|https?://", possible_title)
|
||||
):
|
||||
candidates["title"] = (possible_title, "Linjen under afsendernavnet")
|
||||
break
|
||||
if "title" in candidates:
|
||||
break
|
||||
|
||||
labels = {
|
||||
"first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail",
|
||||
"phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling",
|
||||
}
|
||||
suggestions = []
|
||||
for field, (value, source) in candidates.items():
|
||||
current = str(contact.get(field) or "").strip()
|
||||
if value and current.casefold() != value.casefold():
|
||||
suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source})
|
||||
return suggestions
|
||||
|
||||
|
||||
def _company_from_email_body(body: str, contact_name: str = "") -> dict:
|
||||
lines = _signature_lines(body)
|
||||
joined = "\n".join(lines)
|
||||
cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined)
|
||||
cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else ""
|
||||
if len(cvr_number) != 8:
|
||||
cvr_number = ""
|
||||
|
||||
company_name = ""
|
||||
labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined)
|
||||
if labelled_name:
|
||||
company_name = labelled_name.group(1).strip()
|
||||
if not company_name:
|
||||
for index, line in enumerate(lines):
|
||||
if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)):
|
||||
continue
|
||||
if index:
|
||||
candidate = lines[index - 1].strip()
|
||||
if (
|
||||
candidate.casefold() != contact_name.casefold()
|
||||
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate)
|
||||
and not re.search(r"[@+]|https?://", candidate)
|
||||
):
|
||||
company_name = candidate
|
||||
break
|
||||
return {"name": company_name or None, "cvr_number": cvr_number or None}
|
||||
|
||||
|
||||
@router.post("/contacts/analyze-email")
|
||||
async def analyze_email_for_new_contact(file: UploadFile = File(...)):
|
||||
"""Return editable contact/company candidates without creating anything."""
|
||||
filename = str(file.filename or "").lower()
|
||||
if not filename.endswith((".eml", ".msg")):
|
||||
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
|
||||
content = await file.read()
|
||||
if not content or len(content) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
|
||||
from app.services.email_service import EmailService
|
||||
parser = EmailService()
|
||||
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
|
||||
|
||||
empty_contact = {field: None for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department")}
|
||||
suggestions = _contact_suggestions_from_email(empty_contact, parsed)
|
||||
contact = {item["field"]: item["suggested"] for item in suggestions}
|
||||
contact_name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
|
||||
company = _company_from_email_body(_plain_email_body(parsed), contact_name)
|
||||
company["existing_id"] = None
|
||||
company["existing_name"] = None
|
||||
company["lookup_found"] = False
|
||||
if company.get("cvr_number"):
|
||||
existing = execute_query_single(
|
||||
"""SELECT id, name FROM customers
|
||||
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
|
||||
ORDER BY id LIMIT 1""",
|
||||
(company["cvr_number"],),
|
||||
)
|
||||
if existing:
|
||||
company["existing_id"] = existing.get("id")
|
||||
company["existing_name"] = existing.get("name")
|
||||
official = await get_cvr_service().lookup_by_cvr(company["cvr_number"])
|
||||
if official:
|
||||
company.update({
|
||||
"name": official.get("name") or company.get("name"),
|
||||
"address": official.get("address"),
|
||||
"city": official.get("city"),
|
||||
"postal_code": official.get("postal_code") or official.get("zipcode"),
|
||||
"phone": official.get("phone"),
|
||||
"email": official.get("email"),
|
||||
"website": official.get("website"),
|
||||
"status": official.get("status"),
|
||||
"source": official.get("source"),
|
||||
"lookup_found": True,
|
||||
})
|
||||
return {
|
||||
"filename": file.filename,
|
||||
"mail": {"subject": parsed.get("subject"), "sender_email": parsed.get("sender_email")},
|
||||
"contact": contact,
|
||||
"company": company,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/contacts/resolve-email-company")
|
||||
async def resolve_email_company(payload: EmailCompanyResolve):
|
||||
"""Reuse a CVR match or create the approved company exactly once."""
|
||||
existing = execute_query_single(
|
||||
"""SELECT id, name, cvr_number FROM customers
|
||||
WHERE REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') = %s
|
||||
ORDER BY id LIMIT 1""",
|
||||
(payload.cvr_number,),
|
||||
)
|
||||
if existing:
|
||||
return {**existing, "created": False}
|
||||
created = await create_customer(CustomerCreate(
|
||||
name=payload.name,
|
||||
cvr_number=payload.cvr_number,
|
||||
email=payload.email,
|
||||
phone=payload.phone,
|
||||
address=payload.address,
|
||||
city=payload.city,
|
||||
postal_code=payload.postal_code,
|
||||
website=payload.website,
|
||||
is_active=True,
|
||||
))
|
||||
return {**created, "created": True}
|
||||
|
||||
|
||||
@router.post("/contacts/{contact_id}/analyze-email")
|
||||
async def analyze_contact_email(contact_id: int, file: UploadFile = File(...)):
|
||||
"""Parse an Outlook email and return reviewable contact update suggestions."""
|
||||
contact = execute_query_single(
|
||||
"SELECT id, first_name, last_name, email, phone, mobile, title, department FROM contacts WHERE id = %s",
|
||||
(contact_id,),
|
||||
)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
filename = str(file.filename or "").lower()
|
||||
if not filename.endswith((".eml", ".msg")):
|
||||
raise HTTPException(status_code=400, detail="Kun Outlook .msg- og .eml-filer understøttes")
|
||||
content = await file.read()
|
||||
if not content or len(content) > 10 * 1024 * 1024:
|
||||
raise HTTPException(status_code=400, detail="Mailfilen er tom eller større end 10 MB")
|
||||
|
||||
from app.services.email_service import EmailService
|
||||
parser = EmailService()
|
||||
parsed = parser.parse_msg_file(content) if filename.endswith(".msg") else parser.parse_eml_file(content)
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=422, detail="Mailen kunne ikke læses")
|
||||
suggestions = _contact_suggestions_from_email(contact, parsed)
|
||||
return {
|
||||
"filename": file.filename,
|
||||
"mail": {"subject": parsed.get("subject"), "sender_name": parsed.get("sender_name"), "sender_email": parsed.get("sender_email")},
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/emails")
|
||||
async def get_contact_emails(
|
||||
contact_id: int,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
contact = execute_query_single("SELECT id, email FROM contacts WHERE id = %s", (contact_id,))
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
address = str(contact.get("email") or "").strip().lower()
|
||||
if not address:
|
||||
return {"items": [], "total": 0, "limit": limit, "offset": offset}
|
||||
|
||||
predicate = "em.deleted_at IS NULL AND LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc)) ~ %s"
|
||||
params = (_exact_email_pattern(address),)
|
||||
total = execute_query_single(f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", params)
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
|
||||
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
|
||||
em.linked_case_id, s.titel AS linked_case_title
|
||||
FROM email_messages em
|
||||
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
|
||||
WHERE {predicate}
|
||||
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(*params, limit, offset),
|
||||
) or []
|
||||
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}/cases")
|
||||
async def get_contact_cases(
|
||||
contact_id: int,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
if not execute_query_single("SELECT id FROM contacts WHERE id = %s", (contact_id,)):
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
predicate = "sk.contact_id = %s AND sk.deleted_at IS NULL AND s.deleted_at IS NULL"
|
||||
total = execute_query_single(
|
||||
f"SELECT COUNT(*) AS count FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE {predicate}",
|
||||
(contact_id,),
|
||||
)
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT s.id, s.titel, s.status, s.updated_at, s.customer_id,
|
||||
cu.name AS customer_name,
|
||||
s.ansvarlig_bruger_id,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), '')) AS responsible_name
|
||||
FROM sag_kontakter sk
|
||||
JOIN sag_sager s ON s.id = sk.sag_id
|
||||
LEFT JOIN customers cu ON cu.id = s.customer_id
|
||||
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
|
||||
WHERE {predicate}
|
||||
ORDER BY s.updated_at DESC NULLS LAST, s.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
(contact_id, limit, offset),
|
||||
) or []
|
||||
return {"items": rows, "total": int((total or {}).get("count") or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
class ContactCompanyLink(BaseModel):
|
||||
customer_id: int
|
||||
is_primary: bool = True
|
||||
@ -147,28 +483,32 @@ async def get_contacts(
|
||||
where_clauses = []
|
||||
params = []
|
||||
|
||||
if search:
|
||||
where_clauses.append(
|
||||
"""
|
||||
normalized_search = " ".join(str(search or "").split())
|
||||
if normalized_search:
|
||||
for token in normalized_search.split(" "):
|
||||
token_clause = """
|
||||
(
|
||||
c.first_name ILIKE %s
|
||||
OR c.last_name ILIKE %s
|
||||
OR c.email ILIKE %s
|
||||
OR c.phone ILIKE %s
|
||||
OR c.mobile ILIKE %s
|
||||
OR c.user_company ILIKE %s
|
||||
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s
|
||||
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s
|
||||
OR COALESCE(c.email, '') ILIKE %s
|
||||
OR COALESCE(c.title, '') ILIKE %s
|
||||
OR COALESCE(c.department, '') ILIKE %s
|
||||
OR COALESCE(c.user_company, '') ILIKE %s
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM contact_companies cc2
|
||||
SELECT 1 FROM contact_companies cc2
|
||||
JOIN customers cu2 ON cu2.id = cc2.customer_id
|
||||
WHERE cc2.contact_id = c.id
|
||||
AND cu2.name ILIKE %s
|
||||
)
|
||||
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
|
||||
)
|
||||
"""
|
||||
)
|
||||
like = f"%{search}%"
|
||||
params.extend([like, like, like, like, like, like, like])
|
||||
token_params: list[Any] = [f"%{token}%"] * 7
|
||||
digits = "".join(ch for ch in token if ch.isdigit())
|
||||
if len(digits) >= 2:
|
||||
token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
|
||||
token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
|
||||
token_params.extend([f"%{digits}%", f"%{digits}%"])
|
||||
token_clause += ")"
|
||||
where_clauses.append(token_clause)
|
||||
params.extend(token_params)
|
||||
|
||||
if is_active is not None:
|
||||
where_clauses.append("c.is_active = %s")
|
||||
@ -188,16 +528,33 @@ async def get_contacts(
|
||||
total = count_result[0]['count'] if count_result else 0
|
||||
|
||||
# Step 1: Fetch contacts only (stable pagination)
|
||||
rank_order_sql = ""
|
||||
rank_params: list[Any] = []
|
||||
if normalized_search:
|
||||
rank_sql = """
|
||||
CASE
|
||||
WHEN LOWER(CONCAT_WS(' ', c.first_name, c.last_name)) = LOWER(%s) THEN 0
|
||||
WHEN LOWER(CONCAT_WS(' ', c.last_name, c.first_name)) = LOWER(%s) THEN 1
|
||||
WHEN CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s THEN 2
|
||||
WHEN CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s THEN 3
|
||||
WHEN COALESCE(c.email, '') ILIKE %s THEN 4
|
||||
ELSE 5
|
||||
END
|
||||
"""
|
||||
rank_order_sql = f"{rank_sql},"
|
||||
rank_params = [normalized_search, normalized_search, f"{normalized_search}%", f"{normalized_search}%", f"{normalized_search}%"]
|
||||
|
||||
contacts_query = f"""
|
||||
SELECT
|
||||
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile,
|
||||
c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
|
||||
FROM contacts c
|
||||
{where_sql}
|
||||
ORDER BY c.last_name, c.first_name, c.id
|
||||
ORDER BY {rank_order_sql} c.last_name, c.first_name, c.id
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
contacts_params = list(params)
|
||||
contacts_params.extend(rank_params)
|
||||
contacts_params.extend([limit, offset])
|
||||
contacts = execute_query(contacts_query, tuple(contacts_params)) or []
|
||||
|
||||
|
||||
@ -116,6 +116,21 @@
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.contact-email-dropzone {
|
||||
border: 1px dashed rgba(15, 76, 117, 0.45);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 76, 117, 0.04);
|
||||
padding: 0.8rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, border-color .2s, transform .2s;
|
||||
}
|
||||
|
||||
.contact-email-dropzone.is-dragging {
|
||||
background: rgba(15, 76, 117, 0.12);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@ -150,6 +165,18 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="contactEmailDropzone" class="contact-email-dropzone mb-3" role="button" tabindex="0" aria-label="Analysér Outlook-mail for kontaktopdateringer">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<i class="bi bi-envelope-arrow-down fs-4 text-primary"></i>
|
||||
<div class="flex-grow-1">
|
||||
<div class="fw-semibold">Opdatér kontakt fra Outlook-mail</div>
|
||||
<div class="small text-muted">Træk en .msg- eller .eml-fil hertil, eller klik for at vælge. Intet ændres uden din godkendelse.</div>
|
||||
</div>
|
||||
<div id="contactEmailAnalyzeStatus" class="small text-muted"></div>
|
||||
</div>
|
||||
<input id="contactEmailFileInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
|
||||
</div>
|
||||
|
||||
<!-- Alert Notes Container -->
|
||||
<div id="alert-notes-container"></div>
|
||||
|
||||
@ -173,6 +200,16 @@
|
||||
<i class="bi bi-people"></i>Kontakter
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#cases">
|
||||
<i class="bi bi-list-check"></i>Sager
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#emails">
|
||||
<i class="bi bi-envelope"></i>E-mails
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#invoices">
|
||||
<i class="bi bi-receipt"></i>Fakturaer
|
||||
@ -343,6 +380,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="cases">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">Kontaktens sager</h5>
|
||||
<span id="contactCasesCount" class="badge bg-secondary">0</span>
|
||||
</div>
|
||||
<div id="contactCasesContainer" class="table-responsive"></div>
|
||||
<div id="contactCasesPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="emails">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h5 class="fw-bold mb-0">Kontaktens e-mails</h5>
|
||||
<span id="contactEmailsCount" class="badge bg-secondary">0</span>
|
||||
</div>
|
||||
<div id="contactEmailsContainer" class="table-responsive"></div>
|
||||
<div id="contactEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Invoices Tab -->
|
||||
<div class="tab-pane fade" id="invoices">
|
||||
<h5 class="fw-bold mb-4">Fakturaer</h5>
|
||||
@ -832,6 +887,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="contactEmailSuggestionsModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title">Foreslåede kontaktopdateringer</h5>
|
||||
<div id="contactEmailSuggestionMeta" class="small text-muted"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="contactEmailSuggestionsBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button type="button" class="btn btn-primary" id="applyContactEmailSuggestionsBtn" onclick="applyContactEmailSuggestions()">
|
||||
<i class="bi bi-check-lg me-1"></i>Godkend valgte
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
@ -849,10 +925,12 @@ let kontaktHistoryItems = [];
|
||||
let kontaktHistoryFilter = 'all';
|
||||
let mergeSourceContactId = null;
|
||||
let mergeSearchTimer = null;
|
||||
let pendingContactEmailSuggestions = [];
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadContact();
|
||||
loadCompaniesForSelect();
|
||||
initializeContactEmailDropzone();
|
||||
|
||||
const mergeSearch = document.getElementById('mergeContactSearch');
|
||||
if (mergeSearch) {
|
||||
@ -881,6 +959,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelector('a[href="#cases"]')?.addEventListener('shown.bs.tab', () => loadContactCases());
|
||||
document.querySelector('a[href="#emails"]')?.addEventListener('shown.bs.tab', () => loadContactEmails());
|
||||
|
||||
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
|
||||
if (subscriptionsTab) {
|
||||
subscriptionsTab.addEventListener('shown.bs.tab', () => {
|
||||
@ -938,6 +1019,150 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
});
|
||||
|
||||
function initializeContactEmailDropzone() {
|
||||
const zone = document.getElementById('contactEmailDropzone');
|
||||
const input = document.getElementById('contactEmailFileInput');
|
||||
if (!zone || !input) return;
|
||||
const choose = () => input.click();
|
||||
zone.addEventListener('click', choose);
|
||||
zone.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(); }
|
||||
});
|
||||
input.addEventListener('change', () => {
|
||||
if (input.files?.[0]) analyzeContactEmailFile(input.files[0]);
|
||||
input.value = '';
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(name => zone.addEventListener(name, event => {
|
||||
event.preventDefault();
|
||||
zone.classList.add('is-dragging');
|
||||
}));
|
||||
['dragleave', 'drop'].forEach(name => zone.addEventListener(name, event => {
|
||||
event.preventDefault();
|
||||
zone.classList.remove('is-dragging');
|
||||
}));
|
||||
zone.addEventListener('drop', event => {
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) analyzeContactEmailFile(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function analyzeContactEmailFile(file) {
|
||||
const status = document.getElementById('contactEmailAnalyzeStatus');
|
||||
const lower = String(file?.name || '').toLowerCase();
|
||||
if (!lower.endsWith('.msg') && !lower.endsWith('.eml')) {
|
||||
status.className = 'small text-danger';
|
||||
status.textContent = 'Vælg en .msg- eller .eml-fil';
|
||||
return;
|
||||
}
|
||||
status.className = 'small text-muted';
|
||||
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer…';
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}/analyze-email`, { method: 'POST', body: form });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
|
||||
pendingContactEmailSuggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
|
||||
renderContactEmailSuggestions(data);
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('contactEmailSuggestionsModal')).show();
|
||||
status.className = 'small text-success';
|
||||
status.textContent = `${pendingContactEmailSuggestions.length} forslag fundet`;
|
||||
} catch (error) {
|
||||
status.className = 'small text-danger';
|
||||
status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function renderContactEmailSuggestions(data) {
|
||||
const mail = data.mail || {};
|
||||
document.getElementById('contactEmailSuggestionMeta').textContent = [data.filename, mail.subject, mail.sender_email].filter(Boolean).join(' · ');
|
||||
const body = document.getElementById('contactEmailSuggestionsBody');
|
||||
const button = document.getElementById('applyContactEmailSuggestionsBtn');
|
||||
if (!pendingContactEmailSuggestions.length) {
|
||||
body.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-check-circle fs-2 d-block mb-2"></i>Ingen nye kontaktoplysninger fundet.</div>';
|
||||
button.disabled = true;
|
||||
return;
|
||||
}
|
||||
button.disabled = false;
|
||||
body.innerHTML = `<div class="table-responsive"><table class="table align-middle"><thead><tr><th></th><th>Felt</th><th>Nuværende</th><th>Foreslået</th><th>Kilde</th></tr></thead><tbody>${pendingContactEmailSuggestions.map((item, index) => `
|
||||
<tr><td><input class="form-check-input contact-email-suggestion-check" type="checkbox" data-index="${index}" checked></td><td class="fw-semibold">${escapeHtml(item.label)}</td><td class="text-muted">${escapeHtml(item.current || '—')}</td><td>${escapeHtml(item.suggested)}</td><td><span class="badge bg-light text-dark border">${escapeHtml(item.source)}</span></td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
async function applyContactEmailSuggestions() {
|
||||
const selected = Array.from(document.querySelectorAll('.contact-email-suggestion-check:checked'))
|
||||
.map(input => pendingContactEmailSuggestions[Number(input.dataset.index)])
|
||||
.filter(Boolean);
|
||||
if (!selected.length) return;
|
||||
const payload = Object.fromEntries(selected.map(item => [item.field, item.suggested]));
|
||||
const button = document.getElementById('applyContactEmailSuggestionsBtn');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.detail || 'Opdateringen kunne ikke gemmes');
|
||||
bootstrap.Modal.getInstance(document.getElementById('contactEmailSuggestionsModal'))?.hide();
|
||||
await loadContact();
|
||||
const status = document.getElementById('contactEmailAnalyzeStatus');
|
||||
status.className = 'small text-success';
|
||||
status.textContent = `${selected.length} felt(er) opdateret`;
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const contactRelatedPageSize = 25;
|
||||
let contactCasesOffset = 0;
|
||||
let contactEmailsOffset = 0;
|
||||
|
||||
function contactPagination(hostId, total, offset, loaderName) {
|
||||
const next = offset + contactRelatedPageSize;
|
||||
document.getElementById(hostId).innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${offset === 0 ? 'disabled' : ''} onclick="${loaderName}(${Math.max(0, offset - contactRelatedPageSize)})">Forrige</button>
|
||||
<span class="small text-muted">${total ? offset + 1 : 0}–${Math.min(next, total)} af ${total}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${next >= total ? 'disabled' : ''} onclick="${loaderName}(${next})">Næste</button>`;
|
||||
}
|
||||
|
||||
async function loadContactCases(offset = contactCasesOffset) {
|
||||
contactCasesOffset = Math.max(0, offset);
|
||||
const host = document.getElementById('contactCasesContainer');
|
||||
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}/cases?limit=${contactRelatedPageSize}&offset=${contactCasesOffset}`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente sager');
|
||||
const data = await response.json();
|
||||
document.getElementById('contactCasesCount').textContent = data.total;
|
||||
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Sag</th><th>Status</th><th>Kunde</th><th>Ansvarlig</th><th>Opdateret</th></tr></thead><tbody>${data.items.map(item => `
|
||||
<tr role="button" onclick="window.location.href='/sag/${item.id}'"><td><strong>#${item.id}</strong> ${escapeHtml(item.titel || '')}</td><td>${escapeHtml(item.status || '-')}</td><td>${escapeHtml(item.customer_name || '-')}</td><td>${escapeHtml(item.responsible_name || 'Ikke tildelt')}</td><td>${escapeHtml(item.updated_at ? new Date(item.updated_at).toLocaleString('da-DK') : '-')}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen direkte tilknyttede sager</div>';
|
||||
contactPagination('contactCasesPagination', data.total, contactCasesOffset, 'loadContactCases');
|
||||
} catch (error) {
|
||||
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
|
||||
document.getElementById('contactCasesPagination').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContactEmails(offset = contactEmailsOffset) {
|
||||
contactEmailsOffset = Math.max(0, offset);
|
||||
const host = document.getElementById('contactEmailsContainer');
|
||||
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}/emails?limit=${contactRelatedPageSize}&offset=${contactEmailsOffset}`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
|
||||
const data = await response.json();
|
||||
document.getElementById('contactEmailsCount').textContent = data.total;
|
||||
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
|
||||
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'"><td>${escapeHtml(email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-')}</td><td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td><td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td><td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td><td>${email.has_attachments ? '<i class="bi bi-paperclip"></i>' : ''}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen e-mails fundet for kontaktens adresse</div>';
|
||||
contactPagination('contactEmailsPagination', data.total, contactEmailsOffset, 'loadContactEmails');
|
||||
} catch (error) {
|
||||
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
|
||||
document.getElementById('contactEmailsPagination').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContact() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/contacts/${contactId}`);
|
||||
|
||||
@ -490,6 +490,24 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.create-mail-dropzone {
|
||||
min-width: 210px;
|
||||
border: 1px dashed rgba(15, 76, 117, 0.55);
|
||||
border-radius: 12px;
|
||||
background: rgba(15, 76, 117, 0.05);
|
||||
color: var(--accent);
|
||||
padding: 0.55rem 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, border-color .2s, transform .2s;
|
||||
}
|
||||
|
||||
.create-mail-dropzone:hover,
|
||||
.create-mail-dropzone.is-dragging {
|
||||
background: rgba(15, 76, 117, 0.14);
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.contacts-toolbar {
|
||||
width: 100%;
|
||||
@ -528,10 +546,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<div id="createFromEmailDropzone" class="create-mail-dropzone d-flex align-items-center gap-2" role="button" tabindex="0" title="Træk en mail direkte fra Outlook hertil">
|
||||
<i class="bi bi-envelope-arrow-down fs-5"></i>
|
||||
<div><div class="fw-semibold small">Træk Outlook-mail hertil</div><div class="text-muted" style="font-size:.7rem">eller klik for at vælge</div></div>
|
||||
</div>
|
||||
<input id="createFromEmailInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
|
||||
<button class="btn btn-primary" onclick="showCreateContactModal()">
|
||||
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4 d-flex gap-2 flex-wrap">
|
||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
|
||||
@ -632,6 +657,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create contact from Outlook email -->
|
||||
<div class="modal fade" id="createFromEmailModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title">Opret kontakt ud fra mail</h5>
|
||||
<div id="createFromEmailMeta" class="small text-muted"></div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="createFromEmailStatus" class="alert alert-info d-none"></div>
|
||||
<h6 class="fw-bold">Kontaktoplysninger</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6"><label class="form-label">Fornavn</label><input id="mailContactFirstName" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">Efternavn</label><input id="mailContactLastName" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">E-mail</label><input id="mailContactEmail" type="email" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">Mobil</label><input id="mailContactMobile" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">Telefon</label><input id="mailContactPhone" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">Titel</label><input id="mailContactTitle" class="form-control"></div>
|
||||
<div class="col-md-6"><label class="form-label">Afdeling</label><input id="mailContactDepartment" class="form-control"></div>
|
||||
</div>
|
||||
<div id="mailCompanySection" class="border rounded-3 p-3 d-none">
|
||||
<div class="form-check mb-3">
|
||||
<input id="mailUseCompany" class="form-check-input" type="checkbox" checked>
|
||||
<label id="mailUseCompanyLabel" class="form-check-label fw-semibold" for="mailUseCompany">Tilknyt virksomhed</label>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7"><label class="form-label">Virksomhedsnavn</label><input id="mailCompanyName" class="form-control"></div>
|
||||
<div class="col-md-5"><label class="form-label">CVR</label><input id="mailCompanyCvr" class="form-control" maxlength="8"></div>
|
||||
<div class="col-md-7"><label class="form-label">Adresse</label><input id="mailCompanyAddress" class="form-control"></div>
|
||||
<div class="col-md-2"><label class="form-label">Postnr.</label><input id="mailCompanyPostalCode" class="form-control"></div>
|
||||
<div class="col-md-3"><label class="form-label">By</label><input id="mailCompanyCity" class="form-control"></div>
|
||||
</div>
|
||||
<div id="mailCompanyHint" class="form-text mt-2"></div>
|
||||
</div>
|
||||
<div id="mailNoCompany" class="text-muted small border rounded-3 p-3">Intet CVR fundet. Der oprettes ikke en virksomhed automatisk.</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||
<button id="approveCreateFromEmailBtn" type="button" class="btn btn-primary" onclick="approveCreateFromEmail()">
|
||||
<i class="bi bi-check-lg me-1"></i>Godkend og opret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Contact Modal -->
|
||||
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@ -819,6 +893,7 @@ let selectedCompanyIds = new Set();
|
||||
let currentContactsData = [];
|
||||
let pendingCreateModalCustomerId = null;
|
||||
let pendingCreateReturnTo = null;
|
||||
let analyzedMailCompany = null;
|
||||
let currentSort = {
|
||||
key: 'name',
|
||||
direction: 'asc'
|
||||
@ -832,6 +907,12 @@ let visibleColumns = {
|
||||
|
||||
// Load contacts on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initializeCreateFromEmailDropzone();
|
||||
document.getElementById('createFromEmailInput')?.addEventListener('change', event => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) analyzeEmailForNewContact(file);
|
||||
event.target.value = '';
|
||||
});
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const preselectedCustomerId = Number(urlParams.get('customer_id'));
|
||||
const shouldOpenCreateModal = urlParams.get('create') === '1';
|
||||
@ -918,6 +999,42 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function initializeCreateFromEmailDropzone() {
|
||||
const zone = document.getElementById('createFromEmailDropzone');
|
||||
const input = document.getElementById('createFromEmailInput');
|
||||
if (!zone || !input) return;
|
||||
const chooseFile = () => input.click();
|
||||
zone.addEventListener('click', chooseFile);
|
||||
zone.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
chooseFile();
|
||||
}
|
||||
});
|
||||
['dragenter', 'dragover'].forEach(type => zone.addEventListener(type, event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
|
||||
zone.classList.add('is-dragging');
|
||||
}));
|
||||
['dragleave', 'dragend'].forEach(type => zone.addEventListener(type, event => {
|
||||
event.preventDefault();
|
||||
zone.classList.remove('is-dragging');
|
||||
}));
|
||||
zone.addEventListener('drop', event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zone.classList.remove('is-dragging');
|
||||
const file = event.dataTransfer?.files?.[0]
|
||||
|| Array.from(event.dataTransfer?.items || []).find(item => item.kind === 'file')?.getAsFile();
|
||||
if (!file) {
|
||||
alert('Outlook afleverede ikke mailen som en fil. Prøv at gemme eller trække den som .msg/.eml, eller klik på boksen.');
|
||||
return;
|
||||
}
|
||||
analyzeEmailForNewContact(file);
|
||||
});
|
||||
}
|
||||
|
||||
function setFilter(filter) {
|
||||
currentFilter = filter;
|
||||
currentPage = 0;
|
||||
@ -1425,6 +1542,105 @@ function renderSelectedCompanies() {
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function analyzeEmailForNewContact(file) {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const zone = document.getElementById('createFromEmailDropzone');
|
||||
const original = zone?.innerHTML;
|
||||
if (zone) { zone.style.pointerEvents = 'none'; zone.innerHTML = '<span class="spinner-border spinner-border-sm"></span><span class="small fw-semibold">Analyserer…</span>'; }
|
||||
try {
|
||||
const response = await fetch('/api/v1/contacts/analyze-email', { method: 'POST', body: form });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
|
||||
const contact = data.contact || {};
|
||||
const values = {
|
||||
mailContactFirstName: contact.first_name,
|
||||
mailContactLastName: contact.last_name,
|
||||
mailContactEmail: contact.email,
|
||||
mailContactMobile: contact.mobile,
|
||||
mailContactPhone: contact.phone,
|
||||
mailContactTitle: contact.title,
|
||||
mailContactDepartment: contact.department,
|
||||
mailCompanyName: data.company?.existing_name || data.company?.name,
|
||||
mailCompanyCvr: data.company?.cvr_number,
|
||||
mailCompanyAddress: data.company?.address,
|
||||
mailCompanyPostalCode: data.company?.postal_code,
|
||||
mailCompanyCity: data.company?.city,
|
||||
};
|
||||
Object.entries(values).forEach(([id, value]) => { document.getElementById(id).value = value || ''; });
|
||||
analyzedMailCompany = data.company || {};
|
||||
document.getElementById('createFromEmailMeta').textContent = [data.filename, data.mail?.subject, data.mail?.sender_email].filter(Boolean).join(' · ');
|
||||
const hasCvr = !!data.company?.cvr_number;
|
||||
document.getElementById('mailCompanySection').classList.toggle('d-none', !hasCvr);
|
||||
document.getElementById('mailNoCompany').classList.toggle('d-none', hasCvr);
|
||||
if (hasCvr) {
|
||||
const existing = !!data.company.existing_id;
|
||||
document.getElementById('mailUseCompanyLabel').textContent = existing ? 'Tilknyt eksisterende virksomhed' : 'Opret og tilknyt ny virksomhed';
|
||||
document.getElementById('mailCompanyHint').textContent = existing
|
||||
? `CVR findes allerede som ${data.company.existing_name}. Der oprettes ikke en dublet.`
|
||||
: data.company.lookup_found
|
||||
? `Virksomhedsdata er hentet fra CVR-opslaget (${data.company.source || 'CVR-registeret'}). Kontrollér oplysningerne før oprettelse.`
|
||||
: 'CVR findes ikke i systemet, men kunne ikke valideres i CVR-opslaget. Kontrollér oplysningerne før oprettelse.';
|
||||
document.getElementById('mailCompanyName').readOnly = existing;
|
||||
document.getElementById('mailCompanyCvr').readOnly = existing;
|
||||
document.getElementById('mailUseCompany').checked = true;
|
||||
}
|
||||
document.getElementById('createFromEmailStatus').classList.add('d-none');
|
||||
bootstrap.Modal.getOrCreateInstance(document.getElementById('createFromEmailModal')).show();
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
} finally {
|
||||
if (zone) { zone.style.pointerEvents = ''; zone.innerHTML = original; }
|
||||
}
|
||||
}
|
||||
|
||||
async function approveCreateFromEmail() {
|
||||
const value = id => document.getElementById(id).value.trim() || null;
|
||||
const contactData = {
|
||||
first_name: value('mailContactFirstName'), last_name: value('mailContactLastName') || '',
|
||||
email: value('mailContactEmail'), mobile: value('mailContactMobile'), phone: value('mailContactPhone'),
|
||||
title: value('mailContactTitle'), department: value('mailContactDepartment'), is_active: true,
|
||||
};
|
||||
if (!contactData.first_name) { alert('Fornavn skal udfyldes'); return; }
|
||||
const button = document.getElementById('approveCreateFromEmailBtn');
|
||||
const status = document.getElementById('createFromEmailStatus');
|
||||
button.disabled = true;
|
||||
status.className = 'alert alert-info';
|
||||
status.textContent = 'Opretter…';
|
||||
try {
|
||||
let companyId = document.getElementById('mailUseCompany').checked ? analyzedMailCompany?.existing_id : null;
|
||||
if (document.getElementById('mailUseCompany').checked && analyzedMailCompany?.cvr_number && !companyId) {
|
||||
const companyName = value('mailCompanyName');
|
||||
const cvrNumber = (value('mailCompanyCvr') || '').replace(/\D/g, '');
|
||||
if (!companyName || cvrNumber.length !== 8) throw new Error('Virksomhedsnavn og et gyldigt CVR på 8 cifre skal udfyldes');
|
||||
const companyResponse = await fetch('/api/v1/contacts/resolve-email-company', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: companyName, cvr_number: cvrNumber,
|
||||
email: analyzedMailCompany?.email || contactData.email,
|
||||
phone: analyzedMailCompany?.phone || null,
|
||||
address: value('mailCompanyAddress'), postal_code: value('mailCompanyPostalCode'),
|
||||
city: value('mailCompanyCity'), website: analyzedMailCompany?.website || null
|
||||
})
|
||||
});
|
||||
const company = await companyResponse.json().catch(() => ({}));
|
||||
if (!companyResponse.ok) throw new Error(company.detail || 'Virksomheden kunne ikke oprettes');
|
||||
companyId = company.id;
|
||||
}
|
||||
if (companyId) { contactData.company_ids = [Number(companyId)]; contactData.is_primary = true; }
|
||||
const response = await fetch('/api/v1/contacts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contactData)
|
||||
});
|
||||
const contact = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(contact.detail || 'Kontakten kunne ikke oprettes');
|
||||
window.location.href = `/contacts/${contact.id}`;
|
||||
} catch (error) {
|
||||
status.className = 'alert alert-danger';
|
||||
status.textContent = error.message;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateContactModal() {
|
||||
// Reset form
|
||||
document.getElementById('createContactForm').reset();
|
||||
|
||||
@ -15,6 +15,21 @@ class Settings(BaseSettings):
|
||||
# Database
|
||||
DATABASE_URL: str = "postgresql://bmc_hub:bmc_hub@localhost:5432/bmc_hub"
|
||||
|
||||
# Public website content administration API (HTTPS on Plesk)
|
||||
WEBSITE_CONTENT_API_URL: str = "https://isp.bmcnetworks.dk/api/admin-content.php"
|
||||
WEBSITE_CONTENT_API_HOST_HEADER: str = "ct.bmcnetworks.dk"
|
||||
WEBSITE_CONTENT_API_TOKEN: str = ""
|
||||
WEBSITE_CONTENT_API_TIMEOUT_SECONDS: int = 20
|
||||
# Deprecated direct-MySQL settings retained temporarily for .env compatibility.
|
||||
WEBSITE_CONTENT_MYSQL_HOST: str = ""
|
||||
WEBSITE_CONTENT_MYSQL_PORT: int = 3306
|
||||
WEBSITE_CONTENT_MYSQL_DATABASE: str = ""
|
||||
WEBSITE_CONTENT_MYSQL_USER: str = ""
|
||||
WEBSITE_CONTENT_MYSQL_PASSWORD: str = ""
|
||||
WEBSITE_CONTENT_MYSQL_POOL_SIZE: int = 5
|
||||
WEBSITE_CONTENT_MYSQL_CONNECT_TIMEOUT: int = 8
|
||||
WEBSITE_CONTENT_LOGO_BASE_URL: str = ""
|
||||
|
||||
# API
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8000
|
||||
@ -22,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
|
||||
@ -104,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
|
||||
@ -210,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
|
||||
@ -326,6 +350,24 @@ class Settings(BaseSettings):
|
||||
FEDEX_BASE_URL: str = ""
|
||||
FEDEX_TIMEOUT_SECONDS: int = 20
|
||||
|
||||
# Shipmondo Integration
|
||||
SHIPMONDO_ENABLED: bool = False
|
||||
SHIPMONDO_READ_ONLY: bool = True
|
||||
SHIPMONDO_DRY_RUN: bool = True
|
||||
SHIPMONDO_API_BASE_URL: str = "https://app.shipmondo.com/api/public/v3"
|
||||
SHIPMONDO_API_USER: str = ""
|
||||
SHIPMONDO_API_KEY: str = ""
|
||||
SHIPMONDO_TIMEOUT_SECONDS: int = 30
|
||||
SHIPMONDO_SENDER_NAME: str = "BMC Networks"
|
||||
SHIPMONDO_SENDER_ATTENTION: str = ""
|
||||
SHIPMONDO_SENDER_ADDRESS1: str = ""
|
||||
SHIPMONDO_SENDER_ADDRESS2: str = ""
|
||||
SHIPMONDO_SENDER_POSTAL_CODE: str = ""
|
||||
SHIPMONDO_SENDER_CITY: str = ""
|
||||
SHIPMONDO_SENDER_COUNTRY_CODE: str = "DK"
|
||||
SHIPMONDO_SENDER_EMAIL: str = ""
|
||||
SHIPMONDO_SENDER_PHONE: str = ""
|
||||
|
||||
# ALSO Cloud Marketplace Integration
|
||||
ALSO_ENABLED: bool = False
|
||||
ALSO_READ_ONLY: 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',
|
||||
)
|
||||
@ -10,6 +10,7 @@ from pydantic import BaseModel
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import re
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert
|
||||
@ -111,6 +112,7 @@ class CustomerBase(BaseModel):
|
||||
is_active: Optional[bool] = True
|
||||
invoice_email: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
supplier_service_enrolled: Optional[bool] = False
|
||||
|
||||
|
||||
class CustomerCreate(CustomerBase):
|
||||
@ -133,6 +135,7 @@ class CustomerUpdate(BaseModel):
|
||||
invoice_email: Optional[str] = None
|
||||
mobile_phone: Optional[str] = None
|
||||
department: Optional[str] = None
|
||||
supplier_service_enrolled: Optional[bool] = None
|
||||
|
||||
|
||||
class EmailDomainCreate(BaseModel):
|
||||
@ -580,6 +583,76 @@ async def get_customer(customer_id: int):
|
||||
}
|
||||
|
||||
|
||||
def _email_address_pattern(address: str) -> str:
|
||||
"""PostgreSQL regex matching one exact address inside common address lists."""
|
||||
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
|
||||
|
||||
|
||||
def _email_domain_pattern(domain: str) -> str:
|
||||
"""PostgreSQL regex matching an address at exactly this domain."""
|
||||
return rf"(^|[^a-z0-9._%+-])[a-z0-9._%+-]+@{re.escape(domain.lower())}([^a-z0-9.-]|$)"
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/emails")
|
||||
async def get_customer_emails(
|
||||
customer_id: int,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
):
|
||||
"""List emails safely associated with a customer without mutating links."""
|
||||
customer = execute_query_single("SELECT id, email FROM customers WHERE id = %s", (customer_id,))
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
|
||||
contact_rows = execute_query(
|
||||
"""
|
||||
SELECT DISTINCT LOWER(TRIM(c.email)) AS email
|
||||
FROM contacts c
|
||||
JOIN contact_companies cc ON cc.contact_id = c.id
|
||||
WHERE cc.customer_id = %s AND NULLIF(TRIM(c.email), '') IS NOT NULL
|
||||
""",
|
||||
(customer_id,),
|
||||
) or []
|
||||
domain_rows = execute_query(
|
||||
"SELECT LOWER(TRIM(domain)) AS domain FROM email_domain_customer_mappings WHERE customer_id = %s",
|
||||
(customer_id,),
|
||||
) or []
|
||||
|
||||
addresses = {str(row["email"]).lower() for row in contact_rows if row.get("email")}
|
||||
if customer.get("email"):
|
||||
addresses.add(str(customer["email"]).strip().lower())
|
||||
domains = {str(row["domain"]).lower() for row in domain_rows if row.get("domain")}
|
||||
|
||||
haystack = "LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc))"
|
||||
match_clauses = ["em.customer_id = %s"]
|
||||
match_params: list[Any] = [customer_id]
|
||||
for address in sorted(addresses):
|
||||
match_clauses.append(f"{haystack} ~ %s")
|
||||
match_params.append(_email_address_pattern(address))
|
||||
for domain in sorted(domains):
|
||||
match_clauses.append(f"{haystack} ~ %s")
|
||||
match_params.append(_email_domain_pattern(domain))
|
||||
|
||||
predicate = f"em.deleted_at IS NULL AND ({' OR '.join(match_clauses)})"
|
||||
total_row = execute_query_single(
|
||||
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", tuple(match_params)
|
||||
)
|
||||
rows = execute_query(
|
||||
f"""
|
||||
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
|
||||
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
|
||||
em.linked_case_id, s.titel AS linked_case_title
|
||||
FROM email_messages em
|
||||
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
|
||||
WHERE {predicate}
|
||||
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple([*match_params, limit, offset]),
|
||||
) or []
|
||||
return {"items": rows, "total": int((total_row or {}).get("count") or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
|
||||
@router.get("/customers/{customer_id}/utility-company")
|
||||
async def get_customer_utility_company(customer_id: int):
|
||||
@ -739,8 +812,8 @@ async def create_customer(customer: CustomerCreate):
|
||||
customer_id = execute_insert(
|
||||
"""INSERT INTO customers
|
||||
(name, cvr_number, email, email_domain, phone, address, city, postal_code,
|
||||
country, website, is_active, invoice_email, mobile_phone)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
country, website, is_active, invoice_email, mobile_phone, supplier_service_enrolled)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id""",
|
||||
(
|
||||
customer.name,
|
||||
@ -755,7 +828,8 @@ async def create_customer(customer: CustomerCreate):
|
||||
customer.website,
|
||||
customer.is_active,
|
||||
customer.invoice_email,
|
||||
customer.mobile_phone
|
||||
customer.mobile_phone,
|
||||
customer.supplier_service_enrolled
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -884,9 +884,14 @@
|
||||
<i class="bi bi-list-check"></i>Sager
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#emails">
|
||||
<i class="bi bi-envelope"></i>E-mails
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#kontakt">
|
||||
<i class="bi bi-chat-left-text"></i>Kontakt
|
||||
<i class="bi bi-chat-left-text"></i>Kommunikation
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
@ -1047,6 +1052,13 @@
|
||||
<span class="info-label">Spærret</span>
|
||||
<span class="info-value" id="barred">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<label class="info-label form-check-label" for="supplierServiceEnrolled">Benytter leverandørservice</label>
|
||||
<div class="text-end">
|
||||
<input class="form-check-input" type="checkbox" id="supplierServiceEnrolled" onchange="saveSupplierServiceEnrollment(this)">
|
||||
<div id="supplierServiceStatus" class="small text-muted mt-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1216,11 +1228,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kontakt Tab -->
|
||||
<!-- Emails Tab -->
|
||||
<div class="tab-pane fade" id="emails">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h5 class="fw-bold mb-0">Kundens e-mails</h5>
|
||||
<small class="text-muted">Koblede mails og sikre adresse-/domænematch</small>
|
||||
</div>
|
||||
<span id="customerEmailsCount" class="badge bg-secondary">0</span>
|
||||
</div>
|
||||
<div id="customerEmailsContainer" class="table-responsive"></div>
|
||||
<div id="customerEmailsPagination" class="d-flex justify-content-between align-items-center mt-3"></div>
|
||||
</div>
|
||||
|
||||
<!-- Kommunikation Tab -->
|
||||
<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>
|
||||
@ -2296,6 +2324,8 @@ let customerKontaktFilter = 'all';
|
||||
let customerInvoicesLoaded = false;
|
||||
let customerInvoicesData = [];
|
||||
let customerInvoiceSearchTerm = '';
|
||||
let customerEmailsOffset = 0;
|
||||
const customerEmailsLimit = 25;
|
||||
|
||||
let eventListenersAdded = false;
|
||||
|
||||
@ -2327,6 +2357,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, { once: false });
|
||||
}
|
||||
|
||||
const emailsTab = document.querySelector('a[href="#emails"]');
|
||||
if (emailsTab) {
|
||||
emailsTab.addEventListener('shown.bs.tab', () => loadCustomerEmails(), { once: false });
|
||||
}
|
||||
|
||||
const kontaktTab = document.querySelector('a[href="#kontakt"]');
|
||||
if (kontaktTab) {
|
||||
kontaktTab.addEventListener('shown.bs.tab', () => {
|
||||
@ -2730,6 +2765,7 @@ function displayCustomer(customer) {
|
||||
document.getElementById('barred').innerHTML = customer.barred
|
||||
? '<span class="badge bg-danger">Ja</span>'
|
||||
: '<span class="badge bg-success">Nej</span>';
|
||||
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
|
||||
|
||||
// Integration
|
||||
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
|
||||
@ -2745,6 +2781,68 @@ function displayCustomer(customer) {
|
||||
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container');
|
||||
}
|
||||
|
||||
async function saveSupplierServiceEnrollment(checkbox) {
|
||||
const previous = !checkbox.checked;
|
||||
const status = document.getElementById('supplierServiceStatus');
|
||||
checkbox.disabled = true;
|
||||
status.className = 'small text-muted mt-1';
|
||||
status.textContent = 'Gemmer…';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ supplier_service_enrolled: checkbox.checked })
|
||||
});
|
||||
if (!response.ok) throw new Error('Kunne ikke gemme ændringen');
|
||||
customerData.supplier_service_enrolled = checkbox.checked;
|
||||
status.className = 'small text-success mt-1';
|
||||
status.textContent = 'Gemt';
|
||||
} catch (error) {
|
||||
checkbox.checked = previous;
|
||||
status.className = 'small text-danger mt-1';
|
||||
status.textContent = error.message;
|
||||
} finally {
|
||||
checkbox.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCrmDate(value) {
|
||||
return value ? new Date(value).toLocaleString('da-DK') : '-';
|
||||
}
|
||||
|
||||
async function loadCustomerEmails(offset = customerEmailsOffset) {
|
||||
customerEmailsOffset = Math.max(0, offset);
|
||||
const container = document.getElementById('customerEmailsContainer');
|
||||
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
|
||||
try {
|
||||
const response = await fetch(`/api/v1/customers/${customerId}/emails?limit=${customerEmailsLimit}&offset=${customerEmailsOffset}`);
|
||||
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
|
||||
const data = await response.json();
|
||||
document.getElementById('customerEmailsCount').textContent = data.total;
|
||||
if (!data.items.length) {
|
||||
container.innerHTML = '<div class="text-center text-muted py-5">Ingen e-mails fundet</div>';
|
||||
} else {
|
||||
container.innerHTML = `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
|
||||
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'">
|
||||
<td>${escapeHtml(formatCrmDate(email.received_date))}</td>
|
||||
<td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td>
|
||||
<td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td>
|
||||
<td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td>
|
||||
<td>${email.has_attachments ? `<i class="bi bi-paperclip" title="${email.attachment_count || 1} vedhæftning(er)"></i>` : ''}</td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
const prev = Math.max(0, customerEmailsOffset - customerEmailsLimit);
|
||||
const next = customerEmailsOffset + customerEmailsLimit;
|
||||
document.getElementById('customerEmailsPagination').innerHTML = `
|
||||
<button class="btn btn-sm btn-outline-secondary" ${customerEmailsOffset === 0 ? 'disabled' : ''} onclick="loadCustomerEmails(${prev})">Forrige</button>
|
||||
<span class="small text-muted">${data.total ? customerEmailsOffset + 1 : 0}–${Math.min(next, data.total)} af ${data.total}</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" ${next >= data.total ? 'disabled' : ''} onclick="loadCustomerEmails(${next})">Næste</button>`;
|
||||
} catch (error) {
|
||||
container.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
|
||||
document.getElementById('customerEmailsPagination').innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderCustomerCallNumber(number) {
|
||||
const clean = normalizePhoneValue(number);
|
||||
if (!clean) return '-';
|
||||
|
||||
@ -48,6 +48,41 @@ class BulkSendRequest(BaseModel):
|
||||
ids: List[int] = Field(..., min_length=1)
|
||||
|
||||
|
||||
class SettlementRequest(BaseModel):
|
||||
"""Validate or complete settlement of selected time entries.
|
||||
|
||||
An explicit method deliberately applies to every selected line. Without it,
|
||||
each line keeps its own selected method.
|
||||
"""
|
||||
|
||||
ids: List[int] = Field(..., min_length=1)
|
||||
billing_method: Optional[str] = None
|
||||
prepaid_card_id: Optional[int] = Field(None, gt=0)
|
||||
fixed_price_agreement_id: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
VALID_SETTLEMENT_METHODS = {"invoice", "prepaid", "subscription", "internal", "non_billable"}
|
||||
|
||||
|
||||
def _normalise_billing_method(value: Optional[str]) -> str:
|
||||
method = str(value or "invoice").strip().lower()
|
||||
if method in {"prepaid_card", "clippekort"}:
|
||||
return "prepaid"
|
||||
if method in {"fixed_price", "abonnement"}:
|
||||
return "subscription"
|
||||
return method
|
||||
|
||||
|
||||
def _settlement_label(method: str) -> str:
|
||||
return {
|
||||
"invoice": "Faktura",
|
||||
"prepaid": "Klippekort",
|
||||
"subscription": "Abonnement / fast pris",
|
||||
"internal": "Intern tid",
|
||||
"non_billable": "Ikke-fakturerbar tid",
|
||||
}.get(method, method)
|
||||
|
||||
|
||||
def _ensure_ids(ids: List[int]) -> List[int]:
|
||||
clean = sorted(set(int(i) for i in ids if int(i) > 0))
|
||||
if not clean:
|
||||
@ -55,6 +90,22 @@ def _ensure_ids(ids: List[int]) -> List[int]:
|
||||
return clean
|
||||
|
||||
|
||||
def _hours_for_prepaid_card(row: Dict[str, Any], rounding_minutes: int) -> float:
|
||||
"""Calculate a card debit per registration using the card's own rounding.
|
||||
|
||||
Rounding a combined total undercharges short registrations. The actual
|
||||
duration is therefore rounded individually before the hours are added.
|
||||
"""
|
||||
actual_minutes = row.get("faktisk_tid_min")
|
||||
if actual_minutes is None:
|
||||
actual_minutes = round(float(row.get("original_hours") or 0) * 60)
|
||||
actual_minutes = max(0, int(actual_minutes or 0))
|
||||
if actual_minutes == 0:
|
||||
return 0.0
|
||||
block = max(1, int(rounding_minutes or row.get("round_block_min") or 30))
|
||||
return ((actual_minutes + block - 1) // block * block) / 60.0
|
||||
|
||||
|
||||
@router.get("/time-queue")
|
||||
async def list_hub_time_queue(
|
||||
customer_id: Optional[int] = Query(None, gt=0),
|
||||
@ -68,12 +119,15 @@ async def list_hub_time_queue(
|
||||
conditions = [
|
||||
"t.vtiger_id IS NULL",
|
||||
"t.billed_via_thehub_id IS NULL",
|
||||
"t.economy_order_draft_id IS NULL",
|
||||
"t.status <> 'billed'",
|
||||
]
|
||||
params: List[Any] = []
|
||||
|
||||
# A time entry may retain an old customer_id after a case has been
|
||||
# reassigned. The case is the source of truth whenever it has a customer.
|
||||
if customer_id is not None:
|
||||
conditions.append("t.customer_id = %s")
|
||||
conditions.append("COALESCE(s.customer_id, effective_customer.hub_customer_id) = %s")
|
||||
params.append(customer_id)
|
||||
|
||||
if status:
|
||||
@ -88,7 +142,7 @@ async def list_hub_time_queue(
|
||||
conditions.append(
|
||||
"("
|
||||
"COALESCE(t.description, '') ILIKE %s OR "
|
||||
"COALESCE(cust.name, '') ILIKE %s OR "
|
||||
"COALESCE(case_customer.name, effective_customer.name, '') ILIKE %s OR "
|
||||
"COALESCE(c.title, s.titel, '') ILIKE %s"
|
||||
")"
|
||||
)
|
||||
@ -100,8 +154,9 @@ async def list_hub_time_queue(
|
||||
query = f"""
|
||||
SELECT
|
||||
t.id,
|
||||
t.customer_id,
|
||||
cust.name AS customer_name,
|
||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
|
||||
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
|
||||
t.customer_id AS recorded_customer_id,
|
||||
t.status,
|
||||
t.entry_status,
|
||||
t.billable,
|
||||
@ -110,20 +165,47 @@ async def list_hub_time_queue(
|
||||
t.fixed_price_agreement_id,
|
||||
t.original_hours,
|
||||
t.approved_hours,
|
||||
t.faktisk_tid_min,
|
||||
t.fakturerbar_tid_min,
|
||||
t.round_block_min,
|
||||
t.rounded_to,
|
||||
t.worked_date,
|
||||
t.description,
|
||||
t.entry_type,
|
||||
t.work_type,
|
||||
t.kilde,
|
||||
t.case_id,
|
||||
t.sag_id,
|
||||
COALESCE(c.title, s.titel, 'No title') AS case_title,
|
||||
COALESCE(c.title, s.titel, 'Ingen sagstitel') AS case_title,
|
||||
s.status AS case_status,
|
||||
s.customer_id AS hub_customer_id,
|
||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id,
|
||||
COALESCE(NULLIF(u.full_name, ''), NULLIF(u.username, ''), NULLIF(t.user_name, ''), 'Ukendt medarbejder') AS employee_name,
|
||||
CONCAT_WS(' ', NULLIF(cont.first_name, ''), NULLIF(cont.last_name, '')) AS contact_name,
|
||||
t.created_at,
|
||||
t.updated_at
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
|
||||
LEFT JOIN tmodule_cases c ON c.id = t.case_id
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tc.id
|
||||
FROM tmodule_customers tc
|
||||
WHERE tc.hub_customer_id = s.customer_id
|
||||
ORDER BY tc.id ASC
|
||||
LIMIT 1
|
||||
) sag_customer ON s.customer_id IS NOT NULL
|
||||
LEFT JOIN tmodule_customers effective_customer
|
||||
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
|
||||
LEFT JOIN users u ON u.user_id = t.medarbejder_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT sk.contact_id
|
||||
FROM sag_kontakter sk
|
||||
WHERE sk.sag_id = s.id AND sk.deleted_at IS NULL
|
||||
ORDER BY sk.is_primary DESC NULLS LAST, sk.id ASC
|
||||
LIMIT 1
|
||||
) primary_contact ON TRUE
|
||||
LEFT JOIN contacts cont ON cont.id = primary_contact.contact_id
|
||||
WHERE {where_sql}
|
||||
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
|
||||
LIMIT %s
|
||||
@ -146,17 +228,28 @@ async def list_time_queue_customers():
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT
|
||||
t.customer_id,
|
||||
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name,
|
||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
|
||||
COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) AS customer_name,
|
||||
COUNT(*)::int AS open_count
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id
|
||||
WHERE t.customer_id IS NOT NULL
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tc.id
|
||||
FROM tmodule_customers tc
|
||||
WHERE tc.hub_customer_id = s.customer_id
|
||||
ORDER BY tc.id ASC
|
||||
LIMIT 1
|
||||
) sag_customer ON s.customer_id IS NOT NULL
|
||||
LEFT JOIN tmodule_customers effective_customer
|
||||
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
|
||||
WHERE COALESCE(s.customer_id, effective_customer.hub_customer_id) IS NOT NULL
|
||||
AND t.vtiger_id IS NULL
|
||||
AND t.billed_via_thehub_id IS NULL
|
||||
AND t.economy_order_draft_id IS NULL
|
||||
AND t.status = 'pending'
|
||||
GROUP BY t.customer_id, cust.name
|
||||
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC
|
||||
GROUP BY COALESCE(s.customer_id, effective_customer.hub_customer_id), case_customer.name, effective_customer.name
|
||||
ORDER BY COALESCE(case_customer.name, effective_customer.name, CONCAT('Kunde #', COALESCE(s.customer_id, effective_customer.hub_customer_id)::text)) ASC
|
||||
"""
|
||||
)
|
||||
return {"items": rows, "count": len(rows)}
|
||||
@ -166,15 +259,24 @@ async def list_time_queue_customers():
|
||||
|
||||
|
||||
@router.get("/time-queue/prepaid-cards")
|
||||
async def list_prepaid_cards():
|
||||
async def list_prepaid_cards(customer_id: Optional[int] = Query(None, gt=0)):
|
||||
try:
|
||||
where_sql = "WHERE status IN ('active', 'depleted')"
|
||||
params: List[Any] = []
|
||||
if customer_id is not None:
|
||||
# Prepaid cards belong to Hub customers (not the historic
|
||||
# tmodule_customers record stored on a time entry).
|
||||
where_sql += " AND customer_id = %s"
|
||||
params.append(customer_id)
|
||||
cards = execute_query(
|
||||
"""
|
||||
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at
|
||||
f"""
|
||||
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours,
|
||||
remaining_hours, rounding_minutes, status, expires_at
|
||||
FROM tticket_prepaid_cards
|
||||
WHERE status IN ('active', 'depleted')
|
||||
{where_sql}
|
||||
ORDER BY remaining_hours DESC, id DESC
|
||||
"""
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
return {"items": cards, "count": len(cards)}
|
||||
except Exception as e:
|
||||
@ -218,6 +320,7 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest):
|
||||
WHERE id IN ({placeholders})
|
||||
AND vtiger_id IS NULL
|
||||
AND billed_via_thehub_id IS NULL
|
||||
AND economy_order_draft_id IS NULL
|
||||
AND status <> 'billed'
|
||||
"""
|
||||
execute_update(query, tuple(values + ids))
|
||||
@ -244,6 +347,7 @@ async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
|
||||
WHERE id IN ({placeholders})
|
||||
AND vtiger_id IS NULL
|
||||
AND billed_via_thehub_id IS NULL
|
||||
AND economy_order_draft_id IS NULL
|
||||
AND status <> 'billed'
|
||||
""",
|
||||
tuple([reason] + ids),
|
||||
@ -283,6 +387,7 @@ async def bulk_approve_time_queue(payload: BulkApproveRequest):
|
||||
WHERE id IN ({placeholders})
|
||||
AND vtiger_id IS NULL
|
||||
AND billed_via_thehub_id IS NULL
|
||||
AND economy_order_draft_id IS NULL
|
||||
AND status <> 'billed'
|
||||
"""
|
||||
execute_update(query, tuple(params + ids))
|
||||
@ -315,6 +420,7 @@ async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
|
||||
WHERE id IN ({placeholders})
|
||||
AND vtiger_id IS NULL
|
||||
AND billed_via_thehub_id IS NULL
|
||||
AND economy_order_draft_id IS NULL
|
||||
AND status <> 'billed'
|
||||
""",
|
||||
tuple([payload.prepaid_card_id] + ids),
|
||||
@ -432,32 +538,26 @@ def _create_order_from_selected(customer_id: int, rows: List[Dict[str, Any]], us
|
||||
return int(order_id)
|
||||
|
||||
|
||||
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||
customer = execute_query_single(
|
||||
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
|
||||
(customer_id,),
|
||||
)
|
||||
if not customer:
|
||||
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
|
||||
def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
|
||||
"""Create an order draft for the actual Hub customer on the case.
|
||||
|
||||
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||
hub_customer_id = customer.get("hub_customer_id")
|
||||
|
||||
hub_customer = None
|
||||
if hub_customer_id:
|
||||
`tmodule_customers` is legacy tracking data and can contain stale names or
|
||||
outdated mappings. It must never decide which legal customer is invoiced.
|
||||
"""
|
||||
hub_customer = execute_query_single(
|
||||
"""
|
||||
SELECT
|
||||
standard_hourly_rate,
|
||||
standard_margin_percent,
|
||||
special_freight_price,
|
||||
supplier_service_enrolled,
|
||||
invoice_fee_amount
|
||||
SELECT id, name, standard_hourly_rate, standard_margin_percent,
|
||||
special_freight_price, supplier_service_enrolled, invoice_fee_amount
|
||||
FROM customers
|
||||
WHERE id = %s
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
if not hub_customer:
|
||||
raise HTTPException(status_code=404, detail=f"Kunden på sagen ({hub_customer_id}) findes ikke")
|
||||
|
||||
customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}"
|
||||
hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||||
|
||||
invoice_fee_amount = Decimal(
|
||||
str(
|
||||
@ -529,8 +629,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(amount),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"customer_id": int(hub_customer_id),
|
||||
"customer_name": customer_name,
|
||||
"sag_id": group["sag_id"],
|
||||
"time_entry_ids": ids,
|
||||
"time_date": str(latest_date) if latest_date else None,
|
||||
@ -544,7 +644,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
if special_freight_amount > 0:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"freight:{hub_customer_id or customer_id}",
|
||||
"line_key": f"freight:{hub_customer_id}",
|
||||
"source_type": "freight",
|
||||
"source_id": None,
|
||||
"description": "Særlig fragtpris",
|
||||
@ -555,8 +655,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(special_freight_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"customer_id": int(hub_customer_id),
|
||||
"customer_name": customer_name,
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
@ -567,7 +667,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
if invoice_fee_amount > 0 and not supplier_service_enrolled:
|
||||
line_payloads.append(
|
||||
{
|
||||
"line_key": f"invoice_fee:{hub_customer_id or customer_id}",
|
||||
"line_key": f"invoice_fee:{hub_customer_id}",
|
||||
"source_type": "invoice_fee",
|
||||
"source_id": None,
|
||||
"description": "Faktureringsgebyr",
|
||||
@ -578,8 +678,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
"product_id": None,
|
||||
"selected": True,
|
||||
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
|
||||
"customer_id": int(hub_customer_id) if hub_customer_id else None,
|
||||
"customer_name": customer.get("name") or f"Kunde {customer_id}",
|
||||
"customer_id": int(hub_customer_id),
|
||||
"customer_name": customer_name,
|
||||
"sag_id": None,
|
||||
"time_entry_ids": [],
|
||||
"time_date": None,
|
||||
@ -593,8 +693,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
if not line_payloads:
|
||||
raise HTTPException(status_code=400, detail="No order lines generated from selected entries")
|
||||
|
||||
draft_title = f"Timefaktura {customer.get('name') or f'Kunde {customer_id}'} - {date.today().isoformat()}"
|
||||
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}"
|
||||
draft_title = f"Timefaktura {customer_name} - {date.today().isoformat()}"
|
||||
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id}"
|
||||
|
||||
draft = execute_query_single(
|
||||
"""
|
||||
@ -614,7 +714,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
""",
|
||||
(
|
||||
draft_title,
|
||||
int(hub_customer_id) if hub_customer_id else None,
|
||||
int(hub_customer_id),
|
||||
json.dumps(line_payloads, ensure_ascii=False),
|
||||
"Genereret fra Economy Time Queue",
|
||||
1,
|
||||
@ -630,44 +730,12 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
|
||||
|
||||
|
||||
def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optional[int]) -> Optional[int]:
|
||||
"""Resolve any incoming customer reference to a valid tmodule_customers.id.
|
||||
"""Resolve the actual Hub customer that must be invoiced.
|
||||
|
||||
Accepts:
|
||||
- direct tmodule customer id
|
||||
- hub customer id (customers.id) via tmodule_customers.hub_customer_id
|
||||
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
|
||||
The historic function name remains for callers, but its return value is a
|
||||
`customers.id`. The case company is authoritative; tracking rows are only
|
||||
a fallback for time entries without a case.
|
||||
"""
|
||||
def _find_by_tmodule_id(candidate_id: int) -> Optional[int]:
|
||||
row = execute_query_single("SELECT id FROM tmodule_customers WHERE id = %s", (candidate_id,))
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
def _find_by_hub_customer_id(hub_customer_id: int) -> Optional[int]:
|
||||
row = execute_query_single(
|
||||
"""
|
||||
SELECT id
|
||||
FROM tmodule_customers
|
||||
WHERE hub_customer_id = %s
|
||||
ORDER BY id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(hub_customer_id,),
|
||||
)
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
if raw_customer_id is not None:
|
||||
try:
|
||||
cid = int(raw_customer_id)
|
||||
except (TypeError, ValueError):
|
||||
cid = None
|
||||
|
||||
if cid and cid > 0:
|
||||
direct = _find_by_tmodule_id(cid)
|
||||
if direct:
|
||||
return direct
|
||||
mapped = _find_by_hub_customer_id(cid)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
if sag_id is not None:
|
||||
try:
|
||||
sid = int(sag_id)
|
||||
@ -678,15 +746,321 @@ def _resolve_tmodule_customer_id(raw_customer_id: Optional[int], sag_id: Optiona
|
||||
sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sid,))
|
||||
hub_customer_id = (sag or {}).get("customer_id") if sag else None
|
||||
if hub_customer_id:
|
||||
mapped = _find_by_hub_customer_id(int(hub_customer_id))
|
||||
if mapped:
|
||||
return mapped
|
||||
return int(hub_customer_id)
|
||||
|
||||
if raw_customer_id is not None:
|
||||
try:
|
||||
cid = int(raw_customer_id)
|
||||
except (TypeError, ValueError):
|
||||
cid = None
|
||||
|
||||
if cid and cid > 0:
|
||||
direct_hub_customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (cid,))
|
||||
if direct_hub_customer:
|
||||
return int(direct_hub_customer["id"])
|
||||
tracking_customer = execute_query_single(
|
||||
"SELECT hub_customer_id FROM tmodule_customers WHERE id = %s", (cid,)
|
||||
)
|
||||
if (tracking_customer or {}).get("hub_customer_id"):
|
||||
return int(tracking_customer["hub_customer_id"])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _selected_time_entries(ids: List[int]) -> List[Dict[str, Any]]:
|
||||
"""Fetch queue-eligible records once, with the context needed for validation."""
|
||||
placeholders = ",".join(["%s"] * len(ids))
|
||||
return execute_query(
|
||||
f"""
|
||||
SELECT
|
||||
t.id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
|
||||
t.customer_id AS recorded_customer_id, t.case_id, t.sag_id, t.status, t.billable,
|
||||
t.billing_method, t.prepaid_card_id, t.fixed_price_agreement_id,
|
||||
t.original_hours, t.approved_hours, t.faktisk_tid_min,
|
||||
t.fakturerbar_tid_min, t.round_block_min, t.worked_date, t.description,
|
||||
COALESCE(c.title, s.titel, 'Tidsregistrering') AS case_title,
|
||||
COALESCE(case_customer.name, effective_customer.name) AS customer_name,
|
||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS hub_customer_id,
|
||||
COALESCE(s.customer_id, effective_customer.hub_customer_id) AS billing_customer_id
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN tmodule_cases c ON c.id = t.case_id
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN customers case_customer ON case_customer.id = s.customer_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT tc.id
|
||||
FROM tmodule_customers tc
|
||||
WHERE tc.hub_customer_id = s.customer_id
|
||||
ORDER BY tc.id ASC
|
||||
LIMIT 1
|
||||
) sag_customer ON s.customer_id IS NOT NULL
|
||||
LEFT JOIN tmodule_customers effective_customer
|
||||
ON effective_customer.id = COALESCE(sag_customer.id, t.customer_id)
|
||||
WHERE t.id IN ({placeholders})
|
||||
AND t.vtiger_id IS NULL
|
||||
AND t.billed_via_thehub_id IS NULL
|
||||
AND t.economy_order_draft_id IS NULL
|
||||
AND t.status <> 'billed'
|
||||
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)), t.id
|
||||
""",
|
||||
tuple(ids),
|
||||
) or []
|
||||
|
||||
|
||||
def _active_prepaid_card(card_id: int) -> Optional[Dict[str, Any]]:
|
||||
return execute_query_single(
|
||||
"""
|
||||
SELECT id, card_number, customer_id, remaining_hours, rounding_minutes, expires_at
|
||||
FROM tticket_prepaid_cards
|
||||
WHERE id = %s AND status = 'active'
|
||||
AND remaining_hours > 0
|
||||
AND (expires_at IS NULL OR expires_at >= CURRENT_DATE)
|
||||
""",
|
||||
(card_id,),
|
||||
)
|
||||
|
||||
|
||||
def _active_agreement(agreement_id: int) -> Optional[Dict[str, Any]]:
|
||||
return execute_query_single(
|
||||
"""
|
||||
SELECT id, agreement_number, customer_id, monthly_hours
|
||||
FROM customer_fixed_price_agreements
|
||||
WHERE id = %s AND status = 'active'
|
||||
AND (start_date IS NULL OR start_date <= CURRENT_DATE)
|
||||
AND (end_date IS NULL OR end_date >= CURRENT_DATE)
|
||||
""",
|
||||
(agreement_id,),
|
||||
)
|
||||
|
||||
|
||||
def _build_settlement_preview(payload: SettlementRequest) -> Dict[str, Any]:
|
||||
ids = _ensure_ids(payload.ids)
|
||||
override_method = _normalise_billing_method(payload.billing_method) if payload.billing_method else None
|
||||
if override_method and override_method not in VALID_SETTLEMENT_METHODS:
|
||||
raise HTTPException(status_code=400, detail="Ugyldig afregningstype")
|
||||
|
||||
rows = _selected_time_entries(ids)
|
||||
found_ids = {int(row["id"]) for row in rows}
|
||||
missing_ids = [entry_id for entry_id in ids if entry_id not in found_ids]
|
||||
items: List[Dict[str, Any]] = []
|
||||
invoice_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
prepaid_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
subscription_groups: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
errors: List[Dict[str, Any]] = [
|
||||
{"id": entry_id, "message": "Tiden findes ikke længere i køen"}
|
||||
for entry_id in missing_ids
|
||||
]
|
||||
|
||||
for row in rows:
|
||||
method = override_method or _normalise_billing_method(row.get("billing_method"))
|
||||
hours = float(row.get("approved_hours") or row.get("original_hours") or 0)
|
||||
item = {
|
||||
"id": int(row["id"]),
|
||||
"title": row.get("case_title") or "Tidsregistrering",
|
||||
"customer_name": row.get("customer_name") or "Ukendt kunde",
|
||||
"hours": hours,
|
||||
"method": method,
|
||||
"method_label": _settlement_label(method),
|
||||
"valid": True,
|
||||
"message": None,
|
||||
}
|
||||
if method not in VALID_SETTLEMENT_METHODS:
|
||||
item.update(valid=False, message="Vælg en gyldig afregningstype")
|
||||
elif hours <= 0:
|
||||
item.update(valid=False, message="Tiden mangler et positivt timeantal")
|
||||
elif method == "invoice":
|
||||
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
|
||||
if not customer_id:
|
||||
item.update(valid=False, message="Mangler tilknyttet kunde til fakturering")
|
||||
elif row.get("billable") is False:
|
||||
item.update(valid=False, message="Intern/ikke-fakturerbar tid kan ikke sendes til faktura")
|
||||
else:
|
||||
item["resolved_customer_id"] = customer_id
|
||||
invoice_groups[int(customer_id)].append(row)
|
||||
elif method == "prepaid":
|
||||
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
|
||||
card = _active_prepaid_card(int(card_id)) if card_id else None
|
||||
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
|
||||
if not card:
|
||||
item.update(valid=False, message="Vælg et aktivt klippekort")
|
||||
elif expected_customer_id and int(card["customer_id"]) != int(expected_customer_id):
|
||||
item.update(valid=False, message="Klippekortet tilhører ikke kunden på sagen")
|
||||
else:
|
||||
item["prepaid_card_id"] = int(card["id"])
|
||||
prepaid_groups[int(card["id"])].append(row)
|
||||
elif method == "subscription":
|
||||
agreement_id = payload.fixed_price_agreement_id or row.get("fixed_price_agreement_id")
|
||||
agreement = _active_agreement(int(agreement_id)) if agreement_id else None
|
||||
expected_customer_id = row.get("billing_customer_id") or row.get("hub_customer_id")
|
||||
if not agreement:
|
||||
item.update(valid=False, message="Vælg en aktiv abonnements- eller fastprisaftale")
|
||||
elif expected_customer_id and int(agreement["customer_id"]) != int(expected_customer_id):
|
||||
item.update(valid=False, message="Aftalen tilhører ikke kunden på sagen")
|
||||
else:
|
||||
item["fixed_price_agreement_id"] = int(agreement["id"])
|
||||
subscription_groups[int(agreement["id"])].append(row)
|
||||
if not item["valid"]:
|
||||
errors.append({"id": item["id"], "message": item["message"]})
|
||||
items.append(item)
|
||||
|
||||
invoice_preview = []
|
||||
for customer_id, group_rows in invoice_groups.items():
|
||||
customer = execute_query_single(
|
||||
"SELECT name, COALESCE(standard_hourly_rate, %s) AS hourly_rate FROM customers WHERE id = %s",
|
||||
(settings.TIMETRACKING_DEFAULT_HOURLY_RATE, customer_id),
|
||||
) or {}
|
||||
hours = sum(
|
||||
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
|
||||
for row in group_rows
|
||||
)
|
||||
rate = float(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)
|
||||
invoice_preview.append({
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer.get("name") or f"Kunde #{customer_id}",
|
||||
"entries": [int(row["id"]) for row in group_rows],
|
||||
"hours": hours,
|
||||
"hourly_rate": rate,
|
||||
"amount_ex_vat": round(hours * rate, 2),
|
||||
})
|
||||
|
||||
prepaid_preview = []
|
||||
for card_id, group_rows in prepaid_groups.items():
|
||||
card = _active_prepaid_card(card_id) or {}
|
||||
hours = sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)
|
||||
if hours > float(card.get("remaining_hours") or 0):
|
||||
message = f"Klippekortet mangler {round(hours - float(card.get('remaining_hours') or 0), 2)} timer"
|
||||
for item in items:
|
||||
if item.get("prepaid_card_id") == card_id:
|
||||
item.update(valid=False, message=message)
|
||||
errors.append({"id": item["id"], "message": message})
|
||||
prepaid_preview.append({"card_id": card_id, "card_number": card.get("card_number"), "hours": hours, "remaining_hours": float(card.get("remaining_hours") or 0), "rounding_minutes": int(card.get("rounding_minutes") or 0)})
|
||||
|
||||
return {
|
||||
"valid": not errors,
|
||||
"selected": len(ids),
|
||||
"items": items,
|
||||
"errors": errors,
|
||||
"invoice_groups": invoice_preview,
|
||||
"prepaid_groups": prepaid_preview,
|
||||
"subscription_groups": [
|
||||
{"agreement_id": agreement_id, "entries": [int(row["id"]) for row in group_rows], "hours": sum(float(row.get("approved_hours") or row.get("original_hours") or 0) for row in group_rows)}
|
||||
for agreement_id, group_rows in subscription_groups.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.post("/time-queue/preview-settlement")
|
||||
async def preview_time_queue_settlement(payload: SettlementRequest):
|
||||
"""Validate selected time and return a reviewable settlement preview. No writes."""
|
||||
return _build_settlement_preview(payload)
|
||||
|
||||
|
||||
@router.post("/time-queue/settle")
|
||||
async def settle_time_queue(payload: SettlementRequest, request: Request):
|
||||
"""Complete a previously reviewable settlement without mixing payment methods."""
|
||||
preview = _build_settlement_preview(payload)
|
||||
if not preview["valid"]:
|
||||
raise HTTPException(status_code=409, detail={"message": "Ret fejlene før afregning", "preview": preview})
|
||||
|
||||
ids = _ensure_ids(payload.ids)
|
||||
rows = _selected_time_entries(ids)
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
by_method: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
method = _normalise_billing_method(payload.billing_method or row.get("billing_method"))
|
||||
by_method[method].append(row)
|
||||
|
||||
created_drafts = []
|
||||
settled_ids: List[int] = []
|
||||
for method, method_rows in by_method.items():
|
||||
if method == "invoice":
|
||||
by_customer: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for row in method_rows:
|
||||
customer_id = _resolve_tmodule_customer_id(row.get("customer_id"), row.get("sag_id"))
|
||||
if customer_id:
|
||||
by_customer[int(customer_id)].append(row)
|
||||
for customer_id, customer_rows in by_customer.items():
|
||||
draft_id = _create_ordre_draft_from_selected(customer_id, customer_rows, user_id)
|
||||
customer_entry_ids = [int(row["id"]) for row in customer_rows]
|
||||
placeholders = ",".join(["%s"] * len(customer_entry_ids))
|
||||
execute_update(
|
||||
f"""UPDATE tmodule_times SET status = 'approved', entry_status = 'godkendt',
|
||||
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
|
||||
economy_order_draft_id = %s, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id IN ({placeholders}) AND status <> 'billed'""",
|
||||
tuple([draft_id, user_id] + customer_entry_ids),
|
||||
)
|
||||
settled_ids.extend(customer_entry_ids)
|
||||
created_drafts.append({"customer_id": customer_id, "draft_id": draft_id, "entry_ids": customer_entry_ids})
|
||||
elif method == "prepaid":
|
||||
by_card: Dict[int, List[Dict[str, Any]]] = defaultdict(list)
|
||||
for row in method_rows:
|
||||
card_id = payload.prepaid_card_id or row.get("prepaid_card_id")
|
||||
by_card[int(card_id)].append(row)
|
||||
for card_id, card_rows in by_card.items():
|
||||
card = _active_prepaid_card(card_id)
|
||||
if not card:
|
||||
raise HTTPException(status_code=409, detail="Klippekortet er ikke længere aktivt")
|
||||
hours = sum(
|
||||
_hours_for_prepaid_card(row, int(card.get("rounding_minutes") or 30))
|
||||
for row in card_rows
|
||||
)
|
||||
debited = execute_query(
|
||||
"""UPDATE tticket_prepaid_cards SET used_hours = used_hours + %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = %s AND status = 'active' AND remaining_hours >= %s
|
||||
RETURNING id, remaining_hours""",
|
||||
(hours, card_id, hours),
|
||||
)
|
||||
if not debited:
|
||||
raise HTTPException(status_code=409, detail="Klippekortet har ikke længere nok timer")
|
||||
entry_ids = [int(row["id"]) for row in card_rows]
|
||||
execute_insert(
|
||||
"""INSERT INTO tticket_prepaid_transactions (card_id, transaction_type, hours, balance_after, description, created_by_user_id)
|
||||
VALUES (%s, 'usage', %s, %s, %s, %s) RETURNING id""",
|
||||
(card_id, -hours, debited[0]["remaining_hours"], f"Tidskø: {', '.join(map(str, entry_ids))}", user_id),
|
||||
)
|
||||
placeholders = ",".join(["%s"] * len(entry_ids))
|
||||
execute_update(
|
||||
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt', billable = TRUE,
|
||||
billing_method = 'prepaid', prepaid_card_id = %s, approved_hours = COALESCE(approved_hours, original_hours),
|
||||
approved_at = CURRENT_TIMESTAMP, economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
|
||||
tuple([card_id, user_id] + entry_ids),
|
||||
)
|
||||
settled_ids.extend(entry_ids)
|
||||
else:
|
||||
entry_ids = [int(row["id"]) for row in method_rows]
|
||||
agreement_id = payload.fixed_price_agreement_id if method == "subscription" else None
|
||||
if method == "subscription" and agreement_id is None:
|
||||
agreement_id = method_rows[0].get("fixed_price_agreement_id")
|
||||
placeholders = ",".join(["%s"] * len(entry_ids))
|
||||
execute_update(
|
||||
f"""UPDATE tmodule_times SET status = 'billed', entry_status = 'godkendt',
|
||||
billable = %s, billing_method = %s, fixed_price_agreement_id = %s,
|
||||
approved_hours = COALESCE(approved_hours, original_hours), approved_at = CURRENT_TIMESTAMP,
|
||||
economy_settled_at = CURRENT_TIMESTAMP, economy_settled_by = %s,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id IN ({placeholders})""",
|
||||
tuple([False, method, agreement_id, user_id] + entry_ids),
|
||||
)
|
||||
settled_ids.extend(entry_ids)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"settled_ids": sorted(set(settled_ids)),
|
||||
"created_drafts": created_drafts,
|
||||
"orders_url": f"/ordre/{created_drafts[0]['draft_id']}" if len(created_drafts) == 1 else "/ordre",
|
||||
"message": "Tiderne er afregnet. Ordrekladder er fortsat lokale og skal godkendes fra Ordre.",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/time-queue/send-to-invoices")
|
||||
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request):
|
||||
# Backwards-compatible endpoint for older clients. It now explicitly uses
|
||||
# the invoice path rather than silently invoicing whatever method a row had.
|
||||
return await settle_time_queue(
|
||||
SettlementRequest(ids=payload.ids, billing_method="invoice"), request
|
||||
)
|
||||
|
||||
ids = _ensure_ids(payload.ids)
|
||||
user_id = getattr(request.state, "user_id", None)
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -1,510 +1,65 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Economy Time Queue{% endblock %}
|
||||
|
||||
{% block title %}Tidskø til afregning{% endblock %}
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Economy Time Queue</h2>
|
||||
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center">
|
||||
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span>
|
||||
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button>
|
||||
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button>
|
||||
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button>
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button>
|
||||
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button>
|
||||
</div>
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterCustomer" class="form-label">Firma</label>
|
||||
<select id="filterCustomer" class="form-select">
|
||||
<option value="">Alle firmaer med ubehandlede registreringer</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterStatus" class="form-label">Status</label>
|
||||
<select id="filterStatus" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="approved">approved</option>
|
||||
<option value="rejected">rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterBillable" class="form-label">Billable</label>
|
||||
<select id="filterBillable" class="form-select">
|
||||
<option value="">All</option>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="filterQuery" class="form-label">Search</label>
|
||||
<input id="filterQuery" class="form-control" type="text" placeholder="Customer, case, description">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label for="bulkDescription" class="form-label">Description</label>
|
||||
<input id="bulkDescription" class="form-control" type="text" placeholder="Optional update">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkHours" class="form-label">Hours</label>
|
||||
<input id="bulkHours" class="form-control" type="number" step="0.25" min="0.25" placeholder="Optional">
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkBillingMethod" class="form-label">Billing method</label>
|
||||
<select id="bulkBillingMethod" class="form-select">
|
||||
<option value="">No change</option>
|
||||
<option value="invoice">invoice</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="prepaid">prepaid</option>
|
||||
<option value="fixed_price">fixed_price</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-2">
|
||||
<label for="bulkPrepaidCard" class="form-label">Prepaid card</label>
|
||||
<select id="bulkPrepaidCard" class="form-select">
|
||||
<option value="">Select card</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-primary" id="bulkUpdateBtn">Update Selected</button>
|
||||
<button class="btn btn-outline-primary" id="bulkApproveBtn">Approve Selected</button>
|
||||
<button class="btn btn-outline-warning" id="bulkPrepaidBtn">Apply Prepaid</button>
|
||||
<button class="btn btn-outline-danger" id="bulkDeleteBtn">Soft Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 42px;"><input type="checkbox" id="selectAll"></th>
|
||||
<th>ID</th>
|
||||
<th>Customer</th>
|
||||
<th>Date</th>
|
||||
<th>Case</th>
|
||||
<th>Hours</th>
|
||||
<th>Status</th>
|
||||
<th>Billable</th>
|
||||
<th>Method</th>
|
||||
<th>Hours edit</th>
|
||||
<th>Description</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="queueBody">
|
||||
<tr>
|
||||
<td colspan="12" class="text-center py-4 text-muted">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-queue { max-width: 1700px; }
|
||||
.queue-summary { background:linear-gradient(135deg,#f7fbff,#fff); border:1px solid #d8e3ed; border-radius:1rem; }
|
||||
.queue-stat { min-width:150px; }.queue-stat strong{font-size:1.5rem;color:#15476c}
|
||||
.time-group { border:1px solid #dce5ed;border-radius:.85rem;overflow:hidden }.time-group header{background:#f5f8fb;border-bottom:1px solid #dce5ed}
|
||||
.time-group header { padding:.45rem .75rem!important; font-size:.85rem; }
|
||||
.time-row{border-left:4px solid #0d6efd;padding:.42rem .7rem!important;min-height:43px}.time-row[data-method="prepaid"]{border-left-color:#6f42c1}.time-row[data-method="subscription"]{border-left-color:#198754}.time-row[data-method="internal"],.time-row[data-method="non_billable"]{border-left-color:#6c757d}.time-row+.time-row{border-top:1px solid #edf1f4}
|
||||
.time-row .row { display:grid;grid-template-columns:22px minmax(290px,2.4fr) minmax(125px,.85fr) minmax(94px,.55fr) minmax(170px,.9fr) 82px;gap:.5rem;align-items:center;margin:0; }
|
||||
.time-row .row>[class*="col"] { width:auto;max-width:none;flex:none;padding:0;min-width:0; }
|
||||
.time-row .col-12.col-lg-4>div { display:inline; }.time-row .col-12.col-lg-4>div+div:before { content:' · ';color:#94a3b8; }
|
||||
.time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; }
|
||||
.time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; }
|
||||
.time-row .col-12.col-lg-4,.time-row .col-6,.time-row .col-8 { overflow:hidden;white-space:nowrap;text-overflow:ellipsis; }
|
||||
.time-row > .mt-2 { display:none; }.time-row .btn { padding:.18rem .48rem;font-size:.75rem; }
|
||||
@media (max-width:1100px){.time-row .row{grid-template-columns:22px minmax(240px,2fr) minmax(115px,1fr) minmax(92px,.6fr) 78px}.time-row .col-8.col-lg-2{display:none}}
|
||||
@media (max-width:720px){.time-row .row{display:flex;flex-wrap:wrap;gap:.35rem}.time-row .col-12.col-lg-4{flex:1 1 calc(100% - 30px)}.time-row .col-6,.time-row .col-8{flex:1 1 auto}.time-row .col-4{margin-left:auto}}
|
||||
.queue-actions { position:sticky;bottom:1rem;z-index:20;box-shadow:0 .5rem 1.5rem rgba(27,56,84,.16) }.preview-group{border-left:4px solid #0d6efd}.empty-state{border:2px dashed #d7e0e8;border-radius:.9rem}
|
||||
.case-detail-meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem}.case-detail-meta div{padding:.65rem .75rem;background:#f6f8fa;border-radius:.55rem}.case-detail-meta small{display:block;color:#6c757d;margin-bottom:.12rem}@media(max-width:576px){.case-detail-meta{grid-template-columns:1fr}}
|
||||
</style>
|
||||
<main class="container-fluid py-4 time-queue">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-3"><div><div class="text-primary text-uppercase fw-semibold small">Økonomi</div><h1 class="h2 mb-1">Tidskø til afregning</h1><p class="text-muted mb-0">Gennemgå tid, vælg afregning og opret først ordrekladder efter en tydelig forhåndsvisning.</p></div><button class="btn btn-outline-secondary" id="reloadBtn"><i class="bi bi-arrow-clockwise me-1"></i>Opdatér</button></div>
|
||||
<div id="messageArea" aria-live="polite"></div>
|
||||
<section class="queue-summary p-3 mb-3"><div class="d-flex flex-wrap gap-4 align-items-center"><div class="queue-stat"><div class="small text-muted">Registreringer</div><strong id="statEntries">–</strong></div><div class="queue-stat"><div class="small text-muted">Timer i alt</div><strong id="statHours">–</strong></div><div class="queue-stat"><div class="small text-muted">Klar til faktura</div><strong id="statInvoice">–</strong></div><div class="queue-stat"><div class="small text-muted">Udvalgt</div><strong id="statSelected">0</strong></div><div class="ms-md-auto small text-muted"><i class="bi bi-shield-check text-success me-1"></i>Intet afregnes uden forhåndsvisning.</div></div></section>
|
||||
<section class="card mb-3"><div class="card-body"><div class="row g-2 align-items-end"><div class="col-12 col-md-3"><label class="form-label">Firma</label><select id="filterCustomer" class="form-select"><option value="">Alle firmaer</option></select></div><div class="col-6 col-md-2"><label class="form-label">Status</label><select id="filterStatus" class="form-select"><option value="">Alle statusser</option><option value="pending">Afventer</option><option value="approved">Godkendt</option><option value="rejected">Afvist</option></select></div><div class="col-6 col-md-2"><label class="form-label">Fakturerbar</label><select id="filterBillable" class="form-select"><option value="">Alle</option><option value="true">Ja</option><option value="false">Nej</option></select></div><div class="col-12 col-md-4"><label class="form-label">Søg</label><input id="filterQuery" class="form-control" placeholder="Kunde, sag eller beskrivelse"></div><div class="col-12 col-md-1 d-grid"><button class="btn btn-outline-secondary" id="clearFiltersBtn">Ryd</button></div></div></div></section>
|
||||
<section class="card mb-3"><div class="card-body"><div class="d-flex justify-content-between flex-wrap gap-2 mb-3"><div><h2 class="h5 mb-0">Afregning for valgte tidsregistreringer</h2><div class="small text-muted">Vælg type og eventuelt klippekort eller aftale.</div></div><span class="badge text-bg-primary align-self-center" id="selectedBadge">0 valgt</span></div><div class="row g-2 align-items-end"><div class="col-12 col-lg-3"><label class="form-label">Afregningstype</label><select class="form-select" id="settlementMethod"><option value="">Behold den enkelte tids afregningstype</option><option value="invoice">Faktura</option><option value="prepaid">Klippekort</option><option value="subscription">Abonnement / fast pris</option><option value="internal">Intern tid</option><option value="non_billable">Ikke-fakturerbar tid</option></select></div><div class="col-12 col-lg-3"><label class="form-label">Klippekort</label><select class="form-select" id="prepaidCard" disabled><option value="">Vælg først tider fra ét firma</option></select><div class="form-text" id="prepaidHelp">Vælg tidsregistreringer for at se kundens klippekort.</div></div><div class="col-12 col-lg-2"><label class="form-label">Aftale-ID</label><input id="agreementId" class="form-control" type="number" min="1" placeholder="Kun abonnement"></div><div class="col-12 col-lg-4 d-flex gap-2"><button class="btn btn-outline-primary flex-grow-1" id="setMethodBtn">Gem afregning</button><button class="btn btn-primary flex-grow-1" id="previewBtn"><i class="bi bi-eye me-1"></i>Forhåndsvis</button></div></div></div></section>
|
||||
<section id="previewPanel" class="card border-primary mb-3 d-none"><div class="card-header bg-primary-subtle d-flex justify-content-between align-items-center"><div><strong>Forhåndsvisning af afregning</strong><div class="small text-muted">Kontrollér beløb og fejl før afregningen gennemføres.</div></div><button class="btn btn-sm btn-outline-secondary" id="closePreviewBtn">Luk</button></div><div class="card-body" id="previewBody"></div><div class="card-footer text-end"><button class="btn btn-success" id="settleBtn"><i class="bi bi-check2-circle me-1"></i>Gennemfør afregning</button></div></section>
|
||||
<div class="modal fade" id="caseDetailModal" tabindex="-1" aria-labelledby="caseDetailTitle" aria-hidden="true"><div class="modal-dialog modal-lg modal-dialog-scrollable"><div class="modal-content"><div class="modal-header"><div><div class="small text-primary text-uppercase fw-semibold">Sag uden at forlade tidskøen</div><h2 class="modal-title h4 mb-0" id="caseDetailTitle">Sagsdetaljer</h2></div><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button></div><div class="modal-body" id="caseDetailBody"></div><div class="modal-footer"><a class="btn btn-outline-primary" id="openCaseNewTab" target="_blank" rel="noopener"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn sag i ny fane</a><button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button></div></div></div></div>
|
||||
<section id="queueContent" aria-live="polite"></section>
|
||||
<div class="queue-actions card d-none" id="selectionActions"><div class="card-body py-2 d-flex flex-wrap justify-content-between align-items-center gap-2"><div><strong id="selectionText">0 tidsregistreringer valgt</strong><span class="small text-muted ms-2">Vælg forhåndsvisning før afregning.</span></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-secondary" id="clearSelectionBtn">Fravælg</button><button class="btn btn-sm btn-primary" id="stickyPreviewBtn">Forhåndsvis afregning</button></div></div></div>
|
||||
</main>
|
||||
<script>
|
||||
(() => {
|
||||
const state = {
|
||||
items: [],
|
||||
selected: new Set(),
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const queueBody = document.getElementById('queueBody');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const selectedCountBadge = document.getElementById('selectedCountBadge');
|
||||
|
||||
const filterCustomer = document.getElementById('filterCustomer');
|
||||
const filterStatus = document.getElementById('filterStatus');
|
||||
const filterBillable = document.getElementById('filterBillable');
|
||||
const filterQuery = document.getElementById('filterQuery');
|
||||
|
||||
const bulkDescription = document.getElementById('bulkDescription');
|
||||
const bulkHours = document.getElementById('bulkHours');
|
||||
const bulkBillingMethod = document.getElementById('bulkBillingMethod');
|
||||
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard');
|
||||
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn');
|
||||
|
||||
function selectedIds() {
|
||||
return Array.from(state.selected);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
if (!state.items.length) {
|
||||
queueBody.innerHTML = '<tr><td colspan="12" class="text-center py-4 text-muted">No entries found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
queueBody.innerHTML = state.items.map((item) => {
|
||||
const id = Number(item.id);
|
||||
const checked = state.selected.has(id) ? 'checked' : '';
|
||||
const date = item.worked_date || '-';
|
||||
const hours = item.approved_hours || item.original_hours || 0;
|
||||
const customer = `${item.customer_id || '-'} / ${item.customer_name || ''}`;
|
||||
const title = item.case_title || '-';
|
||||
const desc = (item.description || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const method = item.billing_method || 'invoice';
|
||||
return `
|
||||
<tr>
|
||||
<td><input type="checkbox" class="row-check" data-id="${id}" ${checked}></td>
|
||||
<td>${id}</td>
|
||||
<td>${customer}</td>
|
||||
<td>${date}</td>
|
||||
<td>${title}</td>
|
||||
<td>${hours}</td>
|
||||
<td>${item.status || '-'}</td>
|
||||
<td>${item.billable === false ? 'false' : 'true'}</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm inline-method" data-id="${id}">
|
||||
<option value="invoice" ${method === 'invoice' ? 'selected' : ''}>invoice</option>
|
||||
<option value="internal" ${method === 'internal' ? 'selected' : ''}>internal</option>
|
||||
<option value="prepaid" ${method === 'prepaid' ? 'selected' : ''}>prepaid</option>
|
||||
<option value="fixed_price" ${method === 'fixed_price' ? 'selected' : ''}>fixed_price</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" step="0.25" min="0.25" class="form-control form-control-sm inline-hours" data-id="${id}" value="${hours}">
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm inline-desc" data-id="${id}" value="${desc}">
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-success inline-save" data-id="${id}">Gem</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
document.querySelectorAll('.row-check').forEach((cb) => {
|
||||
cb.addEventListener('change', (e) => {
|
||||
const id = Number(e.target.dataset.id);
|
||||
if (e.target.checked) state.selected.add(id);
|
||||
else state.selected.delete(id);
|
||||
syncSelectAll();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.inline-save').forEach((btn) => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const id = Number(e.target.dataset.id);
|
||||
await saveInlineRow(id, e.target);
|
||||
});
|
||||
});
|
||||
|
||||
syncSelectAll();
|
||||
}
|
||||
|
||||
function syncSelectAll() {
|
||||
const ids = state.items.map((x) => Number(x.id));
|
||||
const allSelected = ids.length && ids.every((id) => state.selected.has(id));
|
||||
selectAll.checked = Boolean(allSelected);
|
||||
selectedCountBadge.textContent = `${state.selected.size} selected`;
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const res = await fetch(url, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...options,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error(data.detail || 'Request failed');
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function buildListUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (filterCustomer.value) params.set('customer_id', filterCustomer.value);
|
||||
if (filterStatus.value) params.set('status', filterStatus.value);
|
||||
if (filterBillable.value) params.set('billable', filterBillable.value);
|
||||
if (filterQuery.value.trim()) params.set('q', filterQuery.value.trim());
|
||||
params.set('limit', '500');
|
||||
return `/api/v1/economy/time-queue?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function loadEntries() {
|
||||
if (state.loading) return;
|
||||
state.loading = true;
|
||||
queueBody.innerHTML = '<tr><td colspan="10" class="text-center py-4 text-muted">Loading...</td></tr>';
|
||||
try {
|
||||
const data = await api(buildListUrl());
|
||||
state.items = data.items || [];
|
||||
state.selected = new Set(Array.from(state.selected).filter((id) => state.items.some((x) => Number(x.id) === id)));
|
||||
renderRows();
|
||||
} catch (err) {
|
||||
queueBody.innerHTML = `<tr><td colspan="10" class="text-center py-4 text-danger">${err.message}</td></tr>`;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPrepaidCards() {
|
||||
try {
|
||||
const data = await api('/api/v1/economy/time-queue/prepaid-cards');
|
||||
const opts = ['<option value="">Select card</option>'];
|
||||
(data.items || []).forEach((card) => {
|
||||
const label = `${card.id} | ${card.card_number || '-'} | rem: ${card.remaining_hours || 0}`;
|
||||
opts.push(`<option value="${card.id}">${label}</option>`);
|
||||
});
|
||||
bulkPrepaidCard.innerHTML = opts.join('');
|
||||
} catch (_) {
|
||||
bulkPrepaidCard.innerHTML = '<option value="">No cards</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const data = await api('/api/v1/economy/time-queue/customers');
|
||||
const current = filterCustomer.value;
|
||||
const opts = ['<option value="">Alle firmaer med ubehandlede registreringer</option>'];
|
||||
(data.items || []).forEach((row) => {
|
||||
const label = `${row.customer_name || 'Ukendt'} (${row.open_count || 0})`;
|
||||
opts.push(`<option value="${row.customer_id}">${label}</option>`);
|
||||
});
|
||||
filterCustomer.innerHTML = opts.join('');
|
||||
if (current) {
|
||||
filterCustomer.value = current;
|
||||
}
|
||||
} catch (_) {
|
||||
filterCustomer.innerHTML = '<option value="">Kunne ikke hente firmaer</option>';
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFilters() {
|
||||
filterCustomer.value = '';
|
||||
filterStatus.value = '';
|
||||
filterBillable.value = '';
|
||||
filterQuery.value = '';
|
||||
setActiveQuickFilter(null);
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
function setActiveQuickFilter(active) {
|
||||
quickFilterBtns.forEach((btn) => {
|
||||
const isActive = btn.dataset.filter === active;
|
||||
btn.classList.toggle('btn-primary', isActive);
|
||||
btn.classList.toggle('btn-outline-primary', !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
async function applyQuickFilter(type) {
|
||||
if (type === 'pending') {
|
||||
filterStatus.value = 'pending';
|
||||
filterBillable.value = '';
|
||||
} else if (type === 'billable') {
|
||||
filterStatus.value = '';
|
||||
filterBillable.value = 'true';
|
||||
} else if (type === 'ready') {
|
||||
filterStatus.value = 'approved';
|
||||
filterBillable.value = 'true';
|
||||
}
|
||||
setActiveQuickFilter(type);
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
async function saveInlineRow(id, buttonEl) {
|
||||
const hoursInput = document.querySelector(`.inline-hours[data-id="${id}"]`);
|
||||
const descInput = document.querySelector(`.inline-desc[data-id="${id}"]`);
|
||||
const methodSelect = document.querySelector(`.inline-method[data-id="${id}"]`);
|
||||
if (!hoursInput || !descInput || !methodSelect) return;
|
||||
|
||||
const originalHours = Number(hoursInput.value);
|
||||
const description = (descInput.value || '').trim();
|
||||
const billingMethod = methodSelect.value;
|
||||
|
||||
if (!originalHours || originalHours <= 0) {
|
||||
alert('Hours must be greater than 0');
|
||||
return;
|
||||
}
|
||||
|
||||
const prevText = buttonEl.textContent;
|
||||
buttonEl.disabled = true;
|
||||
buttonEl.textContent = 'Gemmer...';
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({
|
||||
ids: [id],
|
||||
original_hours: originalHours,
|
||||
description,
|
||||
billing_method: billingMethod,
|
||||
}),
|
||||
});
|
||||
await loadEntries();
|
||||
await loadCustomers();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
buttonEl.disabled = false;
|
||||
buttonEl.textContent = prevText;
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkUpdate() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const payload = { ids };
|
||||
if (bulkDescription.value.trim()) payload.description = bulkDescription.value.trim();
|
||||
if (bulkHours.value) payload.original_hours = Number(bulkHours.value);
|
||||
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||
|
||||
if (!payload.description && !payload.original_hours && !payload.billing_method) {
|
||||
return alert('Set at least one update field');
|
||||
}
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-update', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkApprove() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const payload = { ids };
|
||||
if (bulkBillingMethod.value) payload.billing_method = bulkBillingMethod.value;
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-approve', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doBulkDelete() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const reason = prompt('Reason for soft delete:', 'Soft deleted from economy queue');
|
||||
if (reason === null) return;
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-soft-delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, reason }),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doApplyPrepaid() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
if (!bulkPrepaidCard.value) return alert('Select a prepaid card first');
|
||||
|
||||
try {
|
||||
await api('/api/v1/economy/time-queue/bulk-apply-prepaid', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids, prepaid_card_id: Number(bulkPrepaidCard.value) }),
|
||||
});
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function doSendInvoices() {
|
||||
const ids = selectedIds();
|
||||
if (!ids.length) return alert('Select at least one entry');
|
||||
|
||||
const ok = confirm('Opret lokale ordrer for de valgte linjer? (Ingen direkte overførsel til e-conomic)');
|
||||
if (!ok) return;
|
||||
|
||||
try {
|
||||
const result = await api('/api/v1/economy/time-queue/send-to-invoices', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
const drafts = (result.created_drafts || result.created_orders || []).map((x) => {
|
||||
const draftId = x.draft_id || x.order_id;
|
||||
return `customer ${x.customer_id}, draft ${draftId}`;
|
||||
}).join('\n');
|
||||
const skipped = (result.skipped_missing_customer || []);
|
||||
const failedCustomers = (result.failed_customers || []);
|
||||
const orderMessage = drafts || 'Ingen ordrekladder oprettet';
|
||||
const nextStep = result.orders_url ? `\n\nÅbn ordre: ${result.orders_url}` : '';
|
||||
const skippedMsg = skipped.length ? `\n\nSprunget over (mangler kunde-link): ${skipped.join(', ')}` : '';
|
||||
const failedMsg = failedCustomers.length
|
||||
? `\n\nFejl ved kunde-grupper:\n${failedCustomers.map((f) => `customer ${f.customer_id}: ${f.error}`).join('\n')}`
|
||||
: '';
|
||||
alert(`Ordrekladder oprettet i /ordre:\n${orderMessage}${skippedMsg}${failedMsg}${nextStep}`);
|
||||
await loadCustomers();
|
||||
await loadEntries();
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
if (selectAll.checked) {
|
||||
state.items.forEach((item) => state.selected.add(Number(item.id)));
|
||||
} else {
|
||||
state.items.forEach((item) => state.selected.delete(Number(item.id)));
|
||||
}
|
||||
renderRows();
|
||||
});
|
||||
|
||||
document.getElementById('reloadBtn').addEventListener('click', loadEntries);
|
||||
document.getElementById('clearFiltersBtn').addEventListener('click', clearFilters);
|
||||
document.getElementById('bulkUpdateBtn').addEventListener('click', doBulkUpdate);
|
||||
document.getElementById('bulkApproveBtn').addEventListener('click', doBulkApprove);
|
||||
document.getElementById('bulkPrepaidBtn').addEventListener('click', doApplyPrepaid);
|
||||
document.getElementById('bulkDeleteBtn').addEventListener('click', doBulkDelete);
|
||||
document.getElementById('sendInvoicesBtn').addEventListener('click', doSendInvoices);
|
||||
quickFilterBtns.forEach((btn) => {
|
||||
btn.addEventListener('click', () => applyQuickFilter(btn.dataset.filter));
|
||||
});
|
||||
|
||||
[filterCustomer, filterStatus, filterBillable].forEach((el) => {
|
||||
el.addEventListener('change', loadEntries);
|
||||
});
|
||||
filterQuery.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') loadEntries();
|
||||
});
|
||||
|
||||
loadCustomers();
|
||||
loadPrepaidCards();
|
||||
loadEntries();
|
||||
const state={items:[],selected:new Set(),preview:null,loading:false,cards:[]}; const $=id=>document.getElementById(id);
|
||||
const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid'};
|
||||
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
|
||||
const norm=v=>({fixed_price:'subscription',prepaid_card:'prepaid'})[String(v||'invoice').toLowerCase()]||String(v||'invoice').toLowerCase(); const ids=()=>[...state.selected];
|
||||
function message(type,text){$('messageArea').innerHTML=`<div class="alert alert-${type} alert-dismissible fade show">${esc(text)}<button class="btn-close" data-bs-dismiss="alert"></button></div>`}
|
||||
async function api(url,options={}){const r=await fetch(url,{headers:{'Content-Type':'application/json'},...options});const d=await r.json().catch(()=>({}));if(!r.ok){const e=new Error(typeof d.detail==='object'?(d.detail.message||'Handlingen kunne ikke gennemføres'):(d.detail||'Handlingen kunne ikke gennemføres'));e.payload=d;throw e}return d}
|
||||
function payload(){const method=$('settlementMethod').value,card=Number($('prepaidCard').value),agreement=Number($('agreementId').value);return {ids:ids(),...(method?{billing_method:method}:{}),...(card?{prepaid_card_id:card}:{}),...(agreement?{fixed_price_agreement_id:agreement}:{})}}
|
||||
function status(v){return `<span class="badge ${v==='approved'?'text-bg-success':v==='rejected'?'text-bg-danger':'text-bg-warning'}">${esc(labels[v]||v||'Ukendt')}</span>`} function method(v){const m=norm(v),cls=m==='invoice'?'text-bg-primary':m==='prepaid'?'text-bg-purple':m==='subscription'?'text-bg-success':'text-bg-secondary';return `<span class="badge ${cls}">${esc(labels[m]||m)}</span>`}
|
||||
function renderStats(){const selected=state.items.filter(x=>state.selected.has(Number(x.id)));$('statEntries').textContent=state.items.length;$('statHours').textContent=`${h(state.items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0))} t`;$('statInvoice').textContent=state.items.filter(x=>norm(x.billing_method)==='invoice'&&x.billable!==false).length;$('statSelected').textContent=selected.length;$('selectedBadge').textContent=`${selected.length} valgt`;$('selectionText').textContent=`${selected.length} tidsregistrering${selected.length===1?'':'er'} valgt`;$('selectionActions').classList.toggle('d-none',!selected.length)}
|
||||
function selectedRows(){return state.items.filter(x=>state.selected.has(Number(x.id)))}
|
||||
function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
|
||||
function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
|
||||
function actualHours(rows){return rows.reduce((total,x)=>total+Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)/60,0)}
|
||||
function cardHours(rows,block){const safeBlock=Math.max(1,Number(block)||30);return rows.reduce((total,x)=>{const minutes=Math.max(0,Math.round(Number(x.faktisk_tid_min!=null?x.faktisk_tid_min:Number(x.original_hours||0)*60)));return total+(minutes?Math.ceil(minutes/safeBlock)*safeBlock/60:0)},0)}
|
||||
function renderPrepaidHelp(){const selected=selectedRows(),customers=selectedCardCustomerIds(),card=state.cards.find(x=>Number(x.id)===Number($('prepaidCard').value)),storedHours=selectedHours();if(!selected.length){$('prepaidHelp').textContent='Vælg tidsregistreringer for at se kundens klippekort.';return}if(customers.length!==1){$('prepaidHelp').innerHTML='<span class="text-danger">Vælg kun tider fra ét firma for at bruge ét klippekort.</span>';return}const company=selected[0].customer_name||'den valgte kunde';if(!card){$('prepaidHelp').textContent=`${esc(company)} · Fakturerbar tid: ${h(storedHours)} timer. Vælg et klippekort for at se kortets afrunding.`;return}const block=Number(card.rounding_minutes)||30,actual=actualHours(selected),hours=cardHours(selected,block),remaining=Number(card.remaining_hours||0),after=remaining-hours;$('prepaidHelp').innerHTML=`${esc(company)} · Faktisk tid: <strong>${h(actual)} t</strong> → klippes som <strong>${h(hours)} t</strong> <span class="text-muted">(${block} min. afrunding pr. registrering)</span>. Kortet har ${h(remaining)} t tilbage → <strong class="${after<0?'text-danger':'text-success'}">${h(after)} t efter afregning</strong>.`}
|
||||
function render(){renderStats();if(!state.items.length){$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><i class="bi bi-check2-circle fs-2 d-block text-success mb-2"></i>Ingen tidsregistreringer matcher dine filtre.</div>';return}const groups={};state.items.forEach(x=>{const key=`${x.customer_id||0}:${x.customer_name||'Ukendt kunde'}`;(groups[key]??=[]).push(x)});$('queueContent').innerHTML=Object.values(groups).map(items=>{const f=items[0],total=items.reduce((a,x)=>a+Number(x.approved_hours||x.original_hours||0),0);return `<article class="time-group mb-3"><header class="px-3 py-2 d-flex justify-content-between flex-wrap gap-2"><div><strong>${esc(f.customer_name||'Ukendt kunde')}</strong><span class="small text-muted ms-2">${items.length} registreringer · ${h(total)} timer</span></div><button class="btn btn-sm btn-link customer-select" data-ids="${items.map(x=>x.id).join(',')}">Vælg kunde</button></header>${items.map(x=>{const id=Number(x.id),m=norm(x.billing_method);return `<div class="time-row p-3" data-method="${esc(m)}"><div class="row align-items-center g-2"><div class="col-auto"><input class="form-check-input row-check" type="checkbox" data-id="${id}" ${state.selected.has(id)?'checked':''}></div><div class="col-12 col-lg-4"><div class="fw-semibold">${x.sag_id?`<button type="button" class="btn btn-link p-0 text-decoration-none fw-semibold case-details" data-sag-id="${Number(x.sag_id)}">#${Number(x.sag_id)} · ${esc(x.case_title)}</button>`:esc(x.case_title||'Ingen sag')}</div><div class="small text-muted">${esc(x.contact_name||'Ingen kontakt')} · ${esc(x.employee_name||'Ukendt medarbejder')}</div></div><div class="col-6 col-lg-2"><div class="small text-muted">Dato og status</div>${esc(x.worked_date||'Ingen dato')} ${status(x.status)}</div><div class="col-6 col-lg-2"><div class="small text-muted">Tid</div><strong>${h(x.approved_hours||x.original_hours)} t</strong><span class="small text-muted"> · ${esc(x.work_type||x.entry_type||'arbejde')}</span></div><div class="col-8 col-lg-2"><div class="small text-muted">Afregning</div>${method(m)} ${x.billable===false?'<span class="small text-muted">ikke fakturerbar</span>':''}</div><div class="col-4 col-lg-2 text-lg-end"><button class="btn btn-sm btn-outline-secondary select-one" data-id="${id}">Gennemgå</button></div></div>${x.description?`<div class="small text-muted mt-2 ps-lg-4">${esc(x.description)}</div>`:'<div class="small text-warning mt-2 ps-lg-4"><i class="bi bi-exclamation-triangle me-1"></i>Mangler beskrivelse</div>'}</div>`}).join('')}</article>`}).join('');document.querySelectorAll('.row-check').forEach(e=>e.addEventListener('change',()=>{const id=Number(e.dataset.id);e.checked?state.selected.add(id):state.selected.delete(id);selectionChanged()}));document.querySelectorAll('.case-details').forEach(e=>e.addEventListener('click',()=>openCaseDetails(Number(e.dataset.sagId))));document.querySelectorAll('.select-one').forEach(e=>e.addEventListener('click',()=>{state.selected=new Set([Number(e.dataset.id)]);selectionChanged();$('settlementMethod').scrollIntoView({behavior:'smooth',block:'center'})}));document.querySelectorAll('.customer-select').forEach(e=>e.addEventListener('click',()=>{e.dataset.ids.split(',').forEach(id=>state.selected.add(Number(id)));selectionChanged()}))}
|
||||
function url(){const p=new URLSearchParams({limit:'500'});if($('filterCustomer').value)p.set('customer_id',$('filterCustomer').value);if($('filterStatus').value)p.set('status',$('filterStatus').value);if($('filterBillable').value)p.set('billable',$('filterBillable').value);if($('filterQuery').value.trim())p.set('q',$('filterQuery').value.trim());return `/api/v1/economy/time-queue?${p}`}
|
||||
async function loadCustomers(){try{const d=await api('/api/v1/economy/time-queue/customers'),v=$('filterCustomer').value;$('filterCustomer').innerHTML=`<option value="">Alle firmaer</option>${(d.items||[]).map(x=>`<option value="${Number(x.customer_id)}">${esc(x.customer_name)} (${Number(x.open_count||0)})</option>`).join('')}`;$('filterCustomer').value=v}catch(_){message('warning','Firmafilteret kunne ikke hentes.') }}
|
||||
async function loadCards(){const select=$('prepaidCard'),customers=selectedCardCustomerIds(),current=Number(select.value);state.cards=[];select.disabled=true;if(customers.length!==1){select.innerHTML=`<option value="">${customers.length>1?'Vælg tider fra ét firma':'Vælg først tider fra ét firma'}</option>`;renderPrepaidHelp();return}try{const d=await api(`/api/v1/economy/time-queue/prepaid-cards?customer_id=${customers[0]}`);state.cards=(d.items||[]).filter(x=>x.status==='active'&&Number(x.remaining_hours)>0);select.innerHTML=`<option value="">Vælg kundens klippekort</option>${state.cards.map(x=>`<option value="${Number(x.id)}">${esc(x.card_number)} · ${h(x.remaining_hours)} timer tilbage</option>`).join('')}`;select.disabled=!state.cards.length;if(state.cards.some(x=>Number(x.id)===current))select.value=String(current);renderPrepaidHelp()}catch(_){select.innerHTML='<option value="">Klippekort kunne ikke hentes</option>';renderPrepaidHelp()}}
|
||||
async function load(){if(state.loading)return;state.loading=true;$('queueContent').innerHTML='<div class="empty-state py-5 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter tidsregistreringer…</div>';try{const d=await api(url());state.items=d.items||[];state.selected=new Set(ids().filter(id=>state.items.some(x=>Number(x.id)===id)));state.preview=null;$('previewPanel').classList.add('d-none');render();await loadCards()}catch(e){$('queueContent').innerHTML=`<div class="alert alert-danger">${esc(e.message)}</div>`}finally{state.loading=false}}
|
||||
async function saveMethod(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');const m=$('settlementMethod').value;if(!m)return message('warning','Vælg en afregningstype, der skal gemmes.');try{await api('/api/v1/economy/time-queue/bulk-update',{method:'PATCH',body:JSON.stringify({ids:ids(),billing_method:m,billable:['invoice','prepaid'].includes(m)})});message('success','Afregningstypen er gemt.');await load()}catch(e){message('danger',e.message)}}
|
||||
async function openCaseDetails(sagId){const row=state.items.find(x=>Number(x.sag_id)===sagId)||{},modal=bootstrap.Modal.getOrCreateInstance($('caseDetailModal'));$('caseDetailTitle').textContent=`#${sagId} · ${row.case_title||'Sag'}`;$('openCaseNewTab').href=`/sag/${sagId}/v3`;$('caseDetailBody').innerHTML='<div class="py-4 text-center text-muted"><span class="spinner-border spinner-border-sm me-2"></span>Henter sagsdetaljer…</div>';modal.show();try{const s=await api(`/api/v1/sag/${sagId}`),description=s.beskrivelse||row.description||'Ingen beskrivelse på sagen.';$('caseDetailBody').innerHTML=`<div class="d-flex flex-wrap gap-2 mb-3">${status(s.status)}<span class="badge text-bg-light border">${esc(s.type||'Sag')}</span><span class="badge text-bg-light border">${esc(s.prioritet||'Normal')}</span></div><div class="case-detail-meta mb-3"><div><small>Kunde</small><strong>${esc(row.customer_name||'Ikke tilknyttet')}</strong></div><div><small>Kontakt</small><strong>${esc(row.contact_name||'Ikke tilknyttet')}</strong></div><div><small>Ansvarlig</small><strong>${esc(s.ansvarlig_navn||'Ikke tildelt')}</strong></div><div><small>Senest opdateret</small><strong>${esc(s.updated_at||s.opdateret_at||'Ikke oplyst')}</strong></div></div><h3 class="h6">Beskrivelse</h3><div class="border rounded p-3 bg-light">${esc(description).replace(/\n/g,'<br>')}</div>`}catch(e){$('caseDetailBody').innerHTML=`<div class="alert alert-danger mb-0">${esc(e.message)}</div>`}}
|
||||
function previewView(d){state.preview=d;$('previewPanel').classList.remove('d-none');const errors=d.errors||[],invoice=d.invoice_groups||[],prepaid=d.prepaid_groups||[],sub=d.subscription_groups||[];$('previewBody').innerHTML=`${errors.length?`<div class="alert alert-danger"><strong>Afregningen kan ikke gennemføres endnu.</strong><ul class="mb-0 mt-2">${errors.map(x=>`<li>Tid #${Number(x.id)}: ${esc(x.message)}</li>`).join('')}</ul></div>`:'<div class="alert alert-success"><i class="bi bi-check-circle me-1"></i>Alle valgte tider er klar til afregning.</div>'}<div class="row g-3">${invoice.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light"><strong>Faktura · ${esc(x.customer_name)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer · ${h(x.hourly_rate)} kr./t</div><div class="fs-5 fw-semibold">${Number(x.amount_ex_vat).toLocaleString('da-DK',{minimumFractionDigits:2})} kr. ekskl. moms</div><div class="small text-muted">Gebyr og evt. fragt vises i ordrekladden.</div></div></div>`).join('')}${prepaid.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#6f42c1"><strong>Klippekort · ${esc(x.card_number||'#'+x.card_id)}</strong><div class="small text-muted">Forbrug: ${h(x.hours)} t · Tilbage før træk: ${h(x.remaining_hours)} t</div></div></div>`).join('')}${sub.map(x=>`<div class="col-12 col-lg-6"><div class="preview-group p-3 rounded bg-light" style="border-left-color:#198754"><strong>Abonnement / fast pris · aftale #${Number(x.agreement_id)}</strong><div class="small text-muted">${x.entries.length} tider · ${h(x.hours)} timer knyttes til aftalen uden fakturalinje.</div></div></div>`).join('')}</div>`;$('settleBtn').disabled=!d.valid;$('previewPanel').scrollIntoView({behavior:'smooth',block:'center'})}
|
||||
async function preview(){if(!ids().length)return message('warning','Vælg mindst én tidsregistrering først.');try{previewView(await api('/api/v1/economy/time-queue/preview-settlement',{method:'POST',body:JSON.stringify(payload())}))}catch(e){message('danger',e.message)}}
|
||||
async function settle(){if(!state.preview?.valid)return;$('settleBtn').disabled=true;$('settleBtn').innerHTML='<span class="spinner-border spinner-border-sm me-1"></span>Afregner…';try{const d=await api('/api/v1/economy/time-queue/settle',{method:'POST',body:JSON.stringify(payload())});message('success',`Afregningen er gennemført.${d.created_drafts?.length?' Ordrekladder er oprettet.':''}`);state.selected.clear();$('previewPanel').classList.add('d-none');if(d.orders_url)$('messageArea').insertAdjacentHTML('beforeend',`<p><a class="btn btn-outline-primary" href="${esc(d.orders_url)}">Åbn ordrekladde</a></p>`);await loadCustomers();await load()}catch(e){message('danger',e.message);if(e.payload?.detail?.preview)previewView(e.payload.detail.preview)}finally{$('settleBtn').disabled=false;$('settleBtn').innerHTML='<i class="bi bi-check2-circle me-1"></i>Gennemfør afregning'}}
|
||||
function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}
|
||||
$('reloadBtn').onclick=()=>{loadCustomers();load()};$('clearFiltersBtn').onclick=()=>{$('filterCustomer').value='';$('filterStatus').value='';$('filterBillable').value='';$('filterQuery').value='';load()};[$('filterCustomer'),$('filterStatus'),$('filterBillable')].forEach(x=>x.onchange=load);$('filterQuery').onkeydown=e=>e.key==='Enter'&&load();$('prepaidCard').onchange=renderPrepaidHelp;$('setMethodBtn').onclick=saveMethod;$('previewBtn').onclick=preview;$('stickyPreviewBtn').onclick=preview;$('settleBtn').onclick=settle;$('closePreviewBtn').onclick=()=>{$('previewPanel').classList.add('d-none');state.preview=null};$('clearSelectionBtn').onclick=()=>{state.selected.clear();state.preview=null;$('previewPanel').classList.add('d-none');selectionChanged()};loadCustomers();load();
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@ -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,14 +8,20 @@ Runs daily at 04:00
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
import json
|
||||
from typing import Optional, Sequence
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from app.core.database import execute_query, get_db_connection
|
||||
from app.services.subscription_billing_calendar import (
|
||||
advance_billing_periods,
|
||||
billing_date_for_period,
|
||||
prorated_30_day_factor,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_subscriptions():
|
||||
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
|
||||
@ -26,6 +32,13 @@ async def process_subscriptions():
|
||||
|
||||
try:
|
||||
logger.info("💰 Processing subscription invoices...")
|
||||
from app.subscriptions.backend.router import apply_due_subscription_changes, expire_ended_subscriptions
|
||||
applied_changes = apply_due_subscription_changes()
|
||||
expired_subscriptions = expire_ended_subscriptions()
|
||||
if applied_changes:
|
||||
logger.info("✅ Applied %s scheduled subscription change request(s)", applied_changes)
|
||||
if expired_subscriptions:
|
||||
logger.info("✅ Expired %s ended subscription(s)", expired_subscriptions)
|
||||
|
||||
# Find subscriptions due for invoicing
|
||||
query = """
|
||||
@ -37,8 +50,13 @@ async def process_subscriptions():
|
||||
c.name AS customer_name,
|
||||
s.product_name,
|
||||
s.billing_interval,
|
||||
s.billing_schedule_type,
|
||||
s.billing_day,
|
||||
s.billing_direction,
|
||||
s.advance_months,
|
||||
s.billing_lead_months,
|
||||
s.first_full_period_start,
|
||||
s.proration_basis,
|
||||
s.price,
|
||||
s.next_invoice_date,
|
||||
s.period_start,
|
||||
@ -67,15 +85,43 @@ async def process_subscriptions():
|
||||
),
|
||||
'[]'::json
|
||||
) as line_items
|
||||
,COALESCE(
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', fi.id,
|
||||
'description', fi.description,
|
||||
'quantity', fi.quantity,
|
||||
'unit_price', fi.unit_price,
|
||||
'line_total', fi.line_total,
|
||||
'product_id', fi.product_id
|
||||
) ORDER BY fi.line_no, fi.id)
|
||||
FROM sag_subscription_first_invoice_items fi
|
||||
WHERE fi.subscription_id = s.id AND fi.billed_at IS NULL
|
||||
),
|
||||
'[]'::json
|
||||
) AS first_invoice_items
|
||||
FROM sag_subscriptions s
|
||||
LEFT JOIN sag_sager sg ON sg.id = s.sag_id
|
||||
LEFT JOIN customers c ON c.id = s.customer_id
|
||||
WHERE s.status = 'active'
|
||||
AND s.next_invoice_date <= CURRENT_DATE
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM subscription_billing_runs br
|
||||
WHERE br.subscription_id = s.id AND br.period_start = s.period_start
|
||||
)
|
||||
"""
|
||||
params = []
|
||||
if subscription_ids is not None:
|
||||
selected_ids = sorted({int(item) for item in subscription_ids})
|
||||
if not selected_ids:
|
||||
return
|
||||
query += " AND s.id = ANY(%s)"
|
||||
params.append(selected_ids)
|
||||
query += """
|
||||
ORDER BY s.next_invoice_date, s.id
|
||||
"""
|
||||
|
||||
subscriptions = execute_query(query)
|
||||
subscriptions = execute_query(query, tuple(params))
|
||||
|
||||
if not subscriptions:
|
||||
logger.info("✅ No subscriptions due for invoicing")
|
||||
@ -146,6 +192,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
coverage_start = None
|
||||
coverage_end = None
|
||||
|
||||
# Claim every subscription period. A competing worker will make this
|
||||
# transaction roll back before an order draft can be duplicated.
|
||||
claimed_run_ids = []
|
||||
for sub in subscriptions:
|
||||
cursor.execute(
|
||||
"""INSERT INTO subscription_billing_runs (subscription_id, period_start)
|
||||
VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING id""",
|
||||
(int(sub['id']), sub.get('period_start') or sub.get('next_invoice_date')),
|
||||
)
|
||||
claimed = cursor.fetchone()
|
||||
if not claimed:
|
||||
conn.rollback()
|
||||
logger.info("Subscription period already claimed by another worker")
|
||||
return 0
|
||||
claimed_run_ids.append(int(claimed[0]))
|
||||
|
||||
for sub in subscriptions:
|
||||
subscription_id = int(sub['id'])
|
||||
source_subscription_ids.append(subscription_id)
|
||||
@ -155,7 +217,13 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
line_items = json.loads(line_items)
|
||||
|
||||
period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||
period_end = _calculate_next_period_start(period_start, sub['billing_interval'])
|
||||
first_full_period_start = sub.get('first_full_period_start')
|
||||
if isinstance(first_full_period_start, str):
|
||||
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
|
||||
advance_periods = max(1, int(sub.get('advance_months') or 1))
|
||||
has_short_opening_period = bool(first_full_period_start and period_start < first_full_period_start)
|
||||
full_period_start = first_full_period_start if has_short_opening_period else period_start
|
||||
period_end = advance_billing_periods(full_period_start, sub['billing_interval'], advance_periods)
|
||||
if coverage_start is None or period_start < coverage_start:
|
||||
coverage_start = period_start
|
||||
if coverage_end is None or period_end > coverage_end:
|
||||
@ -171,9 +239,53 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
continue
|
||||
|
||||
product_number = str(item.get('product_id', 'SUB'))
|
||||
if has_short_opening_period:
|
||||
factor = prorated_30_day_factor(period_start, first_full_period_start)
|
||||
if factor > 0:
|
||||
prorated_unit_price = round(float(item.get('unit_price', 0)) * factor, 2)
|
||||
ordre_lines.append({
|
||||
"product": {
|
||||
"productNumber": product_number,
|
||||
"description": f"{item.get('description', '')} – skæv periode {period_start} til {first_full_period_start} (30 dage)"
|
||||
},
|
||||
"quantity": float(item.get('quantity', 1)),
|
||||
"unitNetPrice": prorated_unit_price,
|
||||
"totalNetAmount": round(float(item.get('quantity', 1)) * prorated_unit_price, 2),
|
||||
"discountPercentage": 0,
|
||||
"metadata": {
|
||||
"subscription_id": subscription_id,
|
||||
"proration_basis": "30_day",
|
||||
"proration_factor": factor,
|
||||
"period_from": str(period_start),
|
||||
"period_to": str(first_full_period_start),
|
||||
}
|
||||
})
|
||||
full_unit_price = float(item.get('unit_price', 0)) * advance_periods
|
||||
ordre_lines.append({
|
||||
"product": {
|
||||
"productNumber": product_number,
|
||||
"description": item.get('description', '') + (f" – {advance_periods} perioder" if advance_periods > 1 else '')
|
||||
},
|
||||
"quantity": float(item.get('quantity', 1)),
|
||||
"unitNetPrice": full_unit_price,
|
||||
"totalNetAmount": float(item.get('quantity', 1)) * full_unit_price,
|
||||
"discountPercentage": 0,
|
||||
"metadata": {
|
||||
"subscription_id": subscription_id,
|
||||
"asset_id": item.get('asset_id'),
|
||||
"period_from": str(full_period_start),
|
||||
"period_to": str(item.get('period_to') or period_end),
|
||||
"advance_periods": advance_periods,
|
||||
}
|
||||
})
|
||||
|
||||
first_invoice_items = sub.get('first_invoice_items', [])
|
||||
if isinstance(first_invoice_items, str):
|
||||
first_invoice_items = json.loads(first_invoice_items)
|
||||
for item in first_invoice_items:
|
||||
ordre_lines.append({
|
||||
"product": {
|
||||
"productNumber": str(item.get('product_id') or 'ENGANG'),
|
||||
"description": item.get('description', '')
|
||||
},
|
||||
"quantity": float(item.get('quantity', 1)),
|
||||
@ -182,9 +294,8 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
"discountPercentage": 0,
|
||||
"metadata": {
|
||||
"subscription_id": subscription_id,
|
||||
"asset_id": item.get('asset_id'),
|
||||
"period_from": str(item.get('period_from') or period_start),
|
||||
"period_to": str(item.get('period_to') or period_end),
|
||||
"first_invoice_item_id": item.get('id'),
|
||||
"one_time": True,
|
||||
}
|
||||
})
|
||||
|
||||
@ -237,6 +348,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
))
|
||||
|
||||
ordre_id = cursor.fetchone()[0]
|
||||
cursor.execute(
|
||||
"UPDATE subscription_billing_runs SET ordre_draft_id = %s WHERE id = ANY(%s)",
|
||||
(ordre_id, claimed_run_ids),
|
||||
)
|
||||
for sub, run_id in zip(subscriptions, claimed_run_ids):
|
||||
first_items = sub.get('first_invoice_items', [])
|
||||
if isinstance(first_items, str):
|
||||
first_items = json.loads(first_items)
|
||||
first_item_ids = [int(item['id']) for item in first_items if item.get('id')]
|
||||
if first_item_ids:
|
||||
cursor.execute(
|
||||
"""UPDATE sag_subscription_first_invoice_items
|
||||
SET billed_at = CURRENT_TIMESTAMP, billing_run_id = %s, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE subscription_id = %s AND id = ANY(%s) AND billed_at IS NULL""",
|
||||
(run_id, int(sub['id']), first_item_ids),
|
||||
)
|
||||
logger.info(
|
||||
"✅ Created aggregated ordre draft #%s for %s subscription(s)",
|
||||
ordre_id,
|
||||
@ -246,8 +373,19 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
|
||||
for sub in subscriptions:
|
||||
subscription_id = int(sub['id'])
|
||||
current_period_start = sub.get('period_start') or sub.get('next_invoice_date')
|
||||
new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
|
||||
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
|
||||
first_full_period_start = sub.get('first_full_period_start')
|
||||
if isinstance(first_full_period_start, str):
|
||||
first_full_period_start = datetime.strptime(first_full_period_start, '%Y-%m-%d').date()
|
||||
full_period_start = first_full_period_start if first_full_period_start and current_period_start < first_full_period_start else current_period_start
|
||||
new_period_start = advance_billing_periods(
|
||||
full_period_start, sub['billing_interval'], max(1, int(sub.get('advance_months') or 1))
|
||||
)
|
||||
new_next_invoice_date = billing_date_for_period(
|
||||
new_period_start,
|
||||
int(sub.get('billing_lead_months') or 0),
|
||||
sub.get('billing_schedule_type') or 'fixed_day',
|
||||
int(sub.get('billing_day') or 1),
|
||||
)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
|
||||
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>
|
||||
@ -380,6 +413,82 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="quickSetupModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content border-0 shadow-lg">
|
||||
<div class="modal-header">
|
||||
<div><div class="small text-uppercase text-muted fw-semibold">Hurtig oprettelse</div><h5 class="modal-title mb-0">Forbindelse og abonnement</h5></div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="quick-type-grid mb-3">
|
||||
<button class="quick-type-choice" type="button" data-quick-type="physical" onclick="selectQuickSetupType('physical')"><div class="fw-bold"><i class="bi bi-router me-2"></i>Fysisk forbindelse</div><div class="small text-muted mt-1">En direkte forbindelse fra en internetleverandør</div></button>
|
||||
<button class="quick-type-choice" type="button" data-quick-type="shared" onclick="selectQuickSetupType('shared')"><div class="fw-bold"><i class="bi bi-share me-2"></i>Delefiber / hovedforbindelse</div><div class="small text-muted mt-1">BMC Networks ejer fiberen, og den kan deles til flere kunder</div></button>
|
||||
<button class="quick-type-choice" type="button" data-quick-type="bmcnet" onclick="selectQuickSetupType('bmcnet')"><div class="fw-bold"><i class="bi bi-diagram-3 me-2"></i>BMCnet (delefiber)</div><div class="small text-muted mt-1">En kundeforbindelse under en delt BMC-hovedforbindelse</div></button>
|
||||
</div>
|
||||
<div class="alert alert-info mb-0" id="quickTypeHint">Vælg først hvilken type forbindelse du vil oprette.</div>
|
||||
<div class="row g-3 d-none mt-1" id="quickSetupFields">
|
||||
<div class="col-12"><div class="fw-semibold">1. Kunde</div></div>
|
||||
<div class="col-12" id="quickCustomerWrap">
|
||||
<input class="form-control" id="quickCustomerLookup" placeholder="Søg kundenavn eller CVR…" autocomplete="off">
|
||||
<input type="hidden" id="quickCustomerId">
|
||||
<div class="quick-customer-results d-none" id="quickCustomerResults"></div>
|
||||
</div>
|
||||
<div class="col-12 d-none" id="quickOwnerWrap"><div class="alert alert-success mb-0"><i class="bi bi-building-check me-2"></i><strong>Ejer: BMC Networks</strong> · forbindelsen markeres som delt hovedfiber.</div></div>
|
||||
<div class="col-12 quick-subscription-field"><hr class="my-1"><div class="fw-semibold">2. Abonnement</div></div>
|
||||
<div class="col-md-8 quick-subscription-field"><label class="form-label">Internetprodukt</label><select class="form-select" id="quickProduct"><option value="">Vælg internetprodukt…</option></select></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Pris pr. periode</label><input class="form-control" id="quickPrice" type="number" min="0" step="0.01"></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Startdato</label><input class="form-control" id="quickStartDate" type="date"></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Interval</label><select class="form-select" id="quickInterval"><option value="monthly">Månedlig</option><option value="quarterly">Kvartalsvis</option><option value="yearly">Årlig</option></select></div>
|
||||
<div class="col-md-4 quick-subscription-field"><label class="form-label">Fakturadag</label><input class="form-control" id="quickBillingDay" type="number" min="1" max="31" value="1"></div>
|
||||
<div class="col-12"><hr class="my-1"><div class="fw-semibold" id="quickConnectionStepTitle">3. Forbindelse</div></div>
|
||||
<div class="col-12 d-none" id="quickSharedHeadWrap"><label class="form-label">Delt hovedforbindelse</label><select class="form-select" id="quickSharedHead"><option value="">Vælg hovedforbindelse…</option></select><div class="small mt-1" id="quickSharedHeadSuggestion">Skriv installationsadressen, så foreslår systemet en hovedforbindelse.</div></div>
|
||||
<div class="col-12" id="quickConnectionModeWrap"><select class="form-select" id="quickConnectionMode" onchange="toggleQuickConnectionMode()"><option value="new">Opret ny forbindelse</option><option value="existing">Knyt en eksisterende forbindelse</option></select></div>
|
||||
<div class="col-12 d-none" id="quickExistingWrap"><select class="form-select" id="quickExistingConnection"><option value="">Vælg forbindelse…</option></select></div>
|
||||
<div class="col-md-6 quick-new-field"><label class="form-label">Navn</label><input class="form-control" id="quickConnectionName" placeholder="Fx Internet - Kundenavn"></div>
|
||||
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Leverandør</label><select class="form-select" id="quickVendor"><option value="">Vælg leverandør…</option></select></div>
|
||||
<div class="col-md-6 quick-new-field quick-physical-field"><label class="form-label">Kredsløbsnummer</label><input class="form-control" id="quickCircuit"></div>
|
||||
<div class="col-md-6 quick-new-field"><label class="form-label">Installationsadresse</label><input class="form-control" id="quickAddress"></div>
|
||||
<div class="col-12" id="quickAdvancedConnectionFields"><details class="border rounded p-3"><summary class="fw-semibold">Avanceret — valgfrit</summary><div class="row g-2 mt-2"><div class="col-md-4"><label class="form-label">Teknologi</label><input class="form-control" id="quickTechnology" placeholder="Fiber"></div><div class="col-md-4"><label class="form-label">Download Mbps</label><input class="form-control" id="quickDownload" type="number"></div><div class="col-md-4"><label class="form-label">Upload Mbps</label><input class="form-control" id="quickUpload" type="number"></div><div class="col-md-4"><label class="form-label">Kost pr. måned</label><input class="form-control" id="quickCost" type="number" min="0" step="0.01"></div><div class="col-md-8"><label class="form-label">Noter</label><input class="form-control" id="quickNotes"></div></div></details></div>
|
||||
<div class="col-12 d-none" id="quickBmcnetNotesWrap"><label class="form-label">Noter <span class="text-muted">(valgfrit)</span></label><input class="form-control" id="quickBmcnetNotes"></div>
|
||||
<div class="col-12"><div class="alert alert-light border mb-0" id="quickSetupSummary">Vælg kunde og produkt. Systemet opretter en abonnementssag, et abonnement som kladde og forbinder det hele.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><span class="small text-muted me-auto" id="quickSetupFeedback"></span><button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button><button class="btn btn-primary d-none" id="quickSetupSubmit" type="button" onclick="submitQuickSetup()"><i class="bi bi-check2-circle me-1"></i>Opret forbindelse og abonnement</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="ipNordicImportModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h5 class="modal-title mb-1">Importér IP Nordic-forbindelser</h5>
|
||||
<div class="internet-mini">Excel-filen kontrolleres før import. Kunder tilknyttes aldrig automatisk.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="form-label fw-semibold" for="ipNordicFileInput">IP Nordic Excel-fil (.xlsx)</label>
|
||||
<input class="form-control" id="ipNordicFileInput" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet">
|
||||
<div class="small mt-2" id="ipNordicImportFeedback" role="status"></div>
|
||||
<div class="table-responsive mt-3 d-none" id="ipNordicPreviewWrap">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead><tr><th>Handling</th><th>Rapporteret firma</th><th>Adresse</th><th>Start</th><th class="text-end">Kost</th><th class="text-end">Salg</th></tr></thead>
|
||||
<tbody id="ipNordicPreviewBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-outline-secondary" type="button" data-bs-dismiss="modal">Luk</button>
|
||||
<button class="btn btn-outline-primary" id="ipNordicPreviewBtn" type="button" onclick="previewIpNordicImport()">Kontrollér fil</button>
|
||||
<button class="btn btn-primary d-none" id="ipNordicCommitBtn" type="button" onclick="commitIpNordicImport()">Importér nye forbindelser</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="invoiceReviewModal" tabindex="-1" aria-hidden="true">
|
||||
@ -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,9 +6,10 @@ import re
|
||||
import hashlib
|
||||
import base64
|
||||
import html
|
||||
import math
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional, Dict
|
||||
from typing import Any, List, Optional, Dict
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
|
||||
@ -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]:
|
||||
@ -1002,15 +1028,34 @@ async def create_sag(request: Request, data: dict):
|
||||
status = _normalize_case_status(data.get("status"))
|
||||
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
|
||||
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until")
|
||||
ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id")
|
||||
current_user_id = _get_user_id_from_request(request)
|
||||
raw_responsible = data.get("ansvarlig_bruger_id") if "ansvarlig_bruger_id" in data else current_user_id
|
||||
ansvarlig_bruger_id = _coerce_optional_int(raw_responsible, "ansvarlig_bruger_id")
|
||||
assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
|
||||
|
||||
_validate_user_id(ansvarlig_bruger_id)
|
||||
_validate_group_id(assigned_group_id)
|
||||
|
||||
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
|
||||
pipeline = data.get("pipeline") 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):
|
||||
@ -1025,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:
|
||||
@ -1079,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(
|
||||
"""
|
||||
@ -1089,13 +1138,71 @@ async def create_sag(request: Request, data: dict):
|
||||
RETURNING *
|
||||
""",
|
||||
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id,
|
||||
assigned_group_id, _get_user_id_from_request(request), deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||
assigned_group_id, current_user_id, deadline, deferred_until, data.get("deferred_until_case_id"),
|
||||
data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
if not result:
|
||||
raise HTTPException(status_code=500, detail="Failed to create case")
|
||||
|
||||
if contact_ids:
|
||||
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
|
||||
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
|
||||
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
|
||||
if missing_contact_ids:
|
||||
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
|
||||
for index, contact_id in enumerate(contact_ids):
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
|
||||
VALUES (%s, %s, 'Kontakt', %s)""",
|
||||
(result["id"], contact_id, index == 0),
|
||||
)
|
||||
cursor.execute(
|
||||
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||
VALUES (%s, %s, FALSE, 'Kontakt')
|
||||
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
|
||||
(contact_id, data.get("customer_id")),
|
||||
)
|
||||
|
||||
if internet_connection_ids:
|
||||
cursor.execute(
|
||||
"""SELECT id FROM internet_connections_connections
|
||||
WHERE id = ANY(%s) AND deleted_at IS NULL""",
|
||||
(internet_connection_ids,),
|
||||
)
|
||||
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
|
||||
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
|
||||
if missing_connection_ids:
|
||||
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
|
||||
for connection_id in internet_connection_ids:
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
|
||||
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
|
||||
(result["id"], connection_id, current_user_id),
|
||||
)
|
||||
cursor.execute(
|
||||
"""INSERT INTO sag_tags (sag_id, tag_navn)
|
||||
SELECT %s, 'internet forbindelse'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM sag_tags
|
||||
WHERE sag_id = %s AND deleted_at IS NULL
|
||||
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
|
||||
)""",
|
||||
(result["id"], result["id"]),
|
||||
)
|
||||
|
||||
if telefoni_opkald_id:
|
||||
cursor.execute(
|
||||
"""UPDATE telefoni_opkald
|
||||
SET sag_id = %s,
|
||||
kontakt_id = COALESCE(%s, kontakt_id)
|
||||
WHERE id = %s
|
||||
RETURNING id""",
|
||||
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
|
||||
)
|
||||
if not cursor.fetchone():
|
||||
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
for item in normalized_items:
|
||||
if has_purchase_columns:
|
||||
@ -1112,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:
|
||||
@ -1126,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."""
|
||||
@ -1257,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 = []
|
||||
@ -1289,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
|
||||
@ -1311,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")
|
||||
@ -1536,13 +1721,14 @@ async def delete_todo_step(step_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to delete todo step")
|
||||
|
||||
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)])
|
||||
async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
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(
|
||||
"""
|
||||
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id
|
||||
SELECT *
|
||||
FROM sag_sager
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
@ -1560,6 +1746,23 @@ async def update_sag(sag_id: int, 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:
|
||||
@ -1674,6 +1877,18 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
}
|
||||
|
||||
change_messages: List[str] = []
|
||||
changed_by = "System"
|
||||
try:
|
||||
acting_user_id = _get_user_id_from_request(request)
|
||||
acting_user = execute_query_single(
|
||||
"""SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''),
|
||||
CONCAT('Bruger #', user_id::text)) AS name
|
||||
FROM users WHERE user_id = %s""",
|
||||
(acting_user_id,),
|
||||
)
|
||||
changed_by = str((acting_user or {}).get("name") or f"Bruger #{acting_user_id}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if "status" in updates:
|
||||
old_value = _norm(previous_row.get("status")) or "-"
|
||||
@ -1715,13 +1930,29 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
|
||||
if old_group != new_group:
|
||||
change_messages.append(f"👥 Gruppe ændret: {old_group} → {new_group}")
|
||||
|
||||
explicitly_logged = {"status", "template_key", "type", "priority", "ansvarlig_bruger_id", "assigned_group_id"}
|
||||
field_labels = {
|
||||
"titel": "Titel", "beskrivelse": "Beskrivelse", "customer_id": "Kunde",
|
||||
"start_date": "Startdato", "deadline": "Deadline", "deferred_until": "Udskudt til",
|
||||
"deferred_until_case_id": "Afventer sag", "deferred_until_status": "Afventer status",
|
||||
"supplier_flow_type": "Leverandørflow", "supplier_flow_confidence": "Leverandørflow-sikkerhed",
|
||||
}
|
||||
for field in allowed_fields:
|
||||
if field not in updates or field in explicitly_logged:
|
||||
continue
|
||||
old_value = _norm(previous_row.get(field)) or "-"
|
||||
new_value = _norm(updated_row.get(field)) or "-"
|
||||
if old_value != new_value:
|
||||
label = field_labels.get(field, field.replace("_", " ").capitalize())
|
||||
change_messages.append(f"✏️ {label} ændret: {old_value} → {new_value}")
|
||||
|
||||
for message in change_messages:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(sag_id, "System", message, True),
|
||||
(sag_id, changed_by, message, True),
|
||||
)
|
||||
except Exception as comment_log_error:
|
||||
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, comment_log_error)
|
||||
@ -2382,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."""
|
||||
@ -3049,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."""
|
||||
@ -3146,6 +3437,125 @@ async def create_sale_item(sag_id: int, data: dict):
|
||||
raise HTTPException(status_code=500, detail="Failed to create sale item")
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/sale-items/batch")
|
||||
async def create_sale_items_batch(sag_id: int, data: dict):
|
||||
"""Create purchase and/or sale lines from one selected product atomically.
|
||||
|
||||
A product lookup is one business action, but it can have two accounting
|
||||
consequences: an expected supplier purchase and a customer sale. Keeping
|
||||
both inserts in one transaction prevents half-created product flows.
|
||||
"""
|
||||
lines = data.get("lines") if isinstance(data, dict) else None
|
||||
if not isinstance(lines, list) or not lines:
|
||||
raise HTTPException(status_code=400, detail="lines must contain at least one item")
|
||||
if len(lines) > 2:
|
||||
raise HTTPException(status_code=400, detail="A product action can contain at most one purchase and one sale line")
|
||||
|
||||
try:
|
||||
case_exists = execute_query_single(
|
||||
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||||
(sag_id,),
|
||||
)
|
||||
if not case_exists:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
if not _table_exists("sag_salgsvarer"):
|
||||
raise HTTPException(status_code=503, detail="Varelinjer er ikke klar endnu")
|
||||
|
||||
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
|
||||
normalized_lines = []
|
||||
seen_types = set()
|
||||
for raw_line in lines:
|
||||
if not isinstance(raw_line, dict):
|
||||
raise HTTPException(status_code=400, detail="Every line must be an object")
|
||||
item_type = str(raw_line.get("type") or "").strip().lower()
|
||||
if item_type not in {"sale", "purchase"}:
|
||||
raise HTTPException(status_code=400, detail="type must be sale or purchase")
|
||||
if item_type in seen_types:
|
||||
raise HTTPException(status_code=400, detail="Only one line of each type is allowed")
|
||||
seen_types.add(item_type)
|
||||
|
||||
description = str(raw_line.get("description") or "").strip()
|
||||
if not description:
|
||||
raise HTTPException(status_code=400, detail="description is required")
|
||||
try:
|
||||
amount = float(raw_line.get("amount"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=400, detail="amount must be a number")
|
||||
if amount < 0:
|
||||
raise HTTPException(status_code=400, detail="amount cannot be negative")
|
||||
|
||||
status = str(raw_line.get("status") or "draft").strip().lower()
|
||||
if status not in {"draft", "confirmed", "cancelled"}:
|
||||
raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled")
|
||||
|
||||
purchase_purpose = _normalize_purchase_purpose(raw_line.get("purchase_purpose"), item_type)
|
||||
supplier_invoice_id = supplier_invoice_line_id = None
|
||||
if has_purchase_columns and item_type == "purchase":
|
||||
supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability(
|
||||
raw_line.get("supplier_invoice_id"),
|
||||
raw_line.get("supplier_invoice_line_id"),
|
||||
)
|
||||
|
||||
normalized_lines.append({
|
||||
"type": item_type,
|
||||
"description": description,
|
||||
"quantity": raw_line.get("quantity"),
|
||||
"unit": raw_line.get("unit"),
|
||||
"unit_price": raw_line.get("unit_price"),
|
||||
"amount": amount,
|
||||
"currency": str(raw_line.get("currency") or "DKK").upper(),
|
||||
"status": status,
|
||||
"line_date": raw_line.get("line_date"),
|
||||
"external_ref": raw_line.get("external_ref"),
|
||||
"product_id": raw_line.get("product_id"),
|
||||
"purchase_purpose": purchase_purpose,
|
||||
"supplier_invoice_id": supplier_invoice_id,
|
||||
"supplier_invoice_line_id": supplier_invoice_line_id,
|
||||
})
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
created = []
|
||||
for line in normalized_lines:
|
||||
if has_purchase_columns:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id,
|
||||
purchase_purpose, supplier_invoice_id, supplier_invoice_line_id)
|
||||
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s,
|
||||
%(purchase_purpose)s, %(supplier_invoice_id)s, %(supplier_invoice_line_id)s)
|
||||
RETURNING *
|
||||
""",
|
||||
{"sag_id": sag_id, **line},
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO sag_salgsvarer
|
||||
(sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id)
|
||||
VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s)
|
||||
RETURNING *
|
||||
""",
|
||||
{"sag_id": sag_id, **line},
|
||||
)
|
||||
created.append(dict(cursor.fetchone()))
|
||||
conn.commit()
|
||||
logger.info("✅ Created %s linked product lines for case %s", len(created), sag_id)
|
||||
return {"created": created}
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
release_db_connection(conn)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("❌ Error creating product lines for case %s: %s", sag_id, e, exc_info=True)
|
||||
raise HTTPException(status_code=500, detail="Failed to create product lines")
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/sale-items/{item_id}")
|
||||
async def get_sale_item(sag_id: int, item_id: int):
|
||||
"""Get a single sale item for a case."""
|
||||
@ -3322,6 +3732,100 @@ async def get_kommentarer(sag_id: int):
|
||||
raise HTTPException(status_code=500, detail="Failed to get comments")
|
||||
|
||||
|
||||
def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]:
|
||||
"""Return non-billable source activities linked directly to cases."""
|
||||
if not case_ids:
|
||||
return []
|
||||
|
||||
placeholders = ",".join(["%s"] * len(case_ids))
|
||||
activities: List[Dict[str, Any]] = []
|
||||
|
||||
if _table_exists("telefoni_opkald"):
|
||||
call_query = f"""
|
||||
SELECT t.id, t.sag_id, t.direction, t.ekstern_nummer, t.intern_extension,
|
||||
t.started_at, t.ended_at, t.duration_sec,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name,
|
||||
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name
|
||||
FROM telefoni_opkald t
|
||||
LEFT JOIN users u ON u.user_id = t.bruger_id
|
||||
LEFT JOIN contacts c ON c.id = t.kontakt_id
|
||||
WHERE t.sag_id IN ({placeholders})
|
||||
ORDER BY t.started_at DESC, t.id DESC
|
||||
LIMIT 600
|
||||
"""
|
||||
for row in execute_query(call_query, tuple(case_ids)) or []:
|
||||
started_at = row.get("started_at")
|
||||
if not started_at:
|
||||
continue
|
||||
direction = str(row.get("direction") or "inbound").lower()
|
||||
duration_seconds = row.get("duration_sec")
|
||||
if duration_seconds is not None and not 0 <= float(duration_seconds) <= 43200:
|
||||
duration_seconds = None
|
||||
if duration_seconds is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime):
|
||||
calculated_seconds = int((row["ended_at"] - started_at).total_seconds())
|
||||
duration_seconds = calculated_seconds if 0 <= calculated_seconds <= 43200 else None
|
||||
activities.append({
|
||||
"id": f"call:{row.get('id')}",
|
||||
"source_id": row.get("id"),
|
||||
"activity_type": "call",
|
||||
"sag_id": row.get("sag_id"),
|
||||
"timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at),
|
||||
"ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"),
|
||||
"duration_minutes": round(float(duration_seconds or 0) / 60, 1),
|
||||
"actor_name": str(row.get("actor_name") or "Ukendt medarbejder"),
|
||||
"contact_name": row.get("contact_name"),
|
||||
"number": row.get("ekstern_nummer"),
|
||||
"direction": direction,
|
||||
"title": "Udgående opkald" if direction == "outbound" else "Indgående opkald",
|
||||
"status": "completed" if row.get("ended_at") else "started",
|
||||
})
|
||||
|
||||
if _table_exists("anydesk_sessions"):
|
||||
anydesk_query = f"""
|
||||
SELECT a.id, a.anydesk_session_id, a.sag_id, a.started_at, a.ended_at,
|
||||
a.duration_minutes, a.status,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name,
|
||||
NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name
|
||||
FROM anydesk_sessions a
|
||||
LEFT JOIN users u ON u.user_id = a.created_by_user_id
|
||||
LEFT JOIN contacts c ON c.id = a.contact_id
|
||||
WHERE a.sag_id IN ({placeholders})
|
||||
ORDER BY a.started_at DESC, a.id DESC
|
||||
LIMIT 600
|
||||
"""
|
||||
for row in execute_query(anydesk_query, tuple(case_ids)) or []:
|
||||
started_at = row.get("started_at")
|
||||
if not started_at:
|
||||
continue
|
||||
duration_minutes = row.get("duration_minutes")
|
||||
if duration_minutes is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime):
|
||||
duration_minutes = max(0, round((row["ended_at"] - started_at).total_seconds() / 60, 1))
|
||||
activities.append({
|
||||
"id": f"anydesk:{row.get('id')}",
|
||||
"source_id": row.get("id"),
|
||||
"external_id": row.get("anydesk_session_id"),
|
||||
"activity_type": "anydesk",
|
||||
"sag_id": row.get("sag_id"),
|
||||
"timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at),
|
||||
"ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"),
|
||||
"duration_minutes": float(duration_minutes or 0),
|
||||
"actor_name": str(row.get("actor_name") or "Ukendt medarbejder"),
|
||||
"contact_name": row.get("contact_name"),
|
||||
"title": "AnyDesk-session",
|
||||
"status": str(row.get("status") or "unknown"),
|
||||
})
|
||||
|
||||
activities.sort(key=lambda item: str(item.get("timestamp") or ""), reverse=True)
|
||||
return activities
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/linked-activities")
|
||||
async def get_case_linked_activities(sag_id: int):
|
||||
_assert_sag_exists(sag_id)
|
||||
activities = _get_case_linked_activities([sag_id])
|
||||
return {"activities": activities, "total": len(activities)}
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/timeline")
|
||||
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
"""Return a unified timeline for the case with optional child cases."""
|
||||
@ -3429,6 +3933,46 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
|
||||
events = []
|
||||
|
||||
for activity in _get_case_linked_activities(case_ids):
|
||||
duration = float(activity.get("duration_minutes") or 0)
|
||||
duration_text = f"{duration:g} min" if duration > 0 else "Varighed ikke registreret"
|
||||
contact = str(activity.get("contact_name") or activity.get("number") or "Ukendt kontakt")
|
||||
activity_type = str(activity.get("activity_type") or "activity")
|
||||
description = f"{contact}\n{duration_text}"
|
||||
if activity_type == "call" and activity.get("number") and activity.get("contact_name"):
|
||||
description = f"{contact} · {activity.get('number')}\n{duration_text}"
|
||||
events.append({
|
||||
"id": activity.get("id"),
|
||||
"event_type": activity_type,
|
||||
"event_subtype": activity.get("direction") or activity.get("status"),
|
||||
"source": "call" if activity_type == "call" else "anydesk",
|
||||
"timestamp": activity.get("timestamp"),
|
||||
"sag_id": activity.get("sag_id"),
|
||||
"sag_titel": next((row.get("titel") for row in case_rows if row.get("id") == activity.get("sag_id")), None),
|
||||
"forfatter": activity.get("actor_name") or "System",
|
||||
"title": activity.get("title"),
|
||||
"description": description,
|
||||
})
|
||||
|
||||
case_details_query = f"""
|
||||
SELECT s.id, s.titel, s.created_at,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name
|
||||
FROM sag_sager s
|
||||
LEFT JOIN users u ON u.user_id = s.created_by_user_id
|
||||
WHERE s.id IN ({placeholders})
|
||||
"""
|
||||
for row in execute_query(case_details_query, tuple(case_ids)) or []:
|
||||
created_at = row.get("created_at")
|
||||
if not created_at:
|
||||
continue
|
||||
events.append({
|
||||
"id": f"case:{row.get('id')}:created", "event_type": "case", "event_subtype": "created",
|
||||
"source": "case", "timestamp": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at),
|
||||
"sag_id": row.get("id"), "sag_titel": row.get("titel"),
|
||||
"forfatter": str(row.get("created_by_name") or "System"),
|
||||
"title": "Sag oprettet", "description": str(row.get("titel") or ""),
|
||||
})
|
||||
|
||||
for row in comments:
|
||||
created_at = row.get("created_at")
|
||||
if not created_at:
|
||||
@ -3494,10 +4038,12 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
if _table_exists("sag_reminders"):
|
||||
reminders_query = f"""
|
||||
SELECT r.id, r.sag_id, r.title, r.message, r.event_type,
|
||||
r.next_check_at, r.scheduled_at,
|
||||
r.next_check_at, r.scheduled_at, r.created_at,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name,
|
||||
s.titel AS source_sag_titel
|
||||
FROM sag_reminders r
|
||||
LEFT JOIN sag_sager s ON s.id = r.sag_id
|
||||
LEFT JOIN users u ON u.user_id = r.created_by_user_id
|
||||
WHERE r.deleted_at IS NULL
|
||||
AND r.sag_id IN ({placeholders})
|
||||
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC
|
||||
@ -3506,13 +4052,17 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
reminders = execute_query(reminders_query, tuple(case_ids)) or []
|
||||
|
||||
for row in reminders:
|
||||
start_value = row.get("next_check_at") or row.get("scheduled_at")
|
||||
start_value = row.get("created_at") or row.get("next_check_at") or row.get("scheduled_at")
|
||||
if not start_value:
|
||||
continue
|
||||
|
||||
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
|
||||
title = str(row.get("title") or "Påmindelse")
|
||||
message = str(row.get("message") or "")
|
||||
scheduled_value = row.get("scheduled_at") or row.get("next_check_at")
|
||||
if scheduled_value:
|
||||
scheduled_text = scheduled_value.isoformat() if isinstance(scheduled_value, datetime) else str(scheduled_value)
|
||||
message = f"{message}\nPlanlagt til: {scheduled_text}".strip()
|
||||
event_kind = str(row.get("event_type") or "reminder")
|
||||
|
||||
events.append({
|
||||
@ -3523,7 +4073,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
"timestamp": ts,
|
||||
"sag_id": row.get("sag_id"),
|
||||
"sag_titel": row.get("source_sag_titel"),
|
||||
"forfatter": "Reminder",
|
||||
"forfatter": str(row.get("created_by_name") or "System"),
|
||||
"title": f"Påmindelse · {title}",
|
||||
"description": message,
|
||||
})
|
||||
@ -3532,9 +4082,15 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
time_query = f"""
|
||||
SELECT t.id, t.sag_id, t.description, t.entry_type,
|
||||
t.worked_date, t.created_at, t.approved_hours, t.original_hours,
|
||||
COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''),
|
||||
NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name,
|
||||
COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''),
|
||||
CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name,
|
||||
s.titel AS source_sag_titel
|
||||
FROM tmodule_times t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users employee ON employee.user_id = t.medarbejder_id
|
||||
LEFT JOIN users recorder ON LOWER(recorder.username) = LOWER(t.user_name)
|
||||
WHERE t.sag_id IN ({placeholders})
|
||||
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
|
||||
LIMIT 600
|
||||
@ -3542,7 +4098,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
time_entries = execute_query(time_query, tuple(case_ids)) or []
|
||||
|
||||
for row in time_entries:
|
||||
raw_ts = row.get("worked_date") or row.get("created_at")
|
||||
raw_ts = row.get("created_at") or row.get("worked_date")
|
||||
if not raw_ts:
|
||||
continue
|
||||
|
||||
@ -3552,6 +4108,8 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
hours = row.get("original_hours")
|
||||
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
|
||||
description = str(row.get("description") or "")
|
||||
employee_name = str(row.get("employee_name") or "Ukendt medarbejder")
|
||||
description = f"Registreret for: {employee_name}\n{description}".strip()
|
||||
entry_type = str(row.get("entry_type") or "arbejde")
|
||||
|
||||
events.append({
|
||||
@ -3562,11 +4120,70 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
|
||||
"timestamp": ts,
|
||||
"sag_id": row.get("sag_id"),
|
||||
"sag_titel": row.get("source_sag_titel"),
|
||||
"forfatter": "Tid",
|
||||
"forfatter": str(row.get("actor_name") or "System"),
|
||||
"title": f"Tidsregistrering · {hours_text}",
|
||||
"description": description,
|
||||
})
|
||||
|
||||
if _table_exists("sag_tags"):
|
||||
tag_query = f"""
|
||||
SELECT t.id, t.sag_id, t.tag_navn, t.created_at, t.closed_at, s.titel AS source_sag_titel
|
||||
FROM sag_tags t LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
WHERE t.sag_id IN ({placeholders}) LIMIT 600
|
||||
"""
|
||||
for row in execute_query(tag_query, tuple(case_ids)) or []:
|
||||
for action, timestamp in (("tilføjet", row.get("created_at")), ("lukket", row.get("closed_at"))):
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"tag:{row.get('id')}:{action}", "event_type": "tag", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": "System",
|
||||
"title": f"Tag {action}", "description": str(row.get("tag_navn") or ""),
|
||||
})
|
||||
|
||||
if _table_exists("sag_todo_steps"):
|
||||
todo_query = f"""
|
||||
SELECT t.*, s.titel AS source_sag_titel,
|
||||
COALESCE(NULLIF(TRIM(uc.full_name), ''), NULLIF(TRIM(uc.username), ''), 'System') AS created_by_name,
|
||||
COALESCE(NULLIF(TRIM(ud.full_name), ''), NULLIF(TRIM(ud.username), ''), 'System') AS completed_by_name
|
||||
FROM sag_todo_steps t
|
||||
LEFT JOIN sag_sager s ON s.id = t.sag_id
|
||||
LEFT JOIN users uc ON uc.user_id = t.created_by_user_id
|
||||
LEFT JOIN users ud ON ud.user_id = t.completed_by_user_id
|
||||
WHERE t.sag_id IN ({placeholders}) LIMIT 800
|
||||
"""
|
||||
for row in execute_query(todo_query, tuple(case_ids)) or []:
|
||||
todo_events = (("oprettet", row.get("created_at"), row.get("created_by_name")),
|
||||
("færdiggjort", row.get("completed_at"), row.get("completed_by_name")),
|
||||
("slettet", row.get("deleted_at"), "System"))
|
||||
for action, timestamp, actor in todo_events:
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"todo:{row.get('id')}:{action}", "event_type": "todo", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(actor or "System"),
|
||||
"title": f"Tjeklistepunkt {action}", "description": str(row.get("title") or ""),
|
||||
})
|
||||
|
||||
if _table_exists("sag_relationer"):
|
||||
relation_query = f"""
|
||||
SELECT id, kilde_sag_id, målsag_id, relationstype, created_at, deleted_at
|
||||
FROM sag_relationer
|
||||
WHERE kilde_sag_id IN ({placeholders}) OR målsag_id IN ({placeholders}) LIMIT 600
|
||||
"""
|
||||
for row in execute_query(relation_query, tuple(case_ids) + tuple(case_ids)) or []:
|
||||
current_id = row.get("kilde_sag_id") if row.get("kilde_sag_id") in case_ids else row.get("målsag_id")
|
||||
other_id = row.get("målsag_id") if current_id == row.get("kilde_sag_id") else row.get("kilde_sag_id")
|
||||
for action, timestamp in (("oprettet", row.get("created_at")), ("fjernet", row.get("deleted_at"))):
|
||||
if timestamp:
|
||||
events.append({
|
||||
"id": f"relation:{row.get('id')}:{action}", "event_type": "relation", "event_subtype": action,
|
||||
"source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp),
|
||||
"sag_id": current_id, "sag_titel": next((r.get("titel") for r in case_rows if r.get("id") == current_id), None),
|
||||
"forfatter": "System", "title": f"Relation {action}",
|
||||
"description": f"{row.get('relationstype') or 'Relateret til'} · Sag #{other_id}",
|
||||
})
|
||||
|
||||
def _sort_key(item: Dict) -> datetime:
|
||||
raw = str(item.get("timestamp") or "")
|
||||
try:
|
||||
|
||||
@ -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"}
|
||||
|
||||
@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")
|
||||
|
||||
@router.post(
|
||||
"/sag/{sag_id}/solution",
|
||||
response_model=Solution,
|
||||
dependencies=[Depends(case_edit_access)],
|
||||
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]"),
|
||||
)
|
||||
async def create_solution(sag_id: int, solution: SolutionCreate, request: Request):
|
||||
"""Create a solution for a case."""
|
||||
try:
|
||||
# Check if case exists
|
||||
case_check = execute_query("SELECT id FROM sag_sager WHERE id = %s", (sag_id,))
|
||||
if not case_check:
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
|
||||
# Check if solution already exists
|
||||
check = execute_query("SELECT id FROM sag_solutions WHERE sag_id = %s", (sag_id,))
|
||||
if check:
|
||||
raise HTTPException(status_code=400, detail="Solution already exists for this case")
|
||||
|
||||
query = """
|
||||
INSERT INTO sag_solutions
|
||||
(sag_id, title, description, solution_type, result, created_by_user_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
"""
|
||||
params = (
|
||||
sag_id,
|
||||
solution.title,
|
||||
solution.description,
|
||||
solution.solution_type,
|
||||
solution.result,
|
||||
getattr(request.state, "user_id", None)
|
||||
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),
|
||||
)
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
# 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)
|
||||
@router.get("/sag/{sag_id}/solution", response_model=Optional[Solution])
|
||||
async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)):
|
||||
result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,))
|
||||
return result[0] if result else None
|
||||
|
||||
if not set_clauses:
|
||||
raise HTTPException(status_code=400, detail="No fields to update")
|
||||
|
||||
set_clauses.append("updated_at = NOW()")
|
||||
@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)}
|
||||
|
||||
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")
|
||||
@router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
|
||||
async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)):
|
||||
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
|
||||
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
||||
existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,))
|
||||
if existing:
|
||||
detail = "Sagen har en arkiveret løsning, som skal gendannes" if existing.get("deleted_at") else "Der findes allerede en løsning på sagen"
|
||||
raise HTTPException(status_code=409, detail=detail)
|
||||
data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"}))
|
||||
user_id = _user_id(current_user)
|
||||
result = execute_query(
|
||||
"""INSERT INTO sag_solutions (
|
||||
sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround,
|
||||
visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""",
|
||||
(sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id),
|
||||
)
|
||||
created = result[0]
|
||||
_version_solution(created, user_id, "Løsning oprettet")
|
||||
return created
|
||||
|
||||
|
||||
@router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)])
|
||||
async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)):
|
||||
if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)):
|
||||
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
||||
data = _normalize_payload(updates.model_dump(exclude_unset=True))
|
||||
change_note = data.pop("change_note", None)
|
||||
allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"}
|
||||
fields, params = [], []
|
||||
for key, value in data.items():
|
||||
if key not in allowed:
|
||||
continue
|
||||
fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else ""))
|
||||
params.append(json.dumps(value) if key in {"tags", "products"} else value)
|
||||
if not fields:
|
||||
raise HTTPException(status_code=400, detail="Ingen ændringer at gemme")
|
||||
user_id = _user_id(current_user)
|
||||
fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"])
|
||||
params.extend([user_id, sag_id])
|
||||
updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0]
|
||||
_version_solution(updated, user_id, change_note or "Løsning redigeret")
|
||||
return updated
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)])
|
||||
async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)):
|
||||
action = str((await request.json()).get("action") or "").lower()
|
||||
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
||||
user_id = _user_id(current_user)
|
||||
if action == "submit":
|
||||
status = "pending"
|
||||
elif action == "approve":
|
||||
if not str(solution.get("description") or "").strip():
|
||||
raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse")
|
||||
status = "approved"
|
||||
elif action in {"reject", "outdate"}:
|
||||
status = "rejected" if action == "reject" else "outdated"
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail="Ukendt handling")
|
||||
if status == "approved":
|
||||
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id))
|
||||
else:
|
||||
updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id))
|
||||
_version_solution(updated, user_id, f"Status ændret til {status}")
|
||||
return updated
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)])
|
||||
async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||
solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,))
|
||||
if not solution:
|
||||
raise HTTPException(status_code=404, detail="Løsningen findes ikke")
|
||||
if solution.get("approval_status") != "approved":
|
||||
raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse")
|
||||
publish_text = "\n".join(str(solution.get(field) or "") for field in ("title", "problem", "root_cause", "investigation", "description", "workaround"))
|
||||
_clean_publish_text, secret_warnings = _redact_sensitive(publish_text)
|
||||
if secret_warnings:
|
||||
raise HTTPException(status_code=422, detail="Løsningen indeholder muligvis password, token eller anden hemmelighed. Fjern det før udgivelse.")
|
||||
customer_id = None
|
||||
if solution.get("visibility") == "customer":
|
||||
customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,))
|
||||
if not customer:
|
||||
raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen")
|
||||
customer_id = customer["customer_id"]
|
||||
description = str(solution.get("description") or "").strip()
|
||||
summary = description[:300] + ("…" if len(description) > 300 else "")
|
||||
article = execute_query_single(
|
||||
"""INSERT INTO knowledge_articles (
|
||||
solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround,
|
||||
visibility,status,tags,products,published_by_user_id,reviewed_at
|
||||
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW())
|
||||
ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title,
|
||||
summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause,
|
||||
investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround,
|
||||
visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published',
|
||||
version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id,
|
||||
reviewed_at=NOW(),updated_at=NOW(),archived_at=NULL,archived_by_user_id=NULL RETURNING *""",
|
||||
(solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)),
|
||||
)
|
||||
return article
|
||||
|
||||
|
||||
@router.get("/sag/{sag_id}/knowledge-suggestions")
|
||||
async def case_knowledge_suggestions(
|
||||
sag_id: int,
|
||||
limit: int = Query(6, ge=1, le=20),
|
||||
_current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
_case, articles = _case_knowledge_context(sag_id, limit)
|
||||
items = []
|
||||
for article in articles:
|
||||
item = dict(article)
|
||||
item["reason"] = "Matcher sagens titel, beskrivelse eller tags"
|
||||
item["relevance_percent"] = min(99, max(1, round(float(item.get("relevance") or 0) * 100)))
|
||||
items.append(item)
|
||||
return {"items": items, "total": len(items), "source": "approved_knowledge_only"}
|
||||
|
||||
|
||||
@router.post("/sag/{sag_id}/solution/ai-draft", dependencies=[Depends(case_edit_access)])
|
||||
async def generate_solution_ai_draft(sag_id: int, current_user: dict = Depends(get_current_user)):
|
||||
case, articles = _case_knowledge_context(sag_id, 5)
|
||||
comments = execute_query(
|
||||
"""SELECT id,forfatter,indhold,created_at FROM sag_kommentarer
|
||||
WHERE sag_id=%s AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 60""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
emails = execute_query(
|
||||
"""SELECT DISTINCT e.id,e.subject,e.sender_email,e.recipient_email,e.body_text,e.received_date
|
||||
FROM email_messages e LEFT JOIN sag_emails se ON se.email_id=e.id
|
||||
WHERE e.deleted_at IS NULL AND (se.sag_id=%s OR e.linked_case_id=%s)
|
||||
ORDER BY e.received_date DESC NULLS LAST LIMIT 25""",
|
||||
(sag_id, sag_id),
|
||||
) or []
|
||||
time_entries = execute_query(
|
||||
"""SELECT id,description,original_hours,worked_date FROM tmodule_times
|
||||
WHERE sag_id=%s ORDER BY worked_date DESC NULLS LAST LIMIT 30""",
|
||||
(sag_id,),
|
||||
) or []
|
||||
|
||||
sections = [f"SAG #{sag_id}\nTitel: {case.get('titel') or ''}\nBeskrivelse: {case.get('beskrivelse') or ''}"]
|
||||
if comments:
|
||||
sections.append("KOMMENTARER:\n" + "\n".join(f"[Kommentar {row['id']}] {row.get('forfatter') or 'Ukendt'}: {row.get('indhold') or ''}" for row in reversed(comments)))
|
||||
if emails:
|
||||
sections.append("MAILS:\n" + "\n".join(f"[Mail {row['id']}] {row.get('subject') or '(uden emne)'}: {row.get('body_text') or ''}" for row in reversed(emails)))
|
||||
if time_entries:
|
||||
sections.append("TIDSNOTER:\n" + "\n".join(f"[Tid {row['id']}] {row.get('description') or ''}" for row in time_entries))
|
||||
if articles:
|
||||
sections.append("GODKENDT VIDEN:\n" + "\n".join(f"[Artikel {row['id']}] {row['title']}\nProblem: {row.get('problem') or ''}\nLøsning: {row.get('solution') or ''}" for row in articles))
|
||||
context, warnings = _redact_sensitive("\n\n".join(sections))
|
||||
context = context[:24000]
|
||||
prompt = """Du laver et UDKAST til dokumentation af en IT-supportløsning.
|
||||
Returner KUN gyldig JSON med felterne title, problem, root_cause, investigation, description, workaround, solution_type, result, tags, products, confidence, uncertainties og source_refs.
|
||||
Regler:
|
||||
- Brug kun fakta fra den vedlagte sag og de nummererede kilder.
|
||||
- Opfind aldrig en årsag eller handling. Sæt feltet tomt og beskriv manglen i uncertainties.
|
||||
- description er den endelige løsning. Hvis sagen endnu ikke dokumenterer en løsning, skal description være tom.
|
||||
- source_refs skal indeholde de præcise kilder, fx "Kommentar 12" eller "Artikel 4".
|
||||
- Medtag aldrig passwords, tokens, API-nøgler eller andre hemmeligheder.
|
||||
- Skriv kort, teknisk og professionelt på dansk.
|
||||
- confidence er et tal mellem 0 og 1.
|
||||
"""
|
||||
service = CaseAnalysisService()
|
||||
# A full case history is materially larger than QuickCreate input. Keep the
|
||||
# global QuickCreate timeout unchanged, but allow the local model time to
|
||||
# produce a source-grounded solution draft.
|
||||
service.ai_timeout = max(service.ai_timeout, 60)
|
||||
result = await service._call_ollama(prompt, context)
|
||||
if not result:
|
||||
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,),
|
||||
)
|
||||
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
|
||||
|
||||
@ -838,7 +909,13 @@ async def sag_detaljer(request: Request, sag_id: int):
|
||||
t.ekstern_nummer,
|
||||
t.started_at,
|
||||
t.ended_at,
|
||||
t.duration_sec,
|
||||
CASE
|
||||
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
|
||||
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
|
||||
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
|
||||
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
|
||||
ELSE NULL
|
||||
END AS duration_sec,
|
||||
u.username,
|
||||
u.full_name,
|
||||
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
|
||||
@ -1147,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
|
||||
@ -1176,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
|
||||
|
||||
@ -1193,7 +1284,13 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
|
||||
t.ekstern_nummer,
|
||||
t.started_at,
|
||||
t.ended_at,
|
||||
t.duration_sec,
|
||||
CASE
|
||||
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
|
||||
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
|
||||
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
|
||||
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
|
||||
ELSE NULL
|
||||
END AS duration_sec,
|
||||
u.username,
|
||||
u.full_name,
|
||||
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
|
||||
|
||||
@ -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);
|
||||
@ -1234,18 +1430,35 @@
|
||||
updateCaseTypeSections();
|
||||
}
|
||||
|
||||
async function selectCurrentUserAsResponsible() {
|
||||
const select = document.getElementById('ansvarlig_bruger_id');
|
||||
if (!select || select.value) return;
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/me', { credentials: 'include' });
|
||||
if (!response.ok) return;
|
||||
const user = await response.json();
|
||||
const userId = user.user_id ?? user.id;
|
||||
if (userId && Array.from(select.options).some(option => option.value === String(userId))) {
|
||||
select.value = String(userId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Kunne ikke forvælge ansvarlig bruger', error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
initializeSearch();
|
||||
loadCaseTypesSelect();
|
||||
loadPipelineStages();
|
||||
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');
|
||||
@ -1318,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,
|
||||
@ -1329,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', {
|
||||
@ -1343,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";
|
||||
@ -1420,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")
|
||||
|
||||
1
app/modules/shipmondo/__init__.py
Normal file
1
app/modules/shipmondo/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo shipping integration."""
|
||||
1
app/modules/shipmondo/backend/__init__.py
Normal file
1
app/modules/shipmondo/backend/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo backend package."""
|
||||
75
app/modules/shipmondo/backend/api_client.py
Normal file
75
app/modules/shipmondo/backend/api_client.py
Normal file
@ -0,0 +1,75 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShipmondoApiClient:
|
||||
def __init__(self) -> None:
|
||||
self.base_url = (settings.SHIPMONDO_API_BASE_URL or "").rstrip("/")
|
||||
self.timeout_seconds = max(5, int(settings.SHIPMONDO_TIMEOUT_SECONDS or 30))
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return bool(self.base_url and settings.SHIPMONDO_API_USER and settings.SHIPMONDO_API_KEY)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_payload: Optional[Dict[str, Any]] = None,
|
||||
) -> Any:
|
||||
if not self.configured:
|
||||
raise RuntimeError("Shipmondo API credentials are not configured")
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self.timeout_seconds)
|
||||
auth = aiohttp.BasicAuth(settings.SHIPMONDO_API_USER, settings.SHIPMONDO_API_KEY)
|
||||
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
||||
url = f"{self.base_url}/{path.lstrip('/')}"
|
||||
|
||||
async with aiohttp.ClientSession(timeout=timeout, auth=auth, headers=headers) as session:
|
||||
async with session.request(method, url, params=params, json=json_payload) as response:
|
||||
body = await response.text()
|
||||
if response.status >= 400:
|
||||
detail = ""
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
detail = str(parsed.get("message") or parsed.get("error") or parsed.get("errors") or "")
|
||||
except (ValueError, AttributeError):
|
||||
detail = body.strip()
|
||||
detail = detail[:300]
|
||||
logger.warning("Shipmondo request failed (%s %s): HTTP %s", method, path, response.status)
|
||||
suffix = f": {detail}" if detail else ""
|
||||
raise RuntimeError(f"Shipmondo API returned HTTP {response.status}{suffix}")
|
||||
if not body.strip():
|
||||
return {}
|
||||
return json.loads(body)
|
||||
|
||||
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
|
||||
payload = await self._request("GET", "/products", params={"country_code": country_code.upper()})
|
||||
if isinstance(payload, list):
|
||||
return [item for item in payload if isinstance(item, dict)]
|
||||
if isinstance(payload, dict):
|
||||
raw_items = payload.get("items") or payload.get("products") or payload.get("data") or []
|
||||
if isinstance(raw_items, list):
|
||||
return [item for item in raw_items if isinstance(item, dict)]
|
||||
return []
|
||||
|
||||
async def create_shipment(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
response = await self._request("POST", "/shipments", json_payload=payload)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Shipmondo returned an invalid shipment response")
|
||||
return response
|
||||
|
||||
async def get_shipment(self, shipment_id: str) -> Dict[str, Any]:
|
||||
response = await self._request("GET", f"/shipments/{shipment_id}")
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("Shipmondo returned an invalid shipment response")
|
||||
return response
|
||||
66
app/modules/shipmondo/backend/router.py
Normal file
66
app/modules/shipmondo/backend/router.py
Normal file
@ -0,0 +1,66 @@
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Query, Request, Response
|
||||
|
||||
from app.modules.shipmondo.backend.service import shipmondo_service
|
||||
from app.modules.shipmondo.models.schemas import (
|
||||
ShipmondoBookingCreate,
|
||||
ShipmondoBookingListResponse,
|
||||
ShipmondoBookingResponse,
|
||||
ShipmondoBookingSubmitResponse,
|
||||
ShipmondoProductListResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _user_id_from_request(request: Request) -> Optional[int]:
|
||||
raw_user_id = getattr(request.state, "user_id", None)
|
||||
try:
|
||||
return int(raw_user_id) if raw_user_id is not None else None
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/shipmondo/config")
|
||||
async def shipmondo_config() -> dict:
|
||||
return {
|
||||
"enabled": shipmondo_service.enabled,
|
||||
"configured": shipmondo_service.configured,
|
||||
"read_only": shipmondo_service.read_only,
|
||||
"dry_run": shipmondo_service.dry_run,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/shipmondo/products", response_model=ShipmondoProductListResponse)
|
||||
async def list_products(country_code: str = Query(default="DK", min_length=2, max_length=2)):
|
||||
return {"items": await shipmondo_service.list_products(country_code)}
|
||||
|
||||
|
||||
@router.post("/shipmondo/bookings", response_model=ShipmondoBookingResponse)
|
||||
async def create_booking(payload: ShipmondoBookingCreate, request: Request):
|
||||
return shipmondo_service.create_booking_draft(payload, _user_id_from_request(request))
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings", response_model=ShipmondoBookingListResponse)
|
||||
async def list_bookings(case_id: Optional[int] = Query(default=None, gt=0)):
|
||||
return {"items": shipmondo_service.list_bookings(case_id)}
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings/{booking_ref}", response_model=ShipmondoBookingResponse)
|
||||
async def get_booking(booking_ref: str):
|
||||
return shipmondo_service.get_booking(booking_ref)
|
||||
|
||||
|
||||
@router.post("/shipmondo/bookings/{booking_ref}/submit", response_model=ShipmondoBookingSubmitResponse)
|
||||
async def submit_booking(booking_ref: str, request: Request):
|
||||
return await shipmondo_service.submit_booking(booking_ref, _user_id_from_request(request))
|
||||
|
||||
|
||||
@router.get("/shipmondo/bookings/{booking_ref}/label")
|
||||
async def download_label(booking_ref: str):
|
||||
return Response(
|
||||
content=shipmondo_service.get_label_pdf(booking_ref),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'inline; filename="{booking_ref}.pdf"'},
|
||||
)
|
||||
388
app/modules/shipmondo/backend/service.py
Normal file
388
app/modules/shipmondo/backend/service.py
Normal file
@ -0,0 +1,388 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import execute_query, execute_query_single
|
||||
from app.modules.shipmondo.backend.api_client import ShipmondoApiClient
|
||||
from app.modules.shipmondo.models.schemas import ShipmondoBookingCreate
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
if isinstance(value, Decimal):
|
||||
return float(value)
|
||||
if isinstance(value, datetime):
|
||||
return value.isoformat()
|
||||
return str(value)
|
||||
|
||||
|
||||
def _json_dumps(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, default=_json_default)
|
||||
|
||||
|
||||
def _json_object(value: Any) -> Dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except ValueError:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _json_list(value: Any) -> List[Any]:
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, list) else []
|
||||
except ValueError:
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def _to_float(value: Any) -> Optional[float]:
|
||||
try:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_tracking_number(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("pkg_no", "tracking_number", "trackingNumber", "external_pkg_no"):
|
||||
value = payload.get(key)
|
||||
if value:
|
||||
return str(value).strip()
|
||||
for parcel in payload.get("parcels") or []:
|
||||
if not isinstance(parcel, dict):
|
||||
continue
|
||||
value = parcel.get("pkg_no") or parcel.get("tracking_number")
|
||||
if value:
|
||||
return str(value).strip()
|
||||
values = parcel.get("pkg_nos")
|
||||
if isinstance(values, list) and values:
|
||||
return str(values[0]).strip()
|
||||
return None
|
||||
|
||||
|
||||
def extract_tracking_url(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("tracking_url", "trackingUrl", "carrier_tracking_url"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.startswith(("https://", "http://")):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def extract_price(payload: Dict[str, Any]) -> tuple[Optional[float], Optional[str]]:
|
||||
amount = _to_float(payload.get("price") or payload.get("total_amount") or payload.get("amount"))
|
||||
currency = payload.get("currency") or payload.get("currency_code")
|
||||
return amount, (str(currency).upper() if currency else ("DKK" if amount is not None else None))
|
||||
|
||||
|
||||
def extract_label_base64(payload: Dict[str, Any]) -> Optional[str]:
|
||||
for key in ("label_base64", "labelBase64", "label"):
|
||||
value = payload.get(key)
|
||||
if isinstance(value, str) and value.strip() and not value.startswith(("http://", "https://")):
|
||||
return value.split(",", 1)[-1].strip()
|
||||
for parcel in payload.get("parcels") or []:
|
||||
if isinstance(parcel, dict):
|
||||
value = parcel.get("label_base64") or parcel.get("labelBase64")
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.split(",", 1)[-1].strip()
|
||||
return None
|
||||
|
||||
|
||||
def build_shipmondo_payload(shipment: Dict[str, Any]) -> Dict[str, Any]:
|
||||
sender = {
|
||||
"type": "sender",
|
||||
"name": settings.SHIPMONDO_SENDER_NAME.strip(),
|
||||
"attention": settings.SHIPMONDO_SENDER_ATTENTION.strip() or None,
|
||||
"address1": settings.SHIPMONDO_SENDER_ADDRESS1.strip(),
|
||||
"address2": settings.SHIPMONDO_SENDER_ADDRESS2.strip() or None,
|
||||
"postal_code": settings.SHIPMONDO_SENDER_POSTAL_CODE.strip(),
|
||||
"city": settings.SHIPMONDO_SENDER_CITY.strip(),
|
||||
"country_code": (settings.SHIPMONDO_SENDER_COUNTRY_CODE or "DK").strip().upper(),
|
||||
"email": settings.SHIPMONDO_SENDER_EMAIL.strip() or None,
|
||||
"phone": settings.SHIPMONDO_SENDER_PHONE.strip() or None,
|
||||
}
|
||||
receiver = {
|
||||
"type": "receiver",
|
||||
"name": shipment.get("company_name") or shipment["recipient_name"],
|
||||
"attention": shipment["recipient_name"] if shipment.get("company_name") else None,
|
||||
"address1": shipment["address_line1"],
|
||||
"address2": shipment.get("address_line2"),
|
||||
"postal_code": shipment["postal_code"],
|
||||
"city": shipment["city"],
|
||||
"country_code": shipment["country_code"],
|
||||
"email": shipment.get("email"),
|
||||
"phone": shipment.get("phone"),
|
||||
}
|
||||
parcels = []
|
||||
for index, parcel in enumerate(shipment.get("parcels") or [], start=1):
|
||||
item: Dict[str, Any] = {
|
||||
"quantity": 1,
|
||||
"weight": max(1, round(float(parcel["weight_kg"]) * 1000)),
|
||||
"content": parcel.get("description"),
|
||||
"internal_reference": f"{shipment['booking_ref']}-{index}",
|
||||
}
|
||||
for source, target in (("length_cm", "length"), ("width_cm", "width"), ("height_cm", "height")):
|
||||
if parcel.get(source) is not None:
|
||||
item[target] = max(1, round(float(parcel[source])))
|
||||
parcels.append(item)
|
||||
|
||||
return {
|
||||
"own_agreement": bool(shipment.get("own_agreement")),
|
||||
"label_format": "10x19_pdf",
|
||||
"print": False,
|
||||
"product_code": shipment["product_code"],
|
||||
"service_codes": ",".join(_json_list(shipment.get("service_codes"))),
|
||||
"automatic_select_service_point": bool(shipment.get("automatic_select_service_point")),
|
||||
"reference": f"Sag #{shipment['case_id']} · {shipment['booking_ref']}",
|
||||
"parties": [sender, receiver],
|
||||
"parcels": parcels,
|
||||
}
|
||||
|
||||
|
||||
class ShipmondoService:
|
||||
def __init__(self) -> None:
|
||||
self.client = ShipmondoApiClient()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_ENABLED)
|
||||
|
||||
@property
|
||||
def read_only(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_READ_ONLY)
|
||||
|
||||
@property
|
||||
def dry_run(self) -> bool:
|
||||
return bool(settings.SHIPMONDO_DRY_RUN)
|
||||
|
||||
@property
|
||||
def configured(self) -> bool:
|
||||
return self.client.configured
|
||||
|
||||
def _assert_enabled(self) -> None:
|
||||
if not self.enabled:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo-integrationen er slået fra")
|
||||
|
||||
def _assert_sender_configured(self) -> None:
|
||||
required = {
|
||||
"navn": settings.SHIPMONDO_SENDER_NAME,
|
||||
"adresse": settings.SHIPMONDO_SENDER_ADDRESS1,
|
||||
"postnummer": settings.SHIPMONDO_SENDER_POSTAL_CODE,
|
||||
"by": settings.SHIPMONDO_SENDER_CITY,
|
||||
}
|
||||
missing = [label for label, value in required.items() if not str(value or "").strip()]
|
||||
if missing:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Shipmondo mangler afsenderens {', '.join(missing)} i konfigurationen",
|
||||
)
|
||||
|
||||
def _booking_ref(self) -> str:
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
|
||||
return f"SMD-{stamp}-{uuid4().hex[:8].upper()}"
|
||||
|
||||
def _validate_relations(self, payload: ShipmondoBookingCreate) -> None:
|
||||
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s", (payload.case_id,)):
|
||||
raise HTTPException(status_code=404, detail="Sagen findes ikke")
|
||||
if payload.customer_id and not execute_query_single("SELECT id FROM customers WHERE id = %s", (payload.customer_id,)):
|
||||
raise HTTPException(status_code=404, detail="Kunden findes ikke")
|
||||
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
|
||||
raise HTTPException(status_code=404, detail="Kontakten findes ikke")
|
||||
|
||||
def _fetch_parcels(self, shipment_id: int) -> List[Dict[str, Any]]:
|
||||
rows = execute_query(
|
||||
"""
|
||||
SELECT weight_kg, length_cm, width_cm, height_cm, description
|
||||
FROM shipmondo_shipment_parcels
|
||||
WHERE shipment_id = %s
|
||||
ORDER BY id ASC
|
||||
""",
|
||||
(shipment_id,),
|
||||
) or []
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def _row_to_dict(self, row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mapped = dict(row)
|
||||
mapped["service_codes"] = [str(value) for value in _json_list(mapped.get("service_codes"))]
|
||||
mapped["parcels"] = self._fetch_parcels(int(mapped["id"]))
|
||||
api_response = _json_object(mapped.get("api_response"))
|
||||
mapped["tracking_number"] = mapped.get("tracking_number") or extract_tracking_number(api_response)
|
||||
mapped["tracking_url"] = mapped.get("tracking_url") or extract_tracking_url(api_response)
|
||||
if extract_label_base64(api_response):
|
||||
mapped["label_url"] = f"/api/v1/shipmondo/bookings/{mapped['booking_ref']}/label"
|
||||
else:
|
||||
label_url = api_response.get("label_url")
|
||||
mapped["label_url"] = label_url if isinstance(label_url, str) else None
|
||||
return mapped
|
||||
|
||||
async def list_products(self, country_code: str) -> List[Dict[str, Any]]:
|
||||
self._assert_enabled()
|
||||
if not self.configured:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
|
||||
try:
|
||||
products = await self.client.list_products(country_code)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return [product for product in products if product.get("available", True)]
|
||||
|
||||
def create_booking_draft(self, payload: ShipmondoBookingCreate, user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
self._validate_relations(payload)
|
||||
booking_ref = self._booking_ref()
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO shipmondo_shipments (
|
||||
booking_ref, case_id, customer_id, contact_id, product_code, product_name,
|
||||
carrier_code, carrier_name, service_codes, shipment_status, own_agreement,
|
||||
automatic_select_service_point, recipient_name, company_name, address_line1,
|
||||
address_line2, postal_code, city, country_code, phone, email, dry_run,
|
||||
created_by_user_id, updated_by_user_id, api_payload, api_response
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, 'draft', %s, %s,
|
||||
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb
|
||||
) RETURNING *
|
||||
""",
|
||||
(
|
||||
booking_ref, payload.case_id, payload.customer_id, payload.contact_id,
|
||||
payload.product_code, payload.product_name, payload.carrier_code, payload.carrier_name,
|
||||
_json_dumps(payload.service_codes), payload.own_agreement,
|
||||
payload.automatic_select_service_point, payload.address.recipient_name,
|
||||
payload.address.company_name, payload.address.address_line1, payload.address.address_line2,
|
||||
payload.address.postal_code, payload.address.city, payload.address.country_code,
|
||||
payload.address.phone, payload.address.email, self.dry_run, user_id, user_id,
|
||||
_json_dumps(payload.model_dump(mode="json")), _json_dumps({"status": "draft_created"}),
|
||||
),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="Shipmondo-draft kunne ikke oprettes")
|
||||
for parcel in payload.parcels:
|
||||
execute_query(
|
||||
"""
|
||||
INSERT INTO shipmondo_shipment_parcels
|
||||
(shipment_id, weight_kg, length_cm, width_cm, height_cm, description)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(row["id"], parcel.weight_kg, parcel.length_cm, parcel.width_cm, parcel.height_cm, parcel.description),
|
||||
)
|
||||
return self.get_booking(booking_ref)
|
||||
|
||||
def list_bookings(self, case_id: Optional[int] = None) -> List[Dict[str, Any]]:
|
||||
params: List[Any] = []
|
||||
where = "WHERE deleted_at IS NULL"
|
||||
if case_id is not None:
|
||||
where += " AND case_id = %s"
|
||||
params.append(case_id)
|
||||
rows = execute_query(
|
||||
f"SELECT * FROM shipmondo_shipments {where} ORDER BY created_at DESC LIMIT 200",
|
||||
tuple(params),
|
||||
) or []
|
||||
return [self._row_to_dict(dict(row)) for row in rows]
|
||||
|
||||
def get_booking(self, booking_ref: str) -> Dict[str, Any]:
|
||||
row = execute_query_single(
|
||||
"SELECT * FROM shipmondo_shipments WHERE booking_ref = %s AND deleted_at IS NULL",
|
||||
(booking_ref,),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Shipmondo-forsendelsen findes ikke")
|
||||
return self._row_to_dict(dict(row))
|
||||
|
||||
async def submit_booking(self, booking_ref: str, user_id: Optional[int]) -> Dict[str, Any]:
|
||||
self._assert_enabled()
|
||||
shipment = self.get_booking(booking_ref)
|
||||
if shipment["shipment_status"] not in {"draft", "failed"}:
|
||||
raise HTTPException(status_code=409, detail="Kun drafts og fejlede bookinger kan sendes igen")
|
||||
if self.read_only:
|
||||
raise HTTPException(status_code=403, detail="Shipmondo er i skrivebeskyttet tilstand")
|
||||
self._assert_sender_configured()
|
||||
payload = build_shipmondo_payload(shipment)
|
||||
|
||||
if self.dry_run:
|
||||
api_response = {
|
||||
"id": f"dry-{uuid4().hex[:10]}",
|
||||
"pkg_no": f"SMD-DRY-{uuid4().hex[:10].upper()}",
|
||||
"price": "69.00",
|
||||
"currency": "DKK",
|
||||
"dry_run": True,
|
||||
}
|
||||
new_status = "submitted"
|
||||
else:
|
||||
if not self.configured:
|
||||
raise HTTPException(status_code=503, detail="Shipmondo API-bruger og API-nøgle er ikke konfigureret")
|
||||
try:
|
||||
api_response = await self.client.create_shipment(payload)
|
||||
except Exception as exc:
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE shipmondo_shipments
|
||||
SET shipment_status = 'failed', api_response = %s::jsonb, updated_by_user_id = %s
|
||||
WHERE booking_ref = %s
|
||||
""",
|
||||
(_json_dumps({"error": str(exc)}), user_id, booking_ref),
|
||||
)
|
||||
raise HTTPException(status_code=502, detail=f"Shipmondo-booking fejlede: {exc}") from exc
|
||||
new_status = "booked"
|
||||
|
||||
tracking_number = extract_tracking_number(api_response)
|
||||
tracking_url = extract_tracking_url(api_response)
|
||||
total_amount, currency = extract_price(api_response)
|
||||
shipmondo_id = api_response.get("id")
|
||||
execute_query(
|
||||
"""
|
||||
UPDATE shipmondo_shipments
|
||||
SET shipmondo_id = %s, shipment_status = %s, tracking_number = %s, tracking_url = %s,
|
||||
total_amount = %s, currency = %s, submitted_at = CURRENT_TIMESTAMP,
|
||||
api_payload = %s::jsonb, api_response = %s::jsonb, updated_by_user_id = %s
|
||||
WHERE booking_ref = %s
|
||||
""",
|
||||
(
|
||||
str(shipmondo_id) if shipmondo_id is not None else None, new_status,
|
||||
tracking_number, tracking_url, total_amount, currency,
|
||||
_json_dumps(payload), _json_dumps(api_response), user_id, booking_ref,
|
||||
),
|
||||
)
|
||||
booking = self.get_booking(booking_ref)
|
||||
return {
|
||||
"booking_ref": booking_ref,
|
||||
"status": new_status,
|
||||
"dry_run": self.dry_run,
|
||||
"tracking_number": tracking_number,
|
||||
"tracking_url": tracking_url,
|
||||
"label_url": booking.get("label_url"),
|
||||
"total_amount": total_amount,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
def get_label_pdf(self, booking_ref: str) -> bytes:
|
||||
booking = self.get_booking(booking_ref)
|
||||
row = execute_query_single(
|
||||
"SELECT api_response FROM shipmondo_shipments WHERE id = %s",
|
||||
(booking["id"],),
|
||||
)
|
||||
encoded = extract_label_base64(_json_object(row.get("api_response") if row else None))
|
||||
if not encoded:
|
||||
raise HTTPException(status_code=404, detail="Forsendelsen har endnu ingen PDF-label")
|
||||
try:
|
||||
return base64.b64decode(encoded, validate=True)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise HTTPException(status_code=500, detail="Shipmondo-labelen kunne ikke læses") from exc
|
||||
|
||||
|
||||
shipmondo_service = ShipmondoService()
|
||||
1
app/modules/shipmondo/models/__init__.py
Normal file
1
app/modules/shipmondo/models/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Shipmondo API models."""
|
||||
106
app/modules/shipmondo/models/schemas.py
Normal file
106
app/modules/shipmondo/models/schemas.py
Normal file
@ -0,0 +1,106 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
ShipmentStatus = Literal[
|
||||
"draft",
|
||||
"submitted",
|
||||
"booked",
|
||||
"in_transit",
|
||||
"delivered",
|
||||
"cancelled",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
class ShipmondoAddress(BaseModel):
|
||||
recipient_name: str = Field(min_length=2, max_length=150)
|
||||
company_name: Optional[str] = Field(default=None, max_length=150)
|
||||
address_line1: str = Field(min_length=2, max_length=200)
|
||||
address_line2: Optional[str] = Field(default=None, max_length=200)
|
||||
postal_code: str = Field(min_length=2, max_length=20)
|
||||
city: str = Field(min_length=2, max_length=120)
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
phone: Optional[str] = Field(default=None, max_length=50)
|
||||
email: Optional[str] = Field(default=None, max_length=150)
|
||||
|
||||
@field_validator("country_code")
|
||||
@classmethod
|
||||
def normalize_country_code(cls, value: str) -> str:
|
||||
return value.strip().upper()
|
||||
|
||||
|
||||
class ShipmondoParcelInput(BaseModel):
|
||||
weight_kg: float = Field(gt=0, le=2000)
|
||||
length_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
width_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
height_cm: Optional[float] = Field(default=None, gt=0, le=400)
|
||||
description: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class ShipmondoBookingCreate(BaseModel):
|
||||
case_id: int = Field(gt=0)
|
||||
customer_id: Optional[int] = Field(default=None, gt=0)
|
||||
contact_id: Optional[int] = Field(default=None, gt=0)
|
||||
product_code: str = Field(min_length=1, max_length=80)
|
||||
product_name: Optional[str] = Field(default=None, max_length=180)
|
||||
carrier_code: Optional[str] = Field(default=None, max_length=80)
|
||||
carrier_name: Optional[str] = Field(default=None, max_length=120)
|
||||
service_codes: List[str] = Field(default_factory=list, max_length=30)
|
||||
own_agreement: bool = False
|
||||
automatic_select_service_point: bool = False
|
||||
address: ShipmondoAddress
|
||||
parcels: List[ShipmondoParcelInput] = Field(min_length=1, max_length=30)
|
||||
|
||||
@field_validator("service_codes")
|
||||
@classmethod
|
||||
def normalize_service_codes(cls, values: List[str]) -> List[str]:
|
||||
return list(dict.fromkeys(value.strip().upper() for value in values if value.strip()))
|
||||
|
||||
|
||||
class ShipmondoBookingResponse(BaseModel):
|
||||
id: int
|
||||
booking_ref: str
|
||||
case_id: int
|
||||
customer_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
product_code: str
|
||||
product_name: Optional[str] = None
|
||||
carrier_code: Optional[str] = None
|
||||
carrier_name: Optional[str] = None
|
||||
service_codes: List[str] = Field(default_factory=list)
|
||||
shipment_status: ShipmentStatus
|
||||
recipient_name: str
|
||||
city: str
|
||||
country_code: str
|
||||
tracking_number: Optional[str] = None
|
||||
tracking_url: Optional[str] = None
|
||||
label_url: Optional[str] = None
|
||||
total_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
dry_run: bool
|
||||
submitted_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
parcels: List[ShipmondoParcelInput] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ShipmondoBookingListResponse(BaseModel):
|
||||
items: List[ShipmondoBookingResponse]
|
||||
|
||||
|
||||
class ShipmondoBookingSubmitResponse(BaseModel):
|
||||
booking_ref: str
|
||||
status: ShipmentStatus
|
||||
dry_run: bool
|
||||
tracking_number: Optional[str] = None
|
||||
tracking_url: Optional[str] = None
|
||||
label_url: Optional[str] = None
|
||||
total_amount: Optional[float] = None
|
||||
currency: Optional[str] = None
|
||||
|
||||
|
||||
class ShipmondoProductListResponse(BaseModel):
|
||||
items: List[Dict[str, Any]]
|
||||
@ -3,6 +3,8 @@ import logging
|
||||
import base64
|
||||
import ipaddress
|
||||
import re
|
||||
import uuid
|
||||
import math
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from urllib.error import URLError, HTTPError
|
||||
@ -16,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
|
||||
@ -378,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:
|
||||
@ -438,10 +448,20 @@ 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)
|
||||
return {"status": "ok"}
|
||||
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}
|
||||
|
||||
|
||||
@router.websocket("/telefoni/ws")
|
||||
@ -617,6 +637,76 @@ def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]
|
||||
return str(value)
|
||||
|
||||
|
||||
def _register_completed_call_time(callid: str) -> Optional[int]:
|
||||
"""Create one completed worklog for a case-started call after PBX termination."""
|
||||
call = execute_query_single(
|
||||
"""
|
||||
SELECT t.id, t.sag_id, t.bruger_id, t.started_at, t.ended_at, t.duration_sec,
|
||||
t.raw_payload,
|
||||
COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Hub User') AS user_name
|
||||
FROM telefoni_opkald t
|
||||
JOIN sag_sager s ON s.id = t.sag_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN users u ON u.user_id = t.bruger_id
|
||||
WHERE t.callid = %s
|
||||
""",
|
||||
(callid,),
|
||||
)
|
||||
if not call or not call.get("sag_id"):
|
||||
return None
|
||||
raw_payload = call.get("raw_payload") or {}
|
||||
if isinstance(raw_payload, str):
|
||||
try:
|
||||
raw_payload = json.loads(raw_payload)
|
||||
except (TypeError, ValueError):
|
||||
raw_payload = {}
|
||||
if not raw_payload.get("case_click_to_call"):
|
||||
return None
|
||||
|
||||
marker = f"[telefoni:{call['id']}]"
|
||||
existing = execute_query_single(
|
||||
"SELECT id FROM tmodule_times WHERE sag_id = %s AND description LIKE %s LIMIT 1",
|
||||
(call["sag_id"], f"%{marker}%"),
|
||||
)
|
||||
if existing:
|
||||
return int(existing["id"])
|
||||
|
||||
from app.timetracking.backend.router import _resolve_case_customer_id
|
||||
time_customer_id = _resolve_case_customer_id(call["sag_id"])
|
||||
if not time_customer_id:
|
||||
logger.warning("⚠️ Could not resolve time customer for call %s / case %s", callid, call["sag_id"])
|
||||
return None
|
||||
|
||||
seconds = int(call.get("duration_sec") or 0)
|
||||
if seconds <= 0 and call.get("started_at") and call.get("ended_at"):
|
||||
seconds = max(0, int((call["ended_at"] - call["started_at"]).total_seconds()))
|
||||
actual_minutes = max(1, math.ceil(seconds / 60))
|
||||
billable_minutes = max(30, math.ceil(actual_minutes / 30) * 30)
|
||||
row = execute_query_single(
|
||||
"""
|
||||
INSERT INTO tmodule_times (
|
||||
sag_id, customer_id, description, original_hours, worked_date,
|
||||
user_name, status, billable, start_tid, slut_tid,
|
||||
faktisk_tid_min, fakturerbar_tid_min, entry_type, kilde,
|
||||
entry_status, medarbejder_id, aktiv_timer, round_block_min,
|
||||
ikke_placeret, work_type
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, COALESCE(%s::timestamp, NOW())::date,
|
||||
%s, 'pending', TRUE, %s, %s,
|
||||
%s, %s, 'opkald', 'auto',
|
||||
'afventer', %s, FALSE, 30,
|
||||
FALSE, 'support'
|
||||
) RETURNING id
|
||||
""",
|
||||
(
|
||||
call["sag_id"], time_customer_id, f"Telefonopkald {marker}",
|
||||
max(actual_minutes / 60.0, 0.01), call.get("started_at"), call.get("user_name"),
|
||||
call.get("started_at"), call.get("ended_at"), actual_minutes, billable_minutes,
|
||||
call.get("bruger_id"),
|
||||
),
|
||||
)
|
||||
return int(row["id"]) if row else None
|
||||
|
||||
|
||||
@router.post("/telefoni/click-to-call")
|
||||
async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
||||
client_ip = _get_client_ip(request)
|
||||
@ -706,6 +796,13 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
||||
auth_header = "Basic " + base64.b64encode(credentials).decode("ascii")
|
||||
resolved_url = urlunsplit((parsed.scheme, host_part, parsed.path, parsed.query, parsed.fragment))
|
||||
|
||||
effective_user_id = int(authenticated_user_id or payload.user_id) if (authenticated_user_id or payload.user_id) else None
|
||||
if payload.sag_id:
|
||||
if not execute_query_single("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (payload.sag_id,)):
|
||||
raise HTTPException(status_code=404, detail="Case not found")
|
||||
if payload.contact_id and not execute_query_single("SELECT id FROM contacts WHERE id = %s", (payload.contact_id,)):
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
|
||||
logger.info("📞 Click-to-call trigger: number=%s extension=%s", number_normalized, extension_value or "-")
|
||||
|
||||
try:
|
||||
@ -728,11 +825,29 @@ async def click_to_call(payload: TelefoniClickToCallRequest, request: Request):
|
||||
if auth_header:
|
||||
display_url = "[basic-auth] " + resolved_url
|
||||
|
||||
call_record = None
|
||||
if payload.sag_id:
|
||||
pending_callid = f"click-to-call:{uuid.uuid4()}"
|
||||
call_record = execute_query_single(
|
||||
"""
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, bruger_id, direction, ekstern_nummer, intern_extension, kontakt_id, sag_id, started_at, raw_payload)
|
||||
VALUES (%s, %s, 'outbound', %s, %s, %s, %s, NOW(), %s::jsonb)
|
||||
RETURNING id, callid, sag_id, kontakt_id
|
||||
""",
|
||||
(
|
||||
pending_callid, effective_user_id, number_normalized, extension_value or None,
|
||||
payload.contact_id, payload.sag_id,
|
||||
json.dumps({"case_click_to_call": True, "requested_number": number_normalized}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"action_url": display_url,
|
||||
"http_status": status,
|
||||
"number": number_normalized,
|
||||
"call_record": call_record,
|
||||
}
|
||||
|
||||
|
||||
@ -745,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 = []
|
||||
|
||||
@ -775,13 +891,13 @@ async def list_calls(
|
||||
t.sag_id,
|
||||
t.started_at,
|
||||
t.ended_at,
|
||||
COALESCE(
|
||||
t.duration_sec,
|
||||
CASE
|
||||
WHEN t.started_at IS NOT NULL AND t.ended_at IS NOT NULL THEN GREATEST(EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int, 0)
|
||||
WHEN t.duration_sec BETWEEN 0 AND 43200 THEN t.duration_sec
|
||||
WHEN t.duration_sec IS NULL AND t.started_at IS NOT NULL AND t.ended_at IS NOT NULL
|
||||
AND t.ended_at - t.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
|
||||
THEN EXTRACT(EPOCH FROM (t.ended_at - t.started_at))::int
|
||||
ELSE NULL
|
||||
END
|
||||
) AS duration_sec,
|
||||
END AS duration_sec,
|
||||
t.created_at,
|
||||
u.username,
|
||||
u.full_name,
|
||||
@ -794,7 +910,8 @@ async def list_calls(
|
||||
ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC
|
||||
LIMIT 1
|
||||
) AS contact_company,
|
||||
s.titel AS sag_titel
|
||||
s.titel AS sag_titel,
|
||||
'telefoni'::VARCHAR AS source
|
||||
FROM telefoni_opkald t
|
||||
LEFT JOIN users u ON u.user_id = t.bruger_id
|
||||
LEFT JOIN contacts c ON c.id = t.kontakt_id
|
||||
@ -903,6 +1020,38 @@ async def list_calls(
|
||||
@router.patch("/telefoni/calls/{call_id}")
|
||||
async def update_call_links(call_id: int, data: TelefoniCallLinkUpdate):
|
||||
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE id = %s", (call_id,))
|
||||
if not existing and data.source == "legacy_mission" and data.callid:
|
||||
promoted = execute_query(
|
||||
"""
|
||||
INSERT INTO telefoni_opkald
|
||||
(callid, direction, ekstern_nummer, started_at, ended_at, duration_sec, raw_payload)
|
||||
SELECT
|
||||
m.call_id,
|
||||
CASE WHEN LOWER(COALESCE(m.state, '')) IN ('outbound', 'udgaaende') THEN 'outbound' ELSE 'inbound' END,
|
||||
m.caller_number,
|
||||
m.started_at,
|
||||
m.ended_at,
|
||||
CASE
|
||||
WHEN m.started_at IS NOT NULL AND m.ended_at IS NOT NULL
|
||||
AND m.ended_at - m.started_at BETWEEN INTERVAL '0 seconds' AND INTERVAL '12 hours'
|
||||
THEN EXTRACT(EPOCH FROM (m.ended_at - m.started_at))::int
|
||||
ELSE NULL
|
||||
END,
|
||||
COALESCE(m.last_payload, '{}'::jsonb)
|
||||
FROM mission_call_state m
|
||||
WHERE m.call_id = %s
|
||||
ON CONFLICT (callid) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
(data.callid.strip(),),
|
||||
) or []
|
||||
if promoted:
|
||||
call_id = int(promoted[0]["id"])
|
||||
existing = {"id": call_id}
|
||||
else:
|
||||
existing = execute_query_single("SELECT id FROM telefoni_opkald WHERE callid = %s", (data.callid.strip(),))
|
||||
if existing:
|
||||
call_id = int(existing["id"])
|
||||
if not existing:
|
||||
raise HTTPException(status_code=404, detail="Call not found")
|
||||
|
||||
|
||||
@ -5,6 +5,8 @@ from typing import Optional
|
||||
class TelefoniCallLinkUpdate(BaseModel):
|
||||
sag_id: Optional[int] = None
|
||||
kontakt_id: Optional[int] = None
|
||||
callid: Optional[str] = None
|
||||
source: Optional[str] = None
|
||||
|
||||
|
||||
class TelefoniUserMappingUpdate(BaseModel):
|
||||
@ -19,6 +21,8 @@ class TelefoniClickToCallRequest(BaseModel):
|
||||
number: str
|
||||
extension: Optional[str] = None
|
||||
user_id: Optional[int] = None
|
||||
sag_id: Optional[int] = None
|
||||
contact_id: Optional[int] = None
|
||||
|
||||
|
||||
class SmsSendRequest(BaseModel):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user