Compare commits

...

33 Commits

Author SHA1 Message Date
Christian
ee274a939a release: v2.8.16 show multiline order descriptions 2026-09-14 15:24:43 +02:00
Christian
b95815de0d release: v2.8.15 format ALSO periods on separate line 2026-09-14 14:18:12 +02:00
Christian
dbdb1c554a release: v2.8.14 add ALSO billing periods 2026-09-14 13:35:38 +02:00
Christian
85fd99c5d8 release: v2.8.13 sync economic order lifecycle 2026-09-13 14:05:06 +02:00
Christian
4971f9721c release: v2.8.12 add complete economic export receipt 2026-09-13 11:41:50 +02:00
Christian
a523bc4ac3 release: v2.8.11 rename subscription draft heading 2026-09-13 11:27:05 +02:00
Christian
2a71e9285a release: v2.8.10 persist economic draft order number 2026-09-13 11:24:18 +02:00
Christian
b3059dced1 release: v2.8.9 preview missing products during export 2026-09-13 09:56:24 +02:00
Christian
87932f8e16 release: v2.8.8 fix economic permissions and order validation 2026-09-13 09:33:36 +02:00
Christian
3e8e5ba1cf release: v2.8.7 economic catalog and four-eyes approval 2026-09-13 02:51:15 +02:00
Christian
631fc6cdca Implement four-eyes approval process for e-conomic changes
- Added migration to create economic_change_requests table for tracking change requests for customers and products.
- Introduced new permissions for requesting and approving changes in e-conomic.
- Developed frontend JavaScript functionality for managing economic catalog, including handling change requests and displaying their statuses.
- Created browser tests to validate UI interactions related to product management and change requests.
- Added unit tests for backend logic to ensure proper handling of product numbers, price rules, and economic write policies.
2026-09-13 02:30:52 +02:00
Christian
13f36a36c0 release: v2.8.6 validate e-conomic order references 2026-09-11 16:58:18 +02:00
Christian
7c1a748012 release: v2.8.5 fix e-conomic order payload 2026-09-11 16:46:51 +02:00
Christian
3d4a7c8421 release: v2.8.4 fix order export safety config 2026-09-11 15:45:46 +02:00
Christian
f251bfebc4 release: v2.8.3 invoice and internet workflows 2026-09-11 13:51:10 +02:00
Christian
056ce7b871 release: v2.8.2 fix Ollama amount normalization 2026-09-09 11:57:37 +02:00
Christian
4bf592cf0c release: v2.8.1 planner and system observability 2026-09-09 10:34:49 +02:00
Christian
9ed709c9e8 release: v2.8.0 case creation and messaging 2026-09-08 01:55:47 +02:00
Christian
9ca562745a feat(subscriptions): enhance subscription update logic to allow direct edits for drafts and add new endpoint for manual invoice processing
feat(ticket): update email integration to use new priority constants for ticket classification

feat(procurement): add procurement overview page with dynamic data loading and display

test(subscriptions): add tests for billing calendar to ensure correct invoice dates

feat(reminder): implement automated task lists with user-defined rules for reminders

feat(migrations): create tables for managing delefiber product prices and mobile recorder provisioning history

test(mobile_recorder): add tests for provisioning mobile recorders to ensure correct asset creation and updates
2026-09-07 19:05:58 +02:00
Christian
1cfe5aee76 feat(migrations): add AI benchmark and vTiger archive tables
- Created tables for AI benchmark runs and results to facilitate model evaluation.
- Added expected answers column to benchmark results.
- Introduced tables for internet connection change cases and vTiger archive management, including records, relations, and checkpoints.
- Implemented triggers to enforce append-only behavior for vTiger archive records and files.
- Enhanced solution management with soft delete capabilities for sag_solutions and knowledge_articles.

feat(scripts): add CRM benchmarking script for Ollama models

- Developed a Python script to benchmark CRM models exposed through Ollama, including various test cases and scoring mechanisms.

test(tests): add comprehensive tests for new features

- Implemented tests for internet change case service, vTiger archive functionality, and sag solution knowledge management.
- Ensured coverage for edge cases and error handling in the new features.
2026-08-31 13:01:35 +02:00
Christian
adc4fb5876 Implement tests for sag module, add knowledge base templates, and enhance internet connection migrations
- Added multiple test cases for the sag module to ensure proper functionality and data handling.
- Created new templates for knowledge detail and knowledge index pages to display articles and solutions.
- Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions.
- Added a script to reconcile known internet connections with verified data.
- Planned the implementation of a new website content administration module for managing customer references and operational status.
2026-08-30 14:34:43 +02:00
Christian
ffdc9ac62c release: v2.7.2 2026-08-28 20:49:55 +02:00
Christian
2c2db54d16 release: v2.7.1 2026-08-25 18:42:43 +02:00
Christian
4daca03a55 release: v2.7.0 2026-08-25 16:04:28 +02:00
Christian
b755d61a5e release: v2.6.2 2026-08-25 15:39:38 +02:00
Christian
147451c259 release: v2.6.1 2026-08-25 06:43:31 +02:00
Christian
d0f27b78d5 release: v2.6.0 2026-08-25 01:09:45 +02:00
Christian
6906568dc2 release: v2.5.1 2026-08-18 20:57:49 +02:00
Christian
761753e577 chore(release): bump version to 2.5.0 2026-08-18 07:14:43 +02:00
Christian
0920f2ce30 feat: manage case timers from time usage tab 2026-08-18 06:56:36 +02:00
Christian
97218d61a8 fix: separate timer conversion workflow from case switch 2026-08-17 20:53:08 +02:00
Christian
cabe0395ed fix: open timer conversion from bottom bar tab 2026-08-17 20:43:52 +02:00
Christian
9ac5b91f6f fix: harden bottom bar timers messages and access 2026-08-17 19:34:39 +02:00
243 changed files with 28664 additions and 2740 deletions

View File

@ -3,6 +3,7 @@
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk HUB_BASE_URL=https://hub.bmcnetworks.dk
MOBILE_RECORDER_PROVISIONING_TOKEN=replace-with-a-long-random-service-token
# Database credentials (bruges af docker-compose) # Database credentials (bruges af docker-compose)
POSTGRES_USER=bmc_hub POSTGRES_USER=bmc_hub
@ -10,6 +11,12 @@ POSTGRES_PASSWORD=bmc_hub
POSTGRES_DB=bmc_hub POSTGRES_DB=bmc_hub
POSTGRES_PORT=5433 # Changed from 5432 to avoid conflicts with other services 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 # API CONFIGURATION
# ===================================================== # =====================================================
@ -97,6 +104,30 @@ FEDEX_TIMEOUT_SECONDS=20
# 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser # 🚨 SAFETY SWITCHES - Beskytter mod utilsigtede forsendelser
FEDEX_READ_ONLY=true FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=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) # Nextcloud Integration (Optional)
# ===================================================== # =====================================================

View File

@ -117,6 +117,28 @@ FEDEX_TIMEOUT_SECONDS=20
FEDEX_READ_ONLY=true FEDEX_READ_ONLY=true
FEDEX_DRY_RUN=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) # Links / Endpoints Module - Production (Optional)
# ===================================================== # =====================================================

View File

@ -0,0 +1,64 @@
# Release Notes: v2.5.0
**Dato:** 18. august 2026
## Overblik
Version 2.5 samler den store omlægning af sager, tidsregistrering og bundmenuen samt nye arbejdsgange for kontakter, abonnementer, lokationer, netværk, leverandørdata og fakturakontrol.
## Sager og tidsregistrering
- Timeren fortsætter korrekt efter sideskift og genindlæsning.
- En medarbejder kan kun have én aktiv timer, men flere pausede timere.
- Aktive, pausede og afsluttede timere kan åbnes og håndteres fra bundmenuen.
- Afsluttede timere kan registreres som fakturering, klippekort, abonnement eller intern tid.
- Arbejdstid kan klassificeres, blandt andet som support, udvikling og vedligehold.
- Fanen **Tidsforbrug** på en sag viser og håndterer timere, som tilhører sagen.
- Den aktive timer vises på den tilhørende sag på tværs af sagens faner.
- Timervisning og dialogen **Skift sag** er gjort mere kompakt og viser tydeligere sag, kunde, kontakt, status og tidsforbrug.
- Sagsfiltre, herunder hastesager, sender nu til de rigtige filtrerede visninger.
- Advarsel og bekræftelse ved lukning af en sag uden registreret tid.
## Bundmenu og sidepaneler
- Timer-, besked-, opgave-, note- og sagsfordelingsvisninger er omarbejdet og bruger pladsen bedre.
- Valgt fane og åben/lukket tilstand bevares under navigation.
- Beskeder har individuelle læse- og kvitteringsstatusser pr. bruger.
- Lederadgang til medarbejdertimere er strammet op, og almindelige brugere ser kun egne timere.
- Timerkonvertering åbner som en særskilt arbejdsgang og virker direkte fra timerfanen.
- Links fra bundmenuen bruger de kanoniske sagssider.
## Kontakter og kundehistorik
- Dublerede kontakter kan flettes med relaterede sager, samtaler, e-mails og historik.
- Kontaktsøgning foreslår automatisk brugerens navn i de relevante arbejdsgange.
- Samlet kontakt- og aktivitetshistorik bevares ved fletning.
## Abonnement, drift og rapportering
- Abonnement er en tydelig sagstype og en selvstændig arbejdsgang på sagen.
- Tidsregistrering understøtter udvikling og vedligehold som rapporterbare typer.
- Data kan rapporteres på tværs af sager, medarbejdere, arbejdstyper og afregningsformer.
## Øvrige større forbedringer
- Udvidet lokations-, krydsfelt-, vægudtags- og UISP/netværksoverblik.
- Forbedret migrationscenter og sagsoprettelse fra migreringsdata.
- Udvidet AnyDesk-integration og administration af leverandørers e-maildomæner.
- Oprettelse af produkter fra ALSO-importlinjer.
- Forbedringer til leverandørfakturaer og fakturafejlfinder.
- Sikkerhedsrettelser til ejerskabskontrol, lederadgang og visning af brugeroprettet indhold.
## Databaseændringer
Releasen indeholder de nødvendige migrationer til timer-, besked-, kontakt-, lokations-, abonnements- og integrationsfunktionerne. Migrationerne køres ved normal opstart/deploy.
## Verifikation
- Målrettede sags- og danske UI-tests: **26 bestået**.
- Samlet `tests/`-suite: **104 bestået, 7 kendte test-isoleringsfejl** i ældre databaseafhængige tests.
- Python-kompilering, JavaScript-syntakskontrol og applikationens health-check er bestået.
## Tilbageførsel
Tilstanden før bundmenu- og timergennemgangen er bevaret i branch `checkpoint-before-bottom-bar-review-20260817` på commit `88f7cec`.

View File

@ -0,0 +1,34 @@
# Release Notes: v2.5.1
**Dato:** 18. august 2026
## Overblik
Version 2.5.1 retter de vigtigste fejl fundet efter 2.5-udgivelsen i tidsregistrering, VoIP og oprettelse af nye sager.
## Tidsregistrering
- Manuel tidsregistrering bruger nu den indtastede lokale dato og tid uden utilsigtet tidszoneforskydning.
- Flere medarbejdere kan vælges i samme registrering, og der oprettes én tidsregistrering under hver valgt medarbejder.
- Medarbejdervælgeren viser sagens tilgængelige brugere tydeligt og er adskilt fra live tracking.
- Registreringer, der krydser midnat, får korrekt slutdato.
- Sagsvisningen leveres uden browsercache, så de seneste ændringer i tidsregistreringen vises med det samme.
## VoIP
- Ved et ukendt telefonnummer kan en ny kontakt knyttes til et valgt firma direkte i VoIP-popupen.
- De viste åbne sager kan åbnes og knyttes direkte til opkaldet.
- Click-to-call accepterer nu en godkendt bruger på Hubens lokale eller interne adresse, selv når en proxy skjuler klientens lokale IP-adresse.
- VoIP-klientens cacheversion er opdateret.
## Opret ny sag
- Når et firma vælges først, hentes og vises firmaets kontaktpersoner automatisk.
- Kontaktsøgningen afgrænses til det valgte firmas kontakter og søger på navn, rolle, e-mail og telefon.
- Fjernes firmaet, skifter kontaktfeltet tilbage til den globale kontaktsøgning.
- Det er fortsat muligt at vælge flere kontaktpersoner på sagen.
## Verifikation
- Målrettede tests for sager, tidsregistrering og VoIP: **36 bestået**.
- Python- og diff-kontrol samt lokal API-health-check er gennemført.

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

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

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

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

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

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

View 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`.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -1 +1 @@
2.3.30 2.8.16

1
app/admin/__init__.py Normal file
View File

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

254
app/admin/archive_bundle.py Normal file
View 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
View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const iso=d=>d.toISOString().slice(0,10); let optionsData={};
function defaults(){const now=new Date(),hTo=new Date(now),hFrom=new Date(now),vTo=new Date(now),vFrom=new Date(now);hFrom.setDate(hFrom.getDate()-29);vTo.setDate(hFrom.getDate()-1);vFrom.setDate(vTo.getDate()-29);hubTo.value=iso(hTo);hubFrom.value=iso(hFrom);vtTo.value=iso(vTo);vtFrom.value=iso(vFrom)}
async function api(url,opt={}){const r=await fetch(url,{credentials:'include',...opt,headers:{'Content-Type':'application/json',...(opt.headers||{})}});if(!r.ok){let e={};try{e=await r.json()}catch{}throw new Error(e.detail||`HTTP ${r.status}`)}return r.json()}
async function loadOptions(){optionsData=await api('/api/v1/admin/hub-impact/options');archiveVersion.innerHTML=optionsData.versions.map(v=>`<option value="${v.id}">Version ${v.id} · ${esc(v.sync_kind)} · ${new Date(v.completed_at).toLocaleString('da-DK')}${v.control_approved_at?' · godkendt':''}</option>`).join('');setRawLink();const ready=optionsData.readiness.ready;readinessBadge.className=`ct-status ${ready?'ready':'blocked'}`;readinessBadge.textContent=ready?'Klar til opsigelse':'Ikke klar til opsigelse';readinessReasons.textContent=(optionsData.readiness.reasons||[]).join(' · ');savedReports.innerHTML=optionsData.reports.length?optionsData.reports.map(r=>`<button class="list-group-item list-group-item-action d-flex justify-content-between" onclick="openReport(${r.id})"><span>Rapport #${r.id} · arkiv v${r.archive_version_id}</span><small>${esc(r.vtiger_from)}${esc(r.vtiger_to)} / ${esc(r.hub_from)}${esc(r.hub_to)}</small></button>`).join(''):'<div class="text-muted">Ingen gemte rapporter endnu.</div>'}
async function loadTransfers(){const data=await api('/api/v1/admin/vtiger-archive/status');const files=data.file_archive||[];fileArchiveStatus.textContent=files.length?`Originale filer i arkivet: ${files.map(x=>`${x.source_module} ${x.files} (${(Number(x.size_bytes)/1048576).toFixed(1)} MB)`).join(' · ')}`:'Ingen originale filer arkiveret endnu.';const rows=data.transfers||[];transferRows.className='table-responsive';transferRows.innerHTML=rows.length?`<table class="table align-middle ct-table mb-0"><thead><tr><th>Tid</th><th>Retning</th><th>Status</th><th>Til version</th><th>Checksum</th><th>Indhold</th></tr></thead><tbody>${rows.map(x=>`<tr><td>${new Date(x.completed_at||x.started_at).toLocaleString('da-DK')}</td><td>${x.direction==='export'?'Test → prod':'Pakke → denne server'}</td><td><span class="badge ${x.status==='completed'?'bg-success':x.status==='failed'?'bg-danger':'bg-warning text-dark'}">${esc(x.status)}</span></td><td>${x.through_version_id||'—'}</td><td><code>${esc((x.bundle_sha256||'').slice(0,12))}${x.bundle_sha256?'…':'—'}</code></td><td>${Object.entries(x.counts||{}).map(([k,v])=>`${esc(k)}: ${v}`).join(' · ')||esc(x.error_message||'—')}</td></tr>`).join('')}</tbody></table>`:'<div class="ct-empty">Ingen overførsler endnu.</div>'}
function setRawLink(){rawExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/versions/${archiveVersion.value}/raw-export`:'#';bundleExport.href=archiveVersion.value?`/api/v1/admin/vtiger-archive/bundle.zip?through_version_id=${archiveVersion.value}`:'#'}archiveVersion.onchange=setRawLink;
function render(id,r){resultArea.classList.remove('d-none');excelExport.href=`/api/v1/admin/hub-impact/reports/${id}/export.xlsx`;const labels={hours:'Timer',time_entries:'Tidsregistreringer',cases:'Sager',orders:'Ordrer'};kpis.innerHTML=Object.entries(labels).map(([k,l])=>{const d=r.comparison[k],pct=r.improvement?.percent?.[k],cls=d>0?'positive':d<0?'negative':'';return `<div class="col-sm-6 col-xl-3"><div class="ct-kpi"><div class="text-muted small fw-bold">${l}</div><div class="d-flex justify-content-between mt-2"><span>vTiger <strong>${r.vtiger.totals[k]}</strong></span><span>Hub <strong>${r.hub.totals[k]}</strong></span></div><div class="ct-delta ${cls} mt-2 fw-bold">Forskel ${d>0?'+':''}${d}${pct==null?'':` · ${pct>0?'+':''}${pct}%`}</div></div></div>`}).join('');impactEvidence.innerHTML=(r.improvement?.evidence||[]).map((text,i)=>`<div class="col-md-6"><div class="ct-kpi d-flex gap-3 align-items-center"><span class="rounded-circle bg-primary text-white d-inline-flex align-items-center justify-content-center" style="width:34px;height:34px;flex:0 0 34px">${i+1}</span><strong class="fs-6">${esc(text)}</strong></div></div>`).join('')||'<div class="text-muted">Ikke nok vTiger-baseline til at beregne procentvis effekt.</div>';employeeRows.innerHTML=['vtiger','hub'].flatMap(src=>r[src].employees.map(x=>`<tr><td><span class="badge ${src==='hub'?'bg-primary':'bg-secondary'}">${src}</span></td><td>${esc(x.name)}</td><td>${esc(x.email||'—')}</td><td>${x.hours}</td><td>${x.time_entries}</td><td>${x.cases}</td><td>${x.orders}</td><td>${x.productivity.hours_per_workday}</td></tr>`)).join('');const aa=[...r.vtiger.anomalies,...r.hub.anomalies];anomalies.innerHTML=aa.length?aa.map(a=>`<div class="border-bottom py-2"><strong>${esc(a.source)}</strong> · ${esc(a.type)} · #${esc(a.id)} · ${a.hours} timer</div>`).join(''):'<div class="text-success">Ingen afvigelser i perioderne.</div>';quality.innerHTML=['vtiger','hub'].map(src=>`<div class="mb-3"><strong>${src}</strong><div>Manglende datoer: ${r[src].data_quality.missing_dates}</div><div>Ikke matchede medarbejdere: ${r[src].data_quality.unmatched_employees.length}</div></div>`).join('')}
async function openReport(id){feedback.textContent='Indlæser rapport…';try{const row=await api(`/api/v1/admin/hub-impact/reports/${id}`);render(id,row.result);feedback.textContent=`Rapport #${id} indlæst.`}catch(e){feedback.textContent=e.message}}
runReport.onclick=async()=>{runReport.disabled=true;feedback.textContent='Beregner og gemmer rapport…';try{const x=await api('/api/v1/admin/hub-impact/reports',{method:'POST',body:JSON.stringify({archive_version_id:Number(archiveVersion.value),vtiger_from:vtFrom.value,vtiger_to:vtTo.value,hub_from:hubFrom.value,hub_to:hubTo.value})});render(x.id,x.result);feedback.textContent=`Rapport #${x.id} er gemt reproducerbart.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{runReport.disabled=false}};
async function sync(kind,button){if(kind==='final'&&!confirm('Final sync bruges som grundlag for opsigelsesgodkendelsen. Fortsæt?'))return;button.disabled=true;feedback.textContent=`Kører ${kind} sync…`;try{const r=await api(`/api/v1/admin/vtiger-archive/sync/${kind}`,{method:'POST'});feedback.textContent=`Arkivversion ${r.version_id}: ${r.status}`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{button.disabled=false}}
approveFinal.onclick=async()=>{const final=optionsData.versions.find(v=>v.sync_kind==='final');if(!final){feedback.textContent='Der findes endnu ingen fuldført final sync.';return}approveFinal.disabled=true;try{await api(`/api/v1/admin/vtiger-archive/versions/${final.id}/approve-control`,{method:'POST'});feedback.textContent=`Kontrolrapport for final version ${final.id} er godkendt.`;await loadOptions()}catch(e){feedback.textContent=e.message}finally{approveFinal.disabled=false}};
bundleImportBtn.onclick=()=>bundleImportFile.click();bundleImportFile.onchange=async()=>{const file=bundleImportFile.files[0];if(!file)return;if(!confirm(`Importér ${file.name}? Importen bruger kun filen og kontakter aldrig vTiger.`))return;bundleImportBtn.disabled=true;feedback.textContent='Validerer checksums og importerer hele arkivpakken…';try{const form=new FormData();form.append('file',file);const response=await fetch('/api/v1/admin/vtiger-archive/import-bundle',{method:'POST',body:form,credentials:'include'});const data=await response.json();if(!response.ok)throw new Error(data.detail||`HTTP ${response.status}`);feedback.textContent=data.already_imported?'Denne præcise pakke var allerede importeret; intet blev duplikeret.':`Import færdig: ${data.counts.records} revisioner, ${data.counts.relations} relationer. vTiger kontaktet: nej.`;await Promise.all([loadOptions(),loadTransfers()])}catch(e){feedback.textContent=e.message}finally{bundleImportBtn.disabled=false;bundleImportFile.value=''}};
refreshTransfers.onclick=()=>loadTransfers().catch(e=>feedback.textContent=e.message);syncFull.onclick=()=>sync('full',syncFull);syncIncremental.onclick=()=>sync('incremental',syncIncremental);syncFinal.onclick=()=>sync('final',syncFinal);defaults();Promise.all([loadOptions(),loadTransfers()]).catch(e=>feedback.textContent=e.message);
</script>
{% endblock %}

235
app/admin/hub_impact.py Normal file
View 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
View 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
View 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
View 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}

View File

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

View File

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

View File

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

View File

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

View File

@ -94,17 +94,18 @@ def _rate_limit(user_id: int) -> None:
def _resolve_customer_id() -> int: def _resolve_customer_id() -> int:
configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID) # Resolve our own company by identity, never by an arbitrary/old numeric ID.
configured_row = execute_query_single("SELECT id FROM customers WHERE id = %s", (configured_id,)) # Customer IDs differ between environments and ID 1 may belong to a supplier.
if configured_row:
return int(configured_row["id"])
named_row = execute_query_single( named_row = execute_query_single(
""" """
SELECT id SELECT c.id
FROM customers FROM customers c
WHERE LOWER(name) = LOWER(%s) LEFT JOIN sag_sager s ON s.customer_id = c.id AND s.deleted_at IS NULL
ORDER BY id ASC 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 LIMIT 1
""", """,
("BMC Networks",), ("BMC Networks",),
@ -112,11 +113,23 @@ def _resolve_customer_id() -> int:
if named_row: if named_row:
return int(named_row["id"]) return int(named_row["id"])
fallback = execute_query_single("SELECT id FROM customers ORDER BY id ASC LIMIT 1") configured_id = int(settings.BUG_REPORT_DEFAULT_CUSTOMER_ID)
if fallback: configured_row = execute_query_single(
return int(fallback["id"]) """
SELECT id
FROM customers
WHERE id = %s
AND (
LOWER(name) LIKE '%%bmc networks%%'
OR REGEXP_REPLACE(COALESCE(cvr_number, ''), '[^0-9]', '', 'g') IN ('29522790', '14416285')
)
""",
(configured_id,),
)
if configured_row:
return int(configured_row["id"])
raise HTTPException(status_code=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) @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: if len(raw) > settings.BUG_REPORT_MAX_SCREENSHOT_BYTES:
raise HTTPException(status_code=400, detail="Screenshot too large") raise HTTPException(status_code=400, detail="Screenshot too large")
stored_name, size = _store_raw_file(raw, f"bugreport_{sag_id}.png") extension = "jpg" if content_type in {"image/jpeg", "image/jpg"} else "png"
_create_sag_file_record(sag_id, "screenshot.png", content_type, size, stored_name) 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 # Attach logs as json file
logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8") logs_raw = json.dumps(payload.logs or [], ensure_ascii=False, indent=2).encode("utf-8")

View File

@ -3,8 +3,8 @@ Contact API Router - Simplified (Read-Only)
Only GET endpoints for now Only GET endpoints for now
""" """
from fastapi import APIRouter, HTTPException, Query, Body, status from fastapi import APIRouter, HTTPException, Query, Body, status, UploadFile, File
from typing import Optional from typing import Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.core.database import ( from app.core.database import (
execute_query, execute_query,
@ -15,6 +15,7 @@ from app.core.database import (
) )
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from app.core.contact_utils import get_contact_customer_ids, get_primary_customer_id 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 ( from app.customers.backend.router import (
get_customer_subscriptions, get_customer_subscriptions,
lock_customer_subscriptions, lock_customer_subscriptions,
@ -22,9 +23,14 @@ from app.customers.backend.router import (
get_subscription_comment, get_subscription_comment,
get_subscription_billing_matrix, get_subscription_billing_matrix,
SubscriptionComment, SubscriptionComment,
CustomerCreate,
create_customer,
) )
import logging import logging
import json import json
import re
import html
from email.utils import parseaddr
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -63,6 +69,17 @@ class ContactMergeRequest(BaseModel):
source_contact_id: int = Field(..., gt=0) 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 = ( CONTACT_MERGE_RELATIONS = (
("Firmaer", "contact_companies", "contact_id"), ("Firmaer", "contact_companies", "contact_id"),
("Sager", "sag_kontakter", "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): class ContactCompanyLink(BaseModel):
customer_id: int customer_id: int
is_primary: bool = True is_primary: bool = True
@ -147,28 +483,32 @@ async def get_contacts(
where_clauses = [] where_clauses = []
params = [] params = []
if search: normalized_search = " ".join(str(search or "").split())
where_clauses.append( if normalized_search:
for token in normalized_search.split(" "):
token_clause = """
(
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
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s
)
""" """
( token_params: list[Any] = [f"%{token}%"] * 7
c.first_name ILIKE %s digits = "".join(ch for ch in token if ch.isdigit())
OR c.last_name ILIKE %s if len(digits) >= 2:
OR c.email ILIKE %s token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
OR c.phone ILIKE %s token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
OR c.mobile ILIKE %s token_params.extend([f"%{digits}%", f"%{digits}%"])
OR c.user_company ILIKE %s token_clause += ")"
OR EXISTS ( where_clauses.append(token_clause)
SELECT 1 params.extend(token_params)
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like, like])
if is_active is not None: if is_active is not None:
where_clauses.append("c.is_active = %s") 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 total = count_result[0]['count'] if count_result else 0
# Step 1: Fetch contacts only (stable pagination) # 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""" contacts_query = f"""
SELECT SELECT
c.id, c.first_name, c.last_name, c.email, c.phone, c.mobile, 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 c.title, c.department, c.user_company, c.is_active, c.created_at, c.updated_at
FROM contacts c FROM contacts c
{where_sql} {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 LIMIT %s OFFSET %s
""" """
contacts_params = list(params) contacts_params = list(params)
contacts_params.extend(rank_params)
contacts_params.extend([limit, offset]) contacts_params.extend([limit, offset])
contacts = execute_query(contacts_query, tuple(contacts_params)) or [] contacts = execute_query(contacts_query, tuple(contacts_params)) or []

View File

@ -116,6 +116,21 @@
top: 1rem; top: 1rem;
right: 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> </style>
{% endblock %} {% endblock %}
@ -150,6 +165,18 @@
</div> </div>
</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 --> <!-- Alert Notes Container -->
<div id="alert-notes-container"></div> <div id="alert-notes-container"></div>
@ -173,6 +200,16 @@
<i class="bi bi-people"></i>Kontakter <i class="bi bi-people"></i>Kontakter
</a> </a>
</li> </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"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#invoices"> <a class="nav-link" data-bs-toggle="tab" href="#invoices">
<i class="bi bi-receipt"></i>Fakturaer <i class="bi bi-receipt"></i>Fakturaer
@ -343,6 +380,24 @@
</div> </div>
</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 --> <!-- Invoices Tab -->
<div class="tab-pane fade" id="invoices"> <div class="tab-pane fade" id="invoices">
<h5 class="fw-bold mb-4">Fakturaer</h5> <h5 class="fw-bold mb-4">Fakturaer</h5>
@ -832,6 +887,27 @@
</div> </div>
</div> </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 %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@ -849,10 +925,12 @@ let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all'; let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null; let mergeSourceContactId = null;
let mergeSearchTimer = null; let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadContact(); loadContact();
loadCompaniesForSelect(); loadCompaniesForSelect();
initializeContactEmailDropzone();
const mergeSearch = document.getElementById('mergeContactSearch'); const mergeSearch = document.getElementById('mergeContactSearch');
if (mergeSearch) { 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"]'); const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) { if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => { 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() { async function loadContact() {
try { try {
const response = await fetch(`/api/v1/contacts/${contactId}`); const response = await fetch(`/api/v1/contacts/${contactId}`);

View File

@ -490,6 +490,24 @@
padding: 0; 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) { @media (max-width: 992px) {
.contacts-toolbar { .contacts-toolbar {
width: 100%; width: 100%;
@ -528,9 +546,16 @@
</div> </div>
</div> </div>
</div> </div>
<button class="btn btn-primary" onclick="showCreateContactModal()"> <div class="d-flex gap-2 flex-wrap">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt <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">
</button> <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>
<div class="mb-4 d-flex gap-2 flex-wrap"> <div class="mb-4 d-flex gap-2 flex-wrap">
@ -632,6 +657,55 @@
</div> </div>
</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 --> <!-- Create Contact Modal -->
<div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1"> <div class="modal fade create-contact-modal" id="createContactModal" tabindex="-1">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
@ -819,6 +893,7 @@ let selectedCompanyIds = new Set();
let currentContactsData = []; let currentContactsData = [];
let pendingCreateModalCustomerId = null; let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null; let pendingCreateReturnTo = null;
let analyzedMailCompany = null;
let currentSort = { let currentSort = {
key: 'name', key: 'name',
direction: 'asc' direction: 'asc'
@ -832,6 +907,12 @@ let visibleColumns = {
// Load contacts on page load // Load contacts on page load
document.addEventListener('DOMContentLoaded', () => { 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 urlParams = new URLSearchParams(window.location.search);
const preselectedCustomerId = Number(urlParams.get('customer_id')); const preselectedCustomerId = Number(urlParams.get('customer_id'));
const shouldOpenCreateModal = urlParams.get('create') === '1'; 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) { function setFilter(filter) {
currentFilter = filter; currentFilter = filter;
currentPage = 0; currentPage = 0;
@ -1425,6 +1542,105 @@ function renderSelectedCompanies() {
`).join(''); `).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() { function showCreateContactModal() {
// Reset form // Reset form
document.getElementById('createContactForm').reset(); document.getElementById('createContactForm').reset();

View File

@ -14,6 +14,21 @@ class Settings(BaseSettings):
# Database # Database
DATABASE_URL: str = "postgresql://bmc_hub:bmc_hub@localhost:5432/bmc_hub" 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
API_HOST: str = "0.0.0.0" API_HOST: str = "0.0.0.0"
@ -22,6 +37,10 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Non-interactive service token for Apple Configurator/cfgutil provisioning.
# Leave empty to disable the endpoint rather than accepting unauthenticated calls.
MOBILE_RECORDER_PROVISIONING_TOKEN: str = ""
# Elnet supplier lookup # Elnet supplier lookup
ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api" ELNET_API_BASE_URL: str = "https://api.elnet.greenpowerdenmark.dk/api"
ELNET_TIMEOUT_SECONDS: int = 12 ELNET_TIMEOUT_SECONDS: int = 12
@ -104,6 +123,9 @@ class Settings(BaseSettings):
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434" OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2" OLLAMA_MODEL: str = "llama3.2"
# Primary company CVR used to prevent AI invoice extraction from treating
# the buyer (BMC) as the supplier.
OWN_CVR: str = "29522790"
# Email System Configuration # Email System Configuration
# IMAP Settings # IMAP Settings
@ -210,6 +232,8 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30 ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000 ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
# Backup System Configuration # Backup System Configuration
BACKUP_ENABLED: bool = True BACKUP_ENABLED: bool = True
@ -326,6 +350,24 @@ class Settings(BaseSettings):
FEDEX_BASE_URL: str = "" FEDEX_BASE_URL: str = ""
FEDEX_TIMEOUT_SECONDS: int = 20 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 Cloud Marketplace Integration
ALSO_ENABLED: bool = False ALSO_ENABLED: bool = False
ALSO_READ_ONLY: bool = True ALSO_READ_ONLY: bool = True

View 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',
)

View File

@ -10,6 +10,7 @@ from pydantic import BaseModel
import logging import logging
import asyncio import asyncio
import aiohttp import aiohttp
import re
from urllib.parse import quote from urllib.parse import quote
from app.core.database import execute_query, execute_query_single, execute_update, execute_insert 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 is_active: Optional[bool] = True
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None
supplier_service_enrolled: Optional[bool] = False
class CustomerCreate(CustomerBase): class CustomerCreate(CustomerBase):
@ -133,6 +135,7 @@ class CustomerUpdate(BaseModel):
invoice_email: Optional[str] = None invoice_email: Optional[str] = None
mobile_phone: Optional[str] = None mobile_phone: Optional[str] = None
department: Optional[str] = None department: Optional[str] = None
supplier_service_enrolled: Optional[bool] = None
class EmailDomainCreate(BaseModel): 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") @router.get("/customers/{customer_id}/utility-company")
async def get_customer_utility_company(customer_id: int): async def get_customer_utility_company(customer_id: int):
@ -739,8 +812,8 @@ async def create_customer(customer: CustomerCreate):
customer_id = execute_insert( customer_id = execute_insert(
"""INSERT INTO customers """INSERT INTO customers
(name, cvr_number, email, email_domain, phone, address, city, postal_code, (name, cvr_number, email, email_domain, phone, address, city, postal_code,
country, website, is_active, invoice_email, mobile_phone) 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) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""", RETURNING id""",
( (
customer.name, customer.name,
@ -755,7 +828,8 @@ async def create_customer(customer: CustomerCreate):
customer.website, customer.website,
customer.is_active, customer.is_active,
customer.invoice_email, customer.invoice_email,
customer.mobile_phone customer.mobile_phone,
customer.supplier_service_enrolled
) )
) )

View File

@ -884,9 +884,14 @@
<i class="bi bi-list-check"></i>Sager <i class="bi bi-list-check"></i>Sager
</a> </a>
</li> </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"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#kontakt"> <a class="nav-link" data-bs-toggle="tab" href="#kontakt">
<i class="bi bi-chat-left-text"></i>Kontakt <i class="bi bi-chat-left-text"></i>Kommunikation
</a> </a>
</li> </li>
<li class="nav-item"> <li class="nav-item">
@ -1047,6 +1052,13 @@
<span class="info-label">Spærret</span> <span class="info-label">Spærret</span>
<span class="info-value" id="barred">-</span> <span class="info-value" id="barred">-</span>
</div> </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>
</div> </div>
@ -1216,11 +1228,27 @@
</div> </div>
</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="tab-pane fade" id="kontakt">
<div class="d-flex justify-content-between align-items-center mb-4"> <div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Kontakt historik</h5> <div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kontakt filter"> <h5 class="fw-bold mb-0">Kommunikationshistorik</h5>
<small class="text-muted">Opkald og SMSer med kunden</small>
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Kommunikationsfilter">
<button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button> <button type="button" class="btn btn-outline-secondary active" id="customerKontaktFilterAll" onclick="setCustomerKontaktFilter('all')">Alle</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button> <button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterSms" onclick="setCustomerKontaktFilter('sms')">SMS</button>
<button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button> <button type="button" class="btn btn-outline-secondary" id="customerKontaktFilterCall" onclick="setCustomerKontaktFilter('call')">Opkald</button>
@ -2296,6 +2324,8 @@ let customerKontaktFilter = 'all';
let customerInvoicesLoaded = false; let customerInvoicesLoaded = false;
let customerInvoicesData = []; let customerInvoicesData = [];
let customerInvoiceSearchTerm = ''; let customerInvoiceSearchTerm = '';
let customerEmailsOffset = 0;
const customerEmailsLimit = 25;
let eventListenersAdded = false; let eventListenersAdded = false;
@ -2327,6 +2357,11 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { 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"]'); const kontaktTab = document.querySelector('a[href="#kontakt"]');
if (kontaktTab) { if (kontaktTab) {
kontaktTab.addEventListener('shown.bs.tab', () => { kontaktTab.addEventListener('shown.bs.tab', () => {
@ -2730,6 +2765,7 @@ function displayCustomer(customer) {
document.getElementById('barred').innerHTML = customer.barred document.getElementById('barred').innerHTML = customer.barred
? '<span class="badge bg-danger">Ja</span>' ? '<span class="badge bg-danger">Ja</span>'
: '<span class="badge bg-success">Nej</span>'; : '<span class="badge bg-success">Nej</span>';
document.getElementById('supplierServiceEnrolled').checked = customer.supplier_service_enrolled === true;
// Integration // Integration
document.getElementById('vtigerId').textContent = customer.vtiger_id || '-'; document.getElementById('vtigerId').textContent = customer.vtiger_id || '-';
@ -2745,6 +2781,68 @@ function displayCustomer(customer) {
loadAndDisplayAlerts('customer', customer.id, 'inline', 'alert-notes-container'); 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) { function renderCustomerCallNumber(number) {
const clean = normalizePhoneValue(number); const clean = normalizePhoneValue(number);
if (!clean) return '-'; if (!clean) return '-';

View File

@ -48,6 +48,41 @@ class BulkSendRequest(BaseModel):
ids: List[int] = Field(..., min_length=1) 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]: def _ensure_ids(ids: List[int]) -> List[int]:
clean = sorted(set(int(i) for i in ids if int(i) > 0)) clean = sorted(set(int(i) for i in ids if int(i) > 0))
if not clean: if not clean:
@ -55,6 +90,22 @@ def _ensure_ids(ids: List[int]) -> List[int]:
return clean 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") @router.get("/time-queue")
async def list_hub_time_queue( async def list_hub_time_queue(
customer_id: Optional[int] = Query(None, gt=0), customer_id: Optional[int] = Query(None, gt=0),
@ -68,12 +119,15 @@ async def list_hub_time_queue(
conditions = [ conditions = [
"t.vtiger_id IS NULL", "t.vtiger_id IS NULL",
"t.billed_via_thehub_id IS NULL", "t.billed_via_thehub_id IS NULL",
"t.economy_order_draft_id IS NULL",
"t.status <> 'billed'", "t.status <> 'billed'",
] ]
params: List[Any] = [] 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: 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) params.append(customer_id)
if status: if status:
@ -88,7 +142,7 @@ async def list_hub_time_queue(
conditions.append( conditions.append(
"(" "("
"COALESCE(t.description, '') ILIKE %s OR " "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" "COALESCE(c.title, s.titel, '') ILIKE %s"
")" ")"
) )
@ -100,8 +154,9 @@ async def list_hub_time_queue(
query = f""" query = f"""
SELECT SELECT
t.id, t.id,
t.customer_id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
cust.name AS customer_name, COALESCE(case_customer.name, effective_customer.name) AS customer_name,
t.customer_id AS recorded_customer_id,
t.status, t.status,
t.entry_status, t.entry_status,
t.billable, t.billable,
@ -110,20 +165,47 @@ async def list_hub_time_queue(
t.fixed_price_agreement_id, t.fixed_price_agreement_id,
t.original_hours, t.original_hours,
t.approved_hours, t.approved_hours,
t.faktisk_tid_min,
t.fakturerbar_tid_min,
t.round_block_min,
t.rounded_to, t.rounded_to,
t.worked_date, t.worked_date,
t.description, t.description,
t.entry_type, t.entry_type,
t.work_type,
t.kilde, t.kilde,
t.case_id, t.case_id,
t.sag_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.created_at,
t.updated_at t.updated_at
FROM tmodule_times t 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 tmodule_cases c ON c.id = t.case_id
LEFT JOIN sag_sager s ON s.id = t.sag_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} WHERE {where_sql}
ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC ORDER BY COALESCE(t.worked_date, DATE(t.created_at)) DESC, t.id DESC
LIMIT %s LIMIT %s
@ -146,17 +228,28 @@ async def list_time_queue_customers():
rows = execute_query( rows = execute_query(
""" """
SELECT SELECT
t.customer_id, COALESCE(s.customer_id, effective_customer.hub_customer_id) AS customer_id,
COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) AS customer_name, 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 COUNT(*)::int AS open_count
FROM tmodule_times t FROM tmodule_times t
LEFT JOIN tmodule_customers cust ON cust.id = t.customer_id LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.customer_id IS NOT NULL 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.vtiger_id IS NULL
AND t.billed_via_thehub_id IS NULL AND t.billed_via_thehub_id IS NULL
AND t.economy_order_draft_id IS NULL
AND t.status = 'pending' AND t.status = 'pending'
GROUP BY t.customer_id, cust.name GROUP BY COALESCE(s.customer_id, effective_customer.hub_customer_id), case_customer.name, effective_customer.name
ORDER BY COALESCE(cust.name, CONCAT('Kunde #', t.customer_id::text)) ASC 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)} return {"items": rows, "count": len(rows)}
@ -166,15 +259,24 @@ async def list_time_queue_customers():
@router.get("/time-queue/prepaid-cards") @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: 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( cards = execute_query(
""" f"""
SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours, remaining_hours, status, expires_at SELECT id, card_number, customer_id, purchased_hours AS total_hours, used_hours,
remaining_hours, rounding_minutes, status, expires_at
FROM tticket_prepaid_cards FROM tticket_prepaid_cards
WHERE status IN ('active', 'depleted') {where_sql}
ORDER BY remaining_hours DESC, id DESC ORDER BY remaining_hours DESC, id DESC
""" """,
tuple(params),
) )
return {"items": cards, "count": len(cards)} return {"items": cards, "count": len(cards)}
except Exception as e: except Exception as e:
@ -218,6 +320,7 @@ async def bulk_update_time_queue(payload: BulkUpdateRequest):
WHERE id IN ({placeholders}) WHERE id IN ({placeholders})
AND vtiger_id IS NULL AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed' AND status <> 'billed'
""" """
execute_update(query, tuple(values + ids)) execute_update(query, tuple(values + ids))
@ -244,6 +347,7 @@ async def bulk_soft_delete_time_queue(payload: BulkSoftDeleteRequest):
WHERE id IN ({placeholders}) WHERE id IN ({placeholders})
AND vtiger_id IS NULL AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed' AND status <> 'billed'
""", """,
tuple([reason] + ids), tuple([reason] + ids),
@ -283,6 +387,7 @@ async def bulk_approve_time_queue(payload: BulkApproveRequest):
WHERE id IN ({placeholders}) WHERE id IN ({placeholders})
AND vtiger_id IS NULL AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed' AND status <> 'billed'
""" """
execute_update(query, tuple(params + ids)) execute_update(query, tuple(params + ids))
@ -315,6 +420,7 @@ async def bulk_apply_prepaid(payload: BulkPrepaidRequest):
WHERE id IN ({placeholders}) WHERE id IN ({placeholders})
AND vtiger_id IS NULL AND vtiger_id IS NULL
AND billed_via_thehub_id IS NULL AND billed_via_thehub_id IS NULL
AND economy_order_draft_id IS NULL
AND status <> 'billed' AND status <> 'billed'
""", """,
tuple([payload.prepaid_card_id] + ids), 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) return int(order_id)
def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int: def _create_ordre_draft_from_selected(hub_customer_id: int, rows: List[Dict[str, Any]], user_id: Optional[int]) -> int:
customer = execute_query_single( """Create an order draft for the actual Hub customer on the case.
"SELECT id, hub_customer_id, name, hourly_rate FROM tmodule_customers WHERE id = %s",
(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 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 customer: if not hub_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found") raise HTTPException(status_code=404, detail=f"Kunden på sagen ({hub_customer_id}) findes ikke")
hourly_rate = Decimal(str(customer.get("hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE)) customer_name = hub_customer.get("name") or f"Kunde {hub_customer_id}"
hub_customer_id = customer.get("hub_customer_id") hourly_rate = Decimal(str(hub_customer.get("standard_hourly_rate") or settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
hub_customer = None
if hub_customer_id:
hub_customer = execute_query_single(
"""
SELECT
standard_hourly_rate,
standard_margin_percent,
special_freight_price,
supplier_service_enrolled,
invoice_fee_amount
FROM customers
WHERE id = %s
""",
(hub_customer_id,),
)
invoice_fee_amount = Decimal( invoice_fee_amount = Decimal(
str( str(
@ -529,8 +629,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
"product_id": None, "product_id": None,
"selected": True, "selected": True,
"amount": float(amount), "amount": float(amount),
"customer_id": int(hub_customer_id) if hub_customer_id else None, "customer_id": int(hub_customer_id),
"customer_name": customer.get("name") or f"Kunde {customer_id}", "customer_name": customer_name,
"sag_id": group["sag_id"], "sag_id": group["sag_id"],
"time_entry_ids": ids, "time_entry_ids": ids,
"time_date": str(latest_date) if latest_date else None, "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: if special_freight_amount > 0:
line_payloads.append( line_payloads.append(
{ {
"line_key": f"freight:{hub_customer_id or customer_id}", "line_key": f"freight:{hub_customer_id}",
"source_type": "freight", "source_type": "freight",
"source_id": None, "source_id": None,
"description": "Særlig fragtpris", "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, "product_id": None,
"selected": True, "selected": True,
"amount": float(special_freight_amount.quantize(Decimal("0.01"))), "amount": float(special_freight_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None, "customer_id": int(hub_customer_id),
"customer_name": customer.get("name") or f"Kunde {customer_id}", "customer_name": customer_name,
"sag_id": None, "sag_id": None,
"time_entry_ids": [], "time_entry_ids": [],
"time_date": None, "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: if invoice_fee_amount > 0 and not supplier_service_enrolled:
line_payloads.append( 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_type": "invoice_fee",
"source_id": None, "source_id": None,
"description": "Faktureringsgebyr", "description": "Faktureringsgebyr",
@ -578,8 +678,8 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
"product_id": None, "product_id": None,
"selected": True, "selected": True,
"amount": float(invoice_fee_amount.quantize(Decimal("0.01"))), "amount": float(invoice_fee_amount.quantize(Decimal("0.01"))),
"customer_id": int(hub_customer_id) if hub_customer_id else None, "customer_id": int(hub_customer_id),
"customer_name": customer.get("name") or f"Kunde {customer_id}", "customer_name": customer_name,
"sag_id": None, "sag_id": None,
"time_entry_ids": [], "time_entry_ids": [],
"time_date": None, "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: if not line_payloads:
raise HTTPException(status_code=400, detail="No order lines generated from selected entries") 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()}" draft_title = f"Timefaktura {customer_name} - {date.today().isoformat()}"
invoice_aggregate_key = f"timequeue-customer-{hub_customer_id or customer_id}" invoice_aggregate_key = f"timequeue-customer-{hub_customer_id}"
draft = execute_query_single( draft = execute_query_single(
""" """
@ -614,7 +714,7 @@ def _create_ordre_draft_from_selected(customer_id: int, rows: List[Dict[str, Any
""", """,
( (
draft_title, draft_title,
int(hub_customer_id) if hub_customer_id else None, int(hub_customer_id),
json.dumps(line_payloads, ensure_ascii=False), json.dumps(line_payloads, ensure_ascii=False),
"Genereret fra Economy Time Queue", "Genereret fra Economy Time Queue",
1, 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]: 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: The historic function name remains for callers, but its return value is a
- direct tmodule customer id `customers.id`. The case company is authoritative; tracking rows are only
- hub customer id (customers.id) via tmodule_customers.hub_customer_id a fallback for time entries without a case.
- fallback via sag_sager.customer_id -> tmodule_customers.hub_customer_id
""" """
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: if sag_id is not None:
try: try:
sid = int(sag_id) 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,)) 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 hub_customer_id = (sag or {}).get("customer_id") if sag else None
if hub_customer_id: if hub_customer_id:
mapped = _find_by_hub_customer_id(int(hub_customer_id)) return int(hub_customer_id)
if mapped:
return mapped 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 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") @router.post("/time-queue/send-to-invoices")
async def send_selected_to_invoices(payload: BulkSendRequest, request: Request): 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) ids = _ensure_ids(payload.ids)
user_id = getattr(request.state, "user_id", None) user_id = getattr(request.state, "user_id", None)

View File

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

View File

@ -1,510 +1,65 @@
{% extends "shared/frontend/base.html" %} {% extends "shared/frontend/base.html" %}
{% block title %}Tidskø til afregning{% endblock %}
{% block title %}Economy Time Queue{% endblock %}
{% block content %} {% block content %}
<div class="container-fluid py-4"> <style>
<div class="d-flex flex-wrap align-items-center justify-content-between mb-3"> .time-queue { max-width: 1700px; }
<div> .queue-summary { background:linear-gradient(135deg,#f7fbff,#fff); border:1px solid #d8e3ed; border-radius:1rem; }
<h2 class="mb-1">Economy Time Queue</h2> .queue-stat { min-width:150px; }.queue-stat strong{font-size:1.5rem;color:#15476c}
<p class="text-muted mb-0">Hub-created, non-billed time entries. Opretter kun lokale ordrer.</p> .time-group { border:1px solid #dce5ed;border-radius:.85rem;overflow:hidden }.time-group header{background:#f5f8fb;border-bottom:1px solid #dce5ed}
</div> .time-group header { padding:.45rem .75rem!important; font-size:.85rem; }
<div class="d-flex gap-2 mt-2 mt-md-0 align-items-center"> .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}
<span class="badge text-bg-secondary" id="selectedCountBadge">0 selected</span> .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; }
<button class="btn btn-outline-secondary" id="reloadBtn">Reload</button> .time-row .row>[class*="col"] { width:auto;max-width:none;flex:none;padding:0;min-width:0; }
<button class="btn btn-outline-dark" id="clearFiltersBtn">Clear Filters</button> .time-row .col-12.col-lg-4>div { display:inline; }.time-row .col-12.col-lg-4>div+div:before { content:' · ';color:#94a3b8; }
<button class="btn btn-success" id="sendInvoicesBtn">Opret lokale ordrer</button> .time-row .small { font-size:.75rem;line-height:1.15; }.time-row .fw-semibold,.time-row strong { font-size:.85rem;line-height:1.15; }
</div> .time-row .col-6 .small.text-muted { display:none; }.time-row .col-8 .small.text-muted { display:none; }
</div> .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; }
<div class="card mb-3"> @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}}
<div class="card-body"> @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}}
<div class="d-flex flex-wrap gap-2 mb-3"> .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}
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="pending">Kun pending</button> .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}}
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="billable">Kun billable</button> </style>
<button class="btn btn-sm btn-outline-primary quick-filter-btn" data-filter="ready">Klar til faktura</button> <main class="container-fluid py-4 time-queue">
</div> <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 class="row g-2 align-items-end"> <div id="messageArea" aria-live="polite"></div>
<div class="col-12 col-md-3"> <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>
<label for="filterCustomer" class="form-label">Firma</label> <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>
<select id="filterCustomer" class="form-select"> <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>
<option value="">Alle firmaer med ubehandlede registreringer</option> <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>
</select> <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>
</div> <section id="queueContent" aria-live="polite"></section>
<div class="col-12 col-md-3"> <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>
<label for="filterStatus" class="form-label">Status</label> </main>
<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>
<script> <script>
(() => { (() => {
const state = { const state={items:[],selected:new Set(),preview:null,loading:false,cards:[]}; const $=id=>document.getElementById(id);
items: [], const labels={pending:'Afventer',approved:'Godkendt',rejected:'Afvist',invoice:'Faktura',prepaid:'Klippekort',subscription:'Abonnement / fast pris',internal:'Intern tid',non_billable:'Ikke-fakturerbar tid'};
selected: new Set(), const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'}[c])); const h=v=>Number(v||0).toLocaleString('da-DK',{maximumFractionDigits:2});
loading: false, 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}
const queueBody = document.getElementById('queueBody'); 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}:{})}}
const selectAll = document.getElementById('selectAll'); 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>`}
const selectedCountBadge = document.getElementById('selectedCountBadge'); 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)))}
const filterCustomer = document.getElementById('filterCustomer'); function selectedCardCustomerIds(){return [...new Set(selectedRows().map(x=>Number(x.billing_customer_id||x.hub_customer_id||0)).filter(Boolean))]}
const filterStatus = document.getElementById('filterStatus'); function selectedHours(){return selectedRows().reduce((total,x)=>total+Number(x.approved_hours||x.original_hours||0),0)}
const filterBillable = document.getElementById('filterBillable'); 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)}
const filterQuery = document.getElementById('filterQuery'); 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>.`}
const bulkDescription = document.getElementById('bulkDescription'); 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()}))}
const bulkHours = document.getElementById('bulkHours'); 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}`}
const bulkBillingMethod = document.getElementById('bulkBillingMethod'); 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.') }}
const bulkPrepaidCard = document.getElementById('bulkPrepaidCard'); 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()}}
const quickFilterBtns = document.querySelectorAll('.quick-filter-btn'); 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)}}
function selectedIds() { 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>`}}
return Array.from(state.selected); 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 renderRows() { function selectionChanged(){state.preview=null;$('previewPanel').classList.add('d-none');render();loadCards()}
if (!state.items.length) { $('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();
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, '&lt;').replace(/>/g, '&gt;');
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();
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

@ -8,14 +8,20 @@ Runs daily at 04:00
import logging import logging
from datetime import datetime, date from datetime import datetime, date
import json import json
from typing import Optional, Sequence
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from app.core.database import execute_query, get_db_connection from app.core.database import execute_query, get_db_connection
from app.services.subscription_billing_calendar import (
advance_billing_periods,
billing_date_for_period,
prorated_30_day_factor,
)
logger = logging.getLogger(__name__) 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. Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today - Find active subscriptions where next_invoice_date <= today
@ -26,6 +32,13 @@ async def process_subscriptions():
try: try:
logger.info("💰 Processing subscription invoices...") 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 # Find subscriptions due for invoicing
query = """ query = """
@ -37,8 +50,13 @@ async def process_subscriptions():
c.name AS customer_name, c.name AS customer_name,
s.product_name, s.product_name,
s.billing_interval, s.billing_interval,
s.billing_schedule_type,
s.billing_day,
s.billing_direction, s.billing_direction,
s.advance_months, s.advance_months,
s.billing_lead_months,
s.first_full_period_start,
s.proration_basis,
s.price, s.price,
s.next_invoice_date, s.next_invoice_date,
s.period_start, s.period_start,
@ -67,15 +85,43 @@ async def process_subscriptions():
), ),
'[]'::json '[]'::json
) as line_items ) 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 FROM sag_subscriptions s
LEFT JOIN sag_sager sg ON sg.id = s.sag_id LEFT JOIN sag_sager sg ON sg.id = s.sag_id
LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN customers c ON c.id = s.customer_id
WHERE s.status = 'active' WHERE s.status = 'active'
AND s.next_invoice_date <= CURRENT_DATE 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 ORDER BY s.next_invoice_date, s.id
""" """
subscriptions = execute_query(query) subscriptions = execute_query(query, tuple(params))
if not subscriptions: if not subscriptions:
logger.info("✅ No subscriptions due for invoicing") logger.info("✅ No subscriptions due for invoicing")
@ -146,6 +192,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
coverage_start = None coverage_start = None
coverage_end = 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: for sub in subscriptions:
subscription_id = int(sub['id']) subscription_id = int(sub['id'])
source_subscription_ids.append(subscription_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) line_items = json.loads(line_items)
period_start = sub.get('period_start') or sub.get('next_invoice_date') 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: if coverage_start is None or period_start < coverage_start:
coverage_start = period_start coverage_start = period_start
if coverage_end is None or period_end > coverage_end: if coverage_end is None or period_end > coverage_end:
@ -171,9 +239,53 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
continue continue
product_number = str(item.get('product_id', 'SUB')) 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({ ordre_lines.append({
"product": { "product": {
"productNumber": product_number, "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', '') "description": item.get('description', '')
}, },
"quantity": float(item.get('quantity', 1)), "quantity": float(item.get('quantity', 1)),
@ -182,9 +294,8 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
"discountPercentage": 0, "discountPercentage": 0,
"metadata": { "metadata": {
"subscription_id": subscription_id, "subscription_id": subscription_id,
"asset_id": item.get('asset_id'), "first_invoice_item_id": item.get('id'),
"period_from": str(item.get('period_from') or period_start), "one_time": True,
"period_to": str(item.get('period_to') or period_end),
} }
}) })
@ -237,6 +348,22 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
)) ))
ordre_id = cursor.fetchone()[0] 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( logger.info(
"✅ Created aggregated ordre draft #%s for %s subscription(s)", "✅ Created aggregated ordre draft #%s for %s subscription(s)",
ordre_id, ordre_id,
@ -246,8 +373,19 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
for sub in subscriptions: for sub in subscriptions:
subscription_id = int(sub['id']) subscription_id = int(sub['id'])
current_period_start = sub.get('period_start') or sub.get('next_invoice_date') 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']) first_full_period_start = sub.get('first_full_period_start')
new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval']) 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( cursor.execute(
""" """

View 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")

View File

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

View File

@ -1,6 +1,6 @@
from typing import Optional from typing import Optional
from fastapi import APIRouter, File, Query, Request, UploadFile from fastapi import APIRouter, File, Query, Request, Response, UploadFile
from app.modules.also.backend.service import also_service from app.modules.also.backend.service import also_service
from app.modules.also.models.schemas import ( from app.modules.also.models.schemas import (
@ -34,7 +34,8 @@ def _user_id_from_request(request: Request) -> Optional[int]:
@router.get("/also/config") @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() return also_service.get_config()

View File

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

View File

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

View File

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

View File

@ -83,6 +83,10 @@ def ensure_bottom_bar_messages_schema() -> None:
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE, recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL, message_text TEXT NOT NULL,
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
caller_name VARCHAR(200) NOT NULL DEFAULT '',
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE, requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL read_at TIMESTAMP NULL
@ -92,6 +96,12 @@ def ensure_bottom_bar_messages_schema() -> None:
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically") logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else: else:
columns = set(_table_columns("bottom_bar_messages")) columns = set(_table_columns("bottom_bar_messages"))
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
execute_query("""ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
if "requires_manual_ack" not in columns: if "requires_manual_ack" not in columns:
execute_query( execute_query(
""" """
@ -143,14 +153,21 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.sender_user_id, m.sender_user_id,
m.recipient_user_id, m.recipient_user_id,
m.message_text, m.message_text,
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
m.requires_manual_ack, m.requires_manual_ack,
m.created_at, m.created_at,
m.read_at, receipt.read_at,
receipt.acknowledged_at,
COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name, COALESCE(NULLIF(sender.full_name, ''), sender.username, ('Bruger #' || sender.user_id::text)) AS sender_name,
COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name COALESCE(NULLIF(recipient.full_name, ''), recipient.username, ('Bruger #' || recipient.user_id::text)) AS recipient_name
FROM bottom_bar_messages m FROM bottom_bar_messages m
JOIN users sender ON sender.user_id = m.sender_user_id JOIN users sender ON sender.user_id = m.sender_user_id
LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id LEFT JOIN users recipient ON recipient.user_id = m.recipient_user_id
LEFT JOIN contacts contact ON contact.id = m.contact_id
LEFT JOIN bottom_bar_message_receipts receipt
ON receipt.message_id = m.id
AND receipt.user_id = CASE WHEN m.sender_user_id = %s AND m.recipient_user_id IS NOT NULL THEN m.recipient_user_id ELSE %s END
WHERE m.sender_user_id = %s WHERE m.sender_user_id = %s
OR m.recipient_user_id = %s OR m.recipient_user_id = %s
OR (m.recipient_user_id IS NULL AND EXISTS ( OR (m.recipient_user_id IS NULL AND EXISTS (
@ -162,21 +179,24 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC ORDER BY m.created_at DESC, m.id DESC
LIMIT %s LIMIT %s
""", """,
(int(user_id), int(user_id), int(user_id), safe_limit), (int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit),
) or [] ) or []
unread_row = execute_query_single( unread_row = execute_query_single(
""" """
SELECT COUNT(*) AS count SELECT COUNT(*) AS count
FROM bottom_bar_messages FROM bottom_bar_messages m
WHERE read_at IS NULL LEFT JOIN bottom_bar_message_receipts receipt
AND sender_user_id <> %s ON receipt.message_id = m.id
AND receipt.user_id = %s
WHERE receipt.read_at IS NULL
AND m.sender_user_id <> %s
AND ( AND (
recipient_user_id = %s m.recipient_user_id = %s
OR recipient_user_id IS NULL OR m.recipient_user_id IS NULL
) )
""", """,
(int(user_id), int(user_id)), (int(user_id), int(user_id), int(user_id)),
) or {} ) or {}
unread_count = _safe_count(unread_row) unread_count = _safe_count(unread_row)
@ -193,11 +213,17 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"from": row.get("sender_name") or "Ukendt", "from": row.get("sender_name") or "Ukendt",
"to": recipient_name, "to": recipient_name,
"text": row.get("message_text") or "", "text": row.get("message_text") or "",
"message_kind": row.get("message_kind") or "message",
"contact_id": row.get("contact_id"),
"contact_name": row.get("contact_name"),
"caller_name": row.get("caller_name") or "",
"callback_phone": row.get("callback_phone") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")), "requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None, "created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id), "is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_read": row.get("read_at") is not None,
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id), "is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("read_at") is not None, "is_acknowledged": row.get("acknowledged_at") is not None,
} }
) )
@ -215,31 +241,40 @@ def mark_user_messages_read(user_id: Optional[int], partner_user_id: Optional[in
params: List[Any] = [int(user_id)] params: List[Any] = [int(user_id)]
if partner_user_id is not None and int(partner_user_id) > 0: if partner_user_id is not None and int(partner_user_id) > 0:
where_clause = """ where_clause = """
recipient_user_id = %s (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND read_at IS NULL AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND COALESCE(requires_manual_ack, FALSE) = FALSE AND m.sender_user_id <> %s
AND sender_user_id <> %s
""" """
params.append(int(user_id)) params.append(int(user_id))
where_clause += " AND sender_user_id = %s" where_clause += " AND m.sender_user_id = %s"
params.append(int(partner_user_id)) params.append(int(partner_user_id))
else: else:
where_clause = """ where_clause = """
(recipient_user_id = %s OR recipient_user_id IS NULL) (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
AND read_at IS NULL AND COALESCE(m.requires_manual_ack, FALSE) = FALSE
AND COALESCE(requires_manual_ack, FALSE) = FALSE AND m.sender_user_id <> %s
AND sender_user_id <> %s
""" """
params.append(int(user_id)) params.append(int(user_id))
row = execute_query_single( row = execute_query_single(
f""" f"""
UPDATE bottom_bar_messages WITH eligible AS (
SET read_at = CURRENT_TIMESTAMP SELECT m.id
WHERE {where_clause} FROM bottom_bar_messages m
RETURNING COUNT(*) OVER() AS affected_count LEFT JOIN bottom_bar_message_receipts r
ON r.message_id = m.id AND r.user_id = %s
WHERE {where_clause}
AND r.read_at IS NULL
), updated AS (
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at)
SELECT id, %s, CURRENT_TIMESTAMP FROM eligible
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at)
RETURNING message_id
)
SELECT COUNT(*) AS affected_count FROM updated
""", """,
tuple(params), tuple([int(user_id)] + params + [int(user_id)]),
) or {} ) or {}
return int(row.get("affected_count") or 0) return int(row.get("affected_count") or 0)
@ -252,14 +287,18 @@ def acknowledge_message(user_id: Optional[int], message_id: int) -> bool:
ensure_bottom_bar_messages_schema() ensure_bottom_bar_messages_schema()
row = execute_query_single( row = execute_query_single(
""" """
UPDATE bottom_bar_messages INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at)
SET read_at = CURRENT_TIMESTAMP SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE id = %s FROM bottom_bar_messages m
AND recipient_user_id = %s WHERE m.id = %s
AND read_at IS NULL AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL)
RETURNING id AND m.sender_user_id <> %s
ON CONFLICT (message_id, user_id) DO UPDATE
SET read_at = COALESCE(bottom_bar_message_receipts.read_at, EXCLUDED.read_at),
acknowledged_at = COALESCE(bottom_bar_message_receipts.acknowledged_at, EXCLUDED.acknowledged_at)
RETURNING message_id AS id
""", """,
(int(message_id), int(user_id)), (int(user_id), int(message_id), int(user_id), int(user_id)),
) or {} ) or {}
return bool(row.get("id")) return bool(row.get("id"))
@ -333,19 +372,13 @@ def _can_view_boss_tab(user_id: Optional[int]) -> bool:
group_names = _get_user_group_names(user_id) group_names = _get_user_group_names(user_id)
if not group_names: if not group_names:
# Fail-open for authenticated users if group mapping is missing. return False
return True
leadership_tokens = ( leadership_tokens = (
"admin", "admin",
"manager", "manager",
"leder", "leder",
"chef", "chef",
"teknik",
"technician",
"support",
"drift",
"it",
) )
return any( return any(
any(token in group for token in leadership_tokens) any(token in group for token in leadership_tokens)
@ -957,6 +990,10 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8) unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10) recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10) notes_summary = get_user_notes_summary(user_id, limit=10)
procurement_attention = execute_query_single(
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
WHERE type = 'purchase' AND status = 'draft'"""
) or {"count": 0}
urgent_cases = execute_query( urgent_cases = execute_query(
""" """
@ -1141,6 +1178,10 @@ def build_bottom_bar_state(
"list": unassigned_open_cases.get("items") or [], "list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {}, "filter_meta": unassigned_open_cases.get("filter_meta") or {},
}, },
"procurement": {
"to_order": int(procurement_attention.get("count") or 0),
"route": "/procurement",
},
"timer": { "timer": {
"active_count": 1 if timer.get("active") else 0, "active_count": 1 if timer.get("active") else 0,
"list": timer_list, "list": timer_list,

View File

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

View File

@ -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

View File

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

View File

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

View File

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

View File

@ -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)}

View File

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

View File

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

View File

@ -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-success" onclick="addManualLine()"><i class="bi bi-plus-circle me-1"></i>Tilføj linje</button>
<button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button> <button class="btn btn-primary" onclick="saveOrder()"><i class="bi bi-save me-1"></i>Gem</button>
<button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button> <button class="btn btn-warning" onclick="exportOrder()"><i class="bi bi-cloud-upload me-1"></i>Eksporter til e-conomic</button>
<button class="btn btn-outline-primary" onclick="previewPrices()">Kontrollér nye priser</button>
<a class="btn btn-outline-primary" href="/settings/economic-products">Varekoblinger og eksportfejl</a>
</div> </div>
</div> </div>
@ -95,8 +97,9 @@
<i class="bi bi-shield-exclamation me-1"></i> <i class="bi bi-shield-exclamation me-1"></i>
<strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run. <strong>Safety mode aktiv:</strong> e-conomic eksport er read-only eller dry-run.
</div> </div>
<div id="exportErrorBanner" class="alert alert-danger d-none"></div>
<div 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"> <div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="forceExportToggle"> <input class="form-check-input" type="checkbox" id="forceExportToggle">
<label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label> <label class="form-check-label" for="forceExportToggle">Force export (brug kun ved retry)</label>
@ -115,6 +118,7 @@
<div class="info-item"> <div class="info-item">
<div class="info-label">Kunde ID</div> <div class="info-label">Kunde ID</div>
<input type="number" id="customerId" class="form-control" placeholder="Kunde ID"> <input type="number" id="customerId" class="form-control" placeholder="Kunde ID">
<div id="customerIdentity" class="small text-muted mt-1">-</div>
</div> </div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
@ -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 class="col-md-3"><div class="summary-card"><div class="summary-title">Sidst opdateret</div><div id="updatedAt" class="summary-value">-</div></div></div>
</div> </div>
<div id="exportReceiptCard" class="card mb-3 d-none">
<div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-receipt-cutoff me-1"></i>Eksportkvittering</strong>
<a id="economicOrderLink" class="btn btn-sm btn-outline-primary d-none" target="_blank" rel="noopener">Åbn i e-conomic</a>
</div>
<div class="card-body"><div class="row g-3">
<div class="col-md-3"><div class="small text-muted">Ordrenummer</div><div id="receiptOrderNumber" class="fw-semibold">-</div></div>
<div class="col-md-3"><div class="small text-muted">e-conomic-kunde</div><div id="receiptCustomer">-</div></div>
<div class="col-md-2"><div class="small text-muted">Periode</div><div id="receiptPeriod">-</div></div>
<div class="col-md-2"><div class="small text-muted">Valuta / layout</div><div id="receiptCurrencyLayout">-</div></div>
<div class="col-md-2"><div class="small text-muted">Eksporteret af</div><div id="receiptUser">-</div></div>
<div class="col-md-3"><div class="small text-muted">Eksporteret</div><div id="receiptExportedAt">-</div></div>
<div class="col-md-3"><div class="small text-muted">Nettobeløb</div><div id="receiptNetAmount">-</div></div>
<div class="col-md-6"><div class="small text-muted">Intern eksportreference</div><div id="receiptExportId" class="font-monospace small text-break">-</div></div>
<div id="receiptProductsWrap" class="col-12 d-none"><div class="small text-muted">Oprettede varer</div><div id="receiptProducts">-</div></div>
</div></div>
</div>
<div class="sync-card"> <div class="sync-card">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div> <div>
<h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Sync Lifecycle</h5> <h5 class="mb-1"><i class="bi bi-arrow-repeat me-2"></i>Eksport og status</h5>
<div class="text-muted small">Manuel statusstyring og audit events for denne ordre</div> <div class="text-muted small">Status, e-conomic-numre og historik for ordren</div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button id="economicUpdateBtn" class="btn btn-outline-primary btn-sm d-none" onclick="previewEconomicUpdate()">
<i class="bi bi-pencil-square me-1"></i>Opdatér e-conomic-kladde
</button>
<button id="economicStatusBtn" class="btn btn-outline-secondary btn-sm d-none" onclick="refreshEconomicStatus()">
<i class="bi bi-cloud-arrow-down me-1"></i>Hent status nu
</button>
<button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)"> <button class="btn btn-outline-secondary btn-sm" onclick="loadSyncEvents(0)">
<i class="bi bi-arrow-clockwise me-1"></i>Opdater events <i class="bi bi-arrow-clockwise me-1"></i>Opdater events
</button> </button>
@ -166,6 +194,7 @@
<div class="sync-label">Sync status</div> <div class="sync-label">Sync status</div>
<select id="syncStatusSelect" class="form-select form-select-sm"> <select id="syncStatusSelect" class="form-select form-select-sm">
<option value="pending">pending</option> <option value="pending">pending</option>
<option value="uncertain">uncertain</option>
<option value="exported">exported</option> <option value="exported">exported</option>
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="posted">posted</option> <option value="posted">posted</option>
@ -176,7 +205,7 @@
<div class="sync-label">e-conomic ordre nr.</div> <div class="sync-label">e-conomic ordre nr.</div>
<input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345"> <input id="economicOrderNumber" type="text" class="form-control form-control-sm" placeholder="fx 12345">
</div> </div>
<div class="col-md-3"> <div class="col-md-3" id="invoiceNumberEditWrap">
<div class="sync-label">e-conomic faktura nr.</div> <div class="sync-label">e-conomic faktura nr.</div>
<input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877"> <input id="economicInvoiceNumber" type="text" class="form-control form-control-sm" placeholder="fx 998877">
</div> </div>
@ -200,15 +229,19 @@
<div class="sync-label">Ordrenummer</div> <div class="sync-label">Ordrenummer</div>
<div id="economicOrderNumberView" class="sync-value">-</div> <div id="economicOrderNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3"> <div class="col-md-3" id="invoiceNumberViewWrap">
<div class="sync-label">Fakturanummer</div> <div class="sync-label">Fakturanummer</div>
<div id="economicInvoiceNumberView" class="sync-value">-</div> <div id="economicInvoiceNumberView" class="sync-value">-</div>
</div> </div>
<div class="col-md-3">
<div class="sync-label">Betalingsstatus</div>
<div id="economicPaymentStatusView" class="sync-value">-</div>
</div>
</div> </div>
<div class="row g-2 align-items-end mb-3"> <div class="row g-2 align-items-end mb-3">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label mb-1">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"> <input id="eventTypeFilter" type="text" class="form-control form-control-sm" placeholder="fx export_success">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
@ -292,6 +325,37 @@
</div> </div>
</div> </div>
</div> </div>
<div class="modal fade" id="economicProductPreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Varer oprettes før eksport</h5>
<div class="text-muted small">Kontrollér værdierne. Først efter godkendelse oprettes varerne og ordrekladden.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div class="alert alert-info"><i class="bi bi-info-circle me-1"></i>Der skrives ikke noget til e-conomic, før du vælger <strong>Opret varer og eksportér</strong>.</div>
<div id="economicProductPreviewRows"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" id="confirmEconomicProductsBtn" onclick="confirmEconomicProductCreation()">
<i class="bi bi-box-arrow-up-right me-1"></i>Opret varer og eksportér
</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="economicUpdatePreviewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-scrollable"><div class="modal-content">
<div class="modal-header"><div><h5 class="modal-title">Opdatér e-conomic-ordrekladde</h5><div class="text-muted small">Kontrollér før og efter. Kun den åbne ordrekladde ændres.</div></div><button type="button" class="btn-close" data-bs-dismiss="modal"></button></div>
<div class="modal-body"><div class="alert alert-info">Der skrives først til e-conomic, når du vælger <strong>Godkend og opdatér</strong>.</div><div id="economicUpdateComparison"></div></div>
<div class="modal-footer"><button class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button><button class="btn btn-primary" onclick="confirmEconomicUpdate()">Godkend og opdatér</button></div>
</div></div>
</div>
</div> </div>
{% endblock %} {% endblock %}
@ -304,6 +368,8 @@
let syncEventsOffset = 0; let syncEventsOffset = 0;
let syncEventsTotal = 0; let syncEventsTotal = 0;
let detailToast = null; let detailToast = null;
let pendingEconomicProductPreview = null;
let pendingEconomicUpdateId = null;
function showToast(message, variant = 'dark') { function showToast(message, variant = 'dark') {
const toastEl = document.getElementById('detailToast'); const toastEl = document.getElementById('detailToast');
@ -322,8 +388,8 @@
detailToast.show(); detailToast.show();
} }
function formatCurrency(value) { function formatCurrency(value, currency = 'DKK') {
return new Intl.NumberFormat('da-DK', { style: 'currency', currency: 'DKK' }).format(Number(value || 0)); return new Intl.NumberFormat('da-DK', { style: 'currency', currency: currency || 'DKK' }).format(Number(value || 0));
} }
function formatDate(dateStr) { function formatDate(dateStr) {
@ -354,6 +420,7 @@
if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>'; if (normalized === 'posted') return '<span class="badge bg-info text-dark">posted</span>';
if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>'; if (normalized === 'exported') return '<span class="badge bg-primary">exported</span>';
if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>'; if (normalized === 'failed') return '<span class="badge bg-danger">failed</span>';
if (normalized === 'uncertain') return '<span class="badge bg-warning text-dark">uafklaret</span>';
return '<span class="badge bg-warning text-dark">pending</span>'; return '<span class="badge bg-warning text-dark">pending</span>';
} }
@ -366,6 +433,32 @@
document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at); document.getElementById('lastSyncAt').textContent = formatDate(orderData.last_sync_at);
document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-'; document.getElementById('economicOrderNumberView').textContent = orderData.economic_order_number || '-';
document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-'; document.getElementById('economicInvoiceNumberView').textContent = orderData.economic_invoice_number || '-';
const showInvoice = ['posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()) || Boolean(orderData.economic_invoice_number);
document.getElementById('invoiceNumberEditWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('invoiceNumberViewWrap').classList.toggle('d-none', !showInvoice);
document.getElementById('forceExportWrap').classList.toggle('d-none', ['uncertain', 'exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase()));
const hasReceipt = Boolean(orderData.economic_order_number || orderData.economic_export_id);
document.getElementById('exportReceiptCard').classList.toggle('d-none', !hasReceipt);
if (hasReceipt) {
document.getElementById('receiptOrderNumber').textContent = orderData.economic_order_number || '-';
document.getElementById('receiptCustomer').textContent = orderData.exported_economic_customer_number || orderData.current_economic_customer_number || '-';
const period = orderData.coverage_start && orderData.coverage_end
? `${orderData.coverage_start} ${orderData.coverage_end}`
: (String(orderData.invoice_aggregate_key || '').match(/\d{4}-\d{2}$/) || [])[0] || '-';
document.getElementById('receiptPeriod').textContent = period;
document.getElementById('receiptCurrencyLayout').textContent = `${orderData.exported_currency || 'DKK'} / ${orderData.exported_layout_number || orderData.layout_number || '-'}`;
document.getElementById('receiptUser').textContent = orderData.exported_by_name || '-';
document.getElementById('receiptExportedAt').textContent = formatDate(orderData.last_exported_at);
document.getElementById('receiptNetAmount').textContent = formatCurrency(orderData.exported_net_amount, orderData.exported_currency || 'DKK');
document.getElementById('receiptExportId').textContent = orderData.economic_export_id || orderData.export_idempotency_key || '-';
const createdProducts = Array.isArray(orderData.exported_created_products) ? orderData.exported_created_products : [];
document.getElementById('receiptProductsWrap').classList.toggle('d-none', !createdProducts.length);
document.getElementById('receiptProducts').textContent = createdProducts.join(', ');
const link = document.getElementById('economicOrderLink');
link.classList.toggle('d-none', !orderData.economic_order_url);
if (orderData.economic_order_url) link.href = orderData.economic_order_url;
}
} }
function renderLines() { function renderLines() {
@ -378,9 +471,11 @@
tbody.innerHTML = orderLines.map((line, index) => { tbody.innerHTML = orderLines.map((line, index) => {
const isExportedLine = line.export_status === 'exported'; const isExportedLine = line.export_status === 'exported';
const descriptionField = `<input type="text" class="form-control form-control-sm" value="${escapeHtml(line.description || '')}" const mayEditExportedDraft = isExportedLine && orderData && orderData.sync_status === 'exported' && !orderData.economic_invoice_number;
${isExportedLine ? 'disabled' : ''} const lockLine = isExportedLine && !mayEditExportedDraft;
onchange="orderLines[${index}].description = this.value;">`; 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 exportStatus = line.export_status || '-';
const statusBadge = exportStatus === 'exported' const statusBadge = exportStatus === 'exported'
@ -406,17 +501,17 @@
<td>${descriptionField}</td> <td>${descriptionField}</td>
<td style="min-width:100px;"> <td style="min-width:100px;">
<input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}" <input type="number" min="0.01" step="0.01" class="form-control form-control-sm" value="${Number(line.quantity || 1)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].quantity = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:120px;"> <td style="min-width:120px;">
<input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}" <input type="number" min="0" step="0.01" class="form-control form-control-sm" value="${Number(line.unit_price || 0)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].unit_price = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td style="min-width:110px;"> <td style="min-width:110px;">
<input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}" <input type="number" min="0" max="100" step="0.01" class="form-control form-control-sm" value="${Number(line.discount_percentage || 0)}"
${isExportedLine ? 'disabled' : ''} ${lockLine ? 'disabled' : ''}
onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});"> onchange="orderLines[${index}].discount_percentage = Number(this.value || 0); updateLineAmount(${index});">
</td> </td>
<td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td> <td id="lineAmount-${index}" class="fw-semibold">${formatCurrency(line.amount)}</td>
@ -502,7 +597,7 @@
const product = data.product; const product = data.product;
line.product_id = product.id || line.product_id || null; line.product_id = product.id || line.product_id || null;
line.description = product.name || product.product_name || line.description || ''; line.description = product.name || product.product_name || line.description || '';
if (!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); line.unit_price = Number(product.sales_price || 0);
} }
if (product.ean) { if (product.ean) {
@ -542,7 +637,8 @@
discount_percentage: Number(line.discountPercentage || 0), discount_percentage: Number(line.discountPercentage || 0),
amount: Number(line.totalNetAmount || 0), amount: Number(line.totalNetAmount || 0),
unit: line.unit || 'stk', unit: line.unit || 'stk',
product_id: line.product.productNumber || null, product_id: null,
economic_product_number: line.product.productNumber || null,
selected: line.selected !== false, selected: line.selected !== false,
export_status: line.export_status || null, export_status: line.export_status || null,
}; };
@ -569,13 +665,25 @@
document.getElementById('orderTitle').value = orderData.title || ''; document.getElementById('orderTitle').value = orderData.title || '';
document.getElementById('customerId').value = orderData.customer_id || ''; document.getElementById('customerId').value = orderData.customer_id || '';
document.getElementById('customerIdentity').textContent = orderData.customer_name
? `${orderData.customer_name} · e-conomic ${orderData.current_economic_customer_number || 'ikke koblet'}`
: '-';
document.getElementById('layoutNumber').value = orderData.layout_number || ''; document.getElementById('layoutNumber').value = orderData.layout_number || '';
document.getElementById('orderNotes').value = orderData.notes || ''; document.getElementById('orderNotes').value = orderData.notes || '';
const hasExported = orderData.last_exported_at ? true : false; const hasExported = ['exported', 'posted', 'paid'].includes(String(orderData.sync_status || '').toLowerCase());
document.getElementById('orderStatus').innerHTML = hasExported const isUncertain = String(orderData.sync_status || '').toLowerCase() === 'uncertain';
document.getElementById('orderStatus').innerHTML = hasExported
? '<span class="badge bg-success">Eksporteret</span>' ? '<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('createdAt').textContent = formatDate(orderData.created_at);
document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at); document.getElementById('updatedAt').textContent = formatDate(orderData.updated_at);
@ -666,9 +774,18 @@
} }
body.innerHTML = items.map((event) => { body.innerHTML = items.map((event) => {
const payload = typeof event.event_payload === 'object' const data = typeof event.event_payload === 'object' ? event.event_payload : {};
? JSON.stringify(event.event_payload, null, 2) let payload = String(event.event_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 ` return `
<tr> <tr>
@ -676,7 +793,7 @@
<td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td> <td><span class="badge bg-light text-dark border">${escapeHtml(event.event_type || '-')}</span></td>
<td>${escapeHtml(event.from_status || '-')}</td> <td>${escapeHtml(event.from_status || '-')}</td>
<td>${escapeHtml(event.to_status || '-')}</td> <td>${escapeHtml(event.to_status || '-')}</td>
<td><pre class="event-payload mb-0">${escapeHtml(payload)}</pre></td> <td><div class="event-payload">${escapeHtml(payload)}</div></td>
</tr> </tr>
`; `;
}).join(''); }).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); const customerId = Number(document.getElementById('customerId').value || 0);
if (!customerId) { if (!customerId) {
showToast('Angiv kunde ID før eksport', 'warning'); showToast('Angiv kunde ID før eksport', 'warning');
@ -782,15 +991,18 @@
description: line.description, description: line.description,
quantity: Number(line.quantity || 0), quantity: Number(line.quantity || 0),
unit_price: Number(line.unit_price || 0), unit_price: Number(line.unit_price || 0),
currency: line.currency || 'DKK',
discount_percentage: Number(line.discount_percentage || 0), discount_percentage: Number(line.discount_percentage || 0),
unit: line.unit || 'stk', unit: line.unit || 'stk',
product_id: line.product_id || null, product_id: line.product_id || null,
ean: line.ean || null,
selected: true, selected: true,
})), })),
notes: document.getElementById('orderNotes').value || null, notes: document.getElementById('orderNotes').value || null,
layout_number: Number(document.getElementById('layoutNumber').value || 0) || null, layout_number: Number(document.getElementById('layoutNumber').value || 0) || null,
draft_id: draftId, draft_id: draftId,
force_export: document.getElementById('forceExportToggle').checked, force_export: document.getElementById('forceExportToggle').checked,
create_missing_products: createMissingProducts,
}; };
try { try {
@ -801,6 +1013,10 @@
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) { if (!res.ok) {
if (data.detail && data.detail.code === 'economic_products_missing') {
showEconomicProductPreview(data.detail);
return;
}
throw new Error(data.detail || 'Eksport fejlede'); throw new Error(data.detail || 'Eksport fejlede');
} }

View File

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

View File

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

View 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()

View 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 []}

View File

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

View 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})

File diff suppressed because one or more lines are too long

View 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

View File

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

View File

@ -6,9 +6,10 @@ import re
import hashlib import hashlib
import base64 import base64
import html import html
import math
from pathlib import Path from pathlib import Path
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict from typing import Any, List, Optional, Dict
from uuid import uuid4 from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends 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") raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
"""Mark a case as internet-related without creating duplicate tags."""
existing = execute_query_single(
"""SELECT id FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
LIMIT 1""",
(sag_id,),
)
if not existing:
execute_query(
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
(sag_id,),
fetch=False,
)
def _normalize_case_status(status_value: Optional[str]) -> str: def _normalize_case_status(status_value: Optional[str]) -> str:
allowed_statuses = [] allowed_statuses = []
seen = set() seen = set()
@ -419,7 +437,15 @@ class SagBuzzwordSelectionRequest(BaseModel):
class SagListPreferencesUpdate(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]: 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")) status = _normalize_case_status(data.get("status"))
deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline") deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline")
deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until") 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") assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id")
_validate_user_id(ansvarlig_bruger_id) _validate_user_id(ansvarlig_bruger_id)
_validate_group_id(assigned_group_id) _validate_group_id(assigned_group_id)
case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket" case_type = str(data.get("template_key") or data.get("type", "ticket")).strip().lower() or "ticket"
pipeline = data.get("pipeline") if case_type == "pipeline" else None pipeline = data.get("pipeline")
order_items = data.get("order_items") if case_type == "ordre" else [] 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): if pipeline is not None and not isinstance(pipeline, dict):
raise HTTPException(status_code=400, detail="pipeline skal være et objekt") raise HTTPException(status_code=400, detail="pipeline skal være et objekt")
if not isinstance(order_items, list): if not isinstance(order_items, list):
@ -1025,6 +1070,8 @@ async def create_sag(request: Request, data: dict):
if pipeline_values["amount"] not in (None, ""): if pipeline_values["amount"] not in (None, ""):
try: try:
pipeline_values["amount"] = float(pipeline_values["amount"]) pipeline_values["amount"] = float(pipeline_values["amount"])
if not math.isfinite(pipeline_values["amount"]) or pipeline_values["amount"] < 0:
raise ValueError("Invalid amount")
except (TypeError, ValueError) as exc: except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc raise HTTPException(status_code=400, detail="Pipeline-beløb skal være et tal") from exc
else: else:
@ -1079,6 +1126,8 @@ async def create_sag(request: Request, data: dict):
raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc raise HTTPException(status_code=400, detail="Ordrelinjens talfelter er ugyldige") from exc
if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"): if normalized_items[-1]["status"] not in ("draft", "confirmed", "cancelled"):
raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus") raise HTTPException(status_code=400, detail="Ugyldig ordrelinjestatus")
if any(value is not None and (not math.isfinite(value) or value < 0) for value in (normalized_items[-1][key] for key in ("quantity", "unit_price", "amount"))):
raise HTTPException(status_code=400, detail="Ordrelinjens tal skal være endelige og mindst nul")
cursor.execute( cursor.execute(
""" """
@ -1089,13 +1138,71 @@ async def create_sag(request: Request, data: dict):
RETURNING * RETURNING *
""", """,
(data.get("titel"), data.get("beskrivelse", ""), case_type, status, data.get("customer_id"), ansvarlig_bruger_id, (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"]), data.get("deferred_until_status"), pipeline_values["amount"], pipeline_values["probability"], pipeline_values["stage_id"], pipeline_values["description"]),
) )
result = cursor.fetchone() result = cursor.fetchone()
if not result: if not result:
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
if contact_ids:
cursor.execute("SELECT id FROM contacts WHERE id = ANY(%s)", (contact_ids,))
existing_contact_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_contact_ids = [contact_id for contact_id in contact_ids if contact_id not in existing_contact_ids]
if missing_contact_ids:
raise HTTPException(status_code=400, detail=f"Ugyldig kontakt: {missing_contact_ids[0]}")
for index, contact_id in enumerate(contact_ids):
cursor.execute(
"""INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary)
VALUES (%s, %s, 'Kontakt', %s)""",
(result["id"], contact_id, index == 0),
)
cursor.execute(
"""INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, 'Kontakt')
ON CONFLICT (contact_id, customer_id) DO NOTHING""",
(contact_id, data.get("customer_id")),
)
if internet_connection_ids:
cursor.execute(
"""SELECT id FROM internet_connections_connections
WHERE id = ANY(%s) AND deleted_at IS NULL""",
(internet_connection_ids,),
)
existing_connection_ids = {int(row["id"]) for row in cursor.fetchall()}
missing_connection_ids = [item for item in internet_connection_ids if item not in existing_connection_ids]
if missing_connection_ids:
raise HTTPException(status_code=400, detail=f"Internetforbindelsen findes ikke: {missing_connection_ids[0]}")
for connection_id in internet_connection_ids:
cursor.execute(
"""INSERT INTO sag_internet_connections (sag_id, connection_id, linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(result["id"], connection_id, current_user_id),
)
cursor.execute(
"""INSERT INTO sag_tags (sag_id, tag_navn)
SELECT %s, 'internet forbindelse'
WHERE NOT EXISTS (
SELECT 1 FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
)""",
(result["id"], result["id"]),
)
if telefoni_opkald_id:
cursor.execute(
"""UPDATE telefoni_opkald
SET sag_id = %s,
kontakt_id = COALESCE(%s, kontakt_id)
WHERE id = %s
RETURNING id""",
(result["id"], contact_ids[0] if contact_ids else None, telefoni_opkald_id),
)
if not cursor.fetchone():
raise HTTPException(status_code=400, detail="Det valgte opkald findes ikke")
has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose")
for item in normalized_items: for item in normalized_items:
if has_purchase_columns: if has_purchase_columns:
@ -1112,7 +1219,11 @@ async def create_sag(request: Request, data: dict):
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""", VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
(result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]), (result["id"], item["type"], item["description"], item["quantity"], item["unit"], item["unit_price"], item["amount"], item["currency"], item["status"], item["line_date"], item["external_ref"]),
) )
from app.modules.sag.backend.create_support import attach_create_relations
tag_actions = attach_create_relations(cursor, result["id"], data, current_user_id)
conn.commit() conn.commit()
result = dict(result)
result["tag_actions"] = tag_actions
logger.info("✅ Case created: %s", result["id"]) logger.info("✅ Case created: %s", result["id"])
return dict(result) return dict(result)
except Exception: except Exception:
@ -1126,6 +1237,46 @@ async def create_sag(request: Request, data: dict):
logger.error("❌ Error creating case: %s", e) logger.error("❌ Error creating case: %s", e)
raise HTTPException(status_code=500, detail="Failed to create case") raise HTTPException(status_code=500, detail="Failed to create case")
@router.get("/sag/{sag_id}/internet-connections")
async def list_sag_internet_connections(sag_id: int):
return execute_query(
"""SELECT link.connection_id,link.created_at,ic.name,ic.circuit_number,ic.provider_reference,
ic.address,ic.customer_id
FROM sag_internet_connections link
JOIN internet_connections_connections ic ON ic.id=link.connection_id AND ic.deleted_at IS NULL
WHERE link.sag_id=%s ORDER BY link.created_at DESC""", (sag_id,),
) or []
@router.post("/sag/{sag_id}/internet-connections", dependencies=[Depends(case_edit_access)])
async def link_sag_internet_connection(sag_id: int, request: Request, data: dict):
connection_id = _coerce_optional_int(data.get("connection_id"), "connection_id")
if not connection_id:
raise HTTPException(status_code=400, detail="connection_id er påkrævet")
if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)):
raise HTTPException(status_code=404, detail="Sagen findes ikke")
if not execute_query_single(
"SELECT id FROM internet_connections_connections WHERE id=%s AND deleted_at IS NULL", (connection_id,),
):
raise HTTPException(status_code=404, detail="Internetforbindelsen findes ikke")
execute_query(
"""INSERT INTO sag_internet_connections (sag_id,connection_id,linked_by_user_id)
VALUES (%s,%s,%s) ON CONFLICT DO NOTHING""",
(sag_id, connection_id, _get_user_id_from_request(request)), fetch=False,
)
_ensure_case_internet_connection_tag(sag_id)
return {"linked": True, "sag_id": sag_id, "connection_id": connection_id}
@router.delete("/sag/{sag_id}/internet-connections/{connection_id}", dependencies=[Depends(case_edit_access)])
async def unlink_sag_internet_connection(sag_id: int, connection_id: int):
execute_query(
"DELETE FROM sag_internet_connections WHERE sag_id=%s AND connection_id=%s",
(sag_id, connection_id), fetch=False,
)
return {"unlinked": True}
@router.get("/sag/{sag_id:int}") @router.get("/sag/{sag_id:int}")
async def get_sag(sag_id: int): async def get_sag(sag_id: int):
"""Get a specific case.""" """Get a specific case."""
@ -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): async def get_my_sag_list_preferences(request: Request):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
has_columns = table_has_column("user_sag_list_preferences", "column_order")
select_fields = "type_filters, column_order, hidden_columns" if has_columns else "type_filters"
rows = execute_query( rows = execute_query(
""" f"SELECT {select_fields} FROM user_sag_list_preferences WHERE user_id = %s",
SELECT type_filters
FROM user_sag_list_preferences
WHERE user_id = %s
""",
(user_id,), (user_id,),
) or [] ) or []
if not rows: if not rows:
return {"type_filters": []} return {"type_filters": [], "column_order": list(SAG_LIST_COLUMN_KEYS), "hidden_columns": []}
raw = rows[0].get("type_filters") raw = rows[0].get("type_filters")
parsed = [] parsed = []
@ -1289,20 +1438,43 @@ async def get_my_sag_list_preferences(request: Request):
seen.add(item) seen.add(item)
normalized.append(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: except Exception as e:
if "user_sag_list_preferences" in str(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) logger.error("❌ Could not load sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to load list preferences") raise HTTPException(status_code=500, detail="Failed to load list preferences")
@router.get("/sag/me/quick-filter-context")
async def get_my_sag_quick_filter_context(request: Request):
"""Return the authenticated employee and their groups for list quick filters."""
user_id = _get_user_id_from_request(request)
rows = execute_query(
"SELECT group_id FROM user_groups WHERE user_id = %s ORDER BY group_id",
(user_id,),
) or []
return {"user_id": user_id, "group_ids": [row["group_id"] for row in rows]}
@router.patch("/sag/me/list-preferences") @router.patch("/sag/me/list-preferences")
async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate): async def update_my_sag_list_preferences(request: Request, payload: SagListPreferencesUpdate):
user_id = _get_user_id_from_request(request) user_id = _get_user_id_from_request(request)
try: try:
normalized = [] normalized = []
seen = set() seen = set()
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() item = str(value or "").strip().lower()
if not item or item in seen: if not item or item in seen:
continue continue
@ -1311,18 +1483,31 @@ async def update_my_sag_list_preferences(request: Request, payload: SagListPrefe
seen.add(item) seen.add(item)
normalized.append(item) normalized.append(item)
requested_order = payload.column_order if payload.column_order is not None else existing["column_order"]
column_order = [str(key) for key in requested_order if str(key) in SAG_LIST_COLUMN_KEYS]
column_order.extend(key for key in SAG_LIST_COLUMN_KEYS if key not in column_order)
requested_hidden = payload.hidden_columns if payload.hidden_columns is not None else existing["hidden_columns"]
hidden_columns = list(dict.fromkeys(str(key) for key in requested_hidden if str(key) in SAG_LIST_COLUMN_KEYS))
if not table_has_column("user_sag_list_preferences", "column_order"):
raise HTTPException(status_code=503, detail="Kolonnepræferencer kræver den nyeste databasemigration")
execute_query( execute_query(
""" """
INSERT INTO user_sag_list_preferences (user_id, type_filters, updated_at) INSERT INTO user_sag_list_preferences (user_id, type_filters, column_order, hidden_columns, updated_at)
VALUES (%s, %s::jsonb, NOW()) VALUES (%s, %s::jsonb, %s::jsonb, %s::jsonb, NOW())
ON CONFLICT (user_id) ON CONFLICT (user_id)
DO UPDATE SET DO UPDATE SET
type_filters = EXCLUDED.type_filters, type_filters = EXCLUDED.type_filters,
column_order = EXCLUDED.column_order,
hidden_columns = EXCLUDED.hidden_columns,
updated_at = NOW() updated_at = NOW()
""", """,
(user_id, json.dumps(normalized)), (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: except Exception as e:
logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e) logger.error("❌ Could not update sag list preferences for user %s: %s", user_id, e)
raise HTTPException(status_code=500, detail="Failed to save list preferences") raise HTTPException(status_code=500, detail="Failed to save list preferences")
@ -1536,13 +1721,14 @@ async def delete_todo_step(step_id: int):
raise HTTPException(status_code=500, detail="Failed to delete todo step") raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}", dependencies=[Depends(case_edit_access)]) @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.""" """Update a case."""
try: try:
confirm_close_without_time = updates.pop("confirm_close_without_time", False) is True
# Check if case exists # Check if case exists
check = execute_query( check = execute_query(
""" """
SELECT id, status, template_key, priority, ansvarlig_bruger_id, assigned_group_id SELECT *
FROM sag_sager FROM sag_sager
WHERE id = %s AND deleted_at IS NULL 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: if "status" in updates:
updates["status"] = _normalize_case_status(updates.get("status")) updates["status"] = _normalize_case_status(updates.get("status"))
closing_statuses = {"lukket", "løst", "afsluttet", "closed", "resolved", "done"}
new_status = str(updates.get("status") or "").strip().lower()
case_type = str(previous_row.get("template_key") or "").strip().lower()
is_support_case = case_type in {"", "support", "ticket", "quickcreate", "service"}
if new_status in closing_statuses and previous_status not in closing_statuses and is_support_case:
has_registered_time = execute_query_single(
"SELECT EXISTS(SELECT 1 FROM tmodule_times WHERE sag_id = %s) AS has_time",
(sag_id,),
) or {}
if not bool(has_registered_time.get("has_time")) and not confirm_close_without_time:
raise HTTPException(
status_code=409,
detail={
"code": "close_without_time_confirmation_required",
"message": "Der er ikke registreret tid på supportsagen. Bekræft at den skal lukkes uden tid.",
},
)
if "deadline" in updates: if "deadline" in updates:
updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline")
if "start_date" in updates: if "start_date" in updates:
@ -1674,6 +1877,18 @@ async def update_sag(sag_id: int, updates: dict = Body(...)):
} }
change_messages: List[str] = [] 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: if "status" in updates:
old_value = _norm(previous_row.get("status")) or "-" 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: if old_group != new_group:
change_messages.append(f"👥 Gruppe ændret: {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: for message in change_messages:
execute_query( execute_query(
""" """
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s) VALUES (%s, %s, %s, %s)
""", """,
(sag_id, "System", message, True), (sag_id, changed_by, message, True),
) )
except Exception as comment_log_error: except Exception as comment_log_error:
logger.warning("⚠️ Could not log field-change comments for case %s: %s", sag_id, 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) logger.error("❌ Error listing case contacts: %s", e)
raise HTTPException(status_code=500, detail="Failed to list case contacts") raise HTTPException(status_code=500, detail="Failed to list case contacts")
@router.get("/sag/{sag_id}/reply-recipients")
async def list_case_reply_recipients(sag_id: int, q: str = ""):
"""Email-capable case contacts first, followed by contacts from the case customer."""
rows = execute_query(
"""
WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS (
SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked
FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id
WHERE sk.sag_id=%s AND sk.deleted_at IS NULL
UNION
SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked
FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id
) SELECT DISTINCT ON (id) * FROM candidates
WHERE NULLIF(TRIM(email),'') IS NOT NULL
AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%')
ORDER BY id, linked DESC
""",
(sag_id, sag_id, q.strip(), q.strip()),
) or []
return rows
@router.post("/sag/{sag_id}/contacts") @router.post("/sag/{sag_id}/contacts")
async def add_case_contact(sag_id: int, data: dict): async def add_case_contact(sag_id: int, data: dict):
"""Add a contact to a case.""" """Add a contact to a case."""
@ -3049,6 +3302,44 @@ async def list_sale_items(sag_id: int):
raise HTTPException(status_code=500, detail="Failed to list sale items") raise HTTPException(status_code=500, detail="Failed to list sale items")
@router.get("/procurement/overview")
async def procurement_overview():
"""Operational queue for purchase lines awaiting order, receipt or delivery."""
rows = execute_query(
"""
SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount,
p.status, p.line_date, p.external_ref, p.purchase_purpose,
p.supplier_invoice_id, p.supplier_invoice_line_id,
s.titel AS case_title, c.name AS customer_name,
EXISTS (
SELECT 1 FROM sag_salgsvarer sale
WHERE sale.sag_id = p.sag_id AND sale.type = 'sale'
AND sale.status <> 'cancelled'
) AS has_sales_line,
CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received'
WHEN p.status = 'confirmed' THEN 'ordered'
ELSE 'to_order'
END AS fulfilment_state
FROM sag_salgsvarer p
JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled'
ORDER BY CASE
WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2
WHEN p.status = 'confirmed' THEN 1 ELSE 0 END,
p.line_date NULLS LAST, p.id DESC
"""
) or []
counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0}
for row in rows:
state = row.get("fulfilment_state") or "to_order"
counts[state] = counts.get(state, 0) + 1
if not row.get("has_sales_line"):
counts["missing_sales_order"] += 1
return {"items": rows, "counts": counts}
@router.post("/sag/{sag_id}/sale-items") @router.post("/sag/{sag_id}/sale-items")
async def create_sale_item(sag_id: int, data: dict): async def create_sale_item(sag_id: int, data: dict):
"""Create a sale item for a case.""" """Create a sale item for a case."""
@ -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") 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}") @router.get("/sag/{sag_id}/sale-items/{item_id}")
async def get_sale_item(sag_id: int, item_id: int): async def get_sale_item(sag_id: int, item_id: int):
"""Get a single sale item for a case.""" """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") 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") @router.get("/sag/{sag_id}/timeline")
async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)): async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"""Return a unified timeline for the case with optional child cases.""" """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 = [] 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: for row in comments:
created_at = row.get("created_at") created_at = row.get("created_at")
if not 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"): if _table_exists("sag_reminders"):
reminders_query = f""" reminders_query = f"""
SELECT r.id, r.sag_id, r.title, r.message, r.event_type, 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 s.titel AS source_sag_titel
FROM sag_reminders r FROM sag_reminders r
LEFT JOIN sag_sager s ON s.id = r.sag_id 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 WHERE r.deleted_at IS NULL
AND r.sag_id IN ({placeholders}) AND r.sag_id IN ({placeholders})
ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC 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 [] reminders = execute_query(reminders_query, tuple(case_ids)) or []
for row in reminders: 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: if not start_value:
continue continue
ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value) ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value)
title = str(row.get("title") or "Påmindelse") title = str(row.get("title") or "Påmindelse")
message = str(row.get("message") or "") 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") event_kind = str(row.get("event_type") or "reminder")
events.append({ events.append({
@ -3523,7 +4073,7 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"timestamp": ts, "timestamp": ts,
"sag_id": row.get("sag_id"), "sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"), "sag_titel": row.get("source_sag_titel"),
"forfatter": "Reminder", "forfatter": str(row.get("created_by_name") or "System"),
"title": f"Påmindelse · {title}", "title": f"Påmindelse · {title}",
"description": message, "description": message,
}) })
@ -3532,9 +4082,15 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
time_query = f""" time_query = f"""
SELECT t.id, t.sag_id, t.description, t.entry_type, SELECT t.id, t.sag_id, t.description, t.entry_type,
t.worked_date, t.created_at, t.approved_hours, t.original_hours, 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 s.titel AS source_sag_titel
FROM tmodule_times t FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id 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}) WHERE t.sag_id IN ({placeholders})
ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC
LIMIT 600 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 [] time_entries = execute_query(time_query, tuple(case_ids)) or []
for row in time_entries: 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: if not raw_ts:
continue continue
@ -3552,6 +4108,8 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
hours = row.get("original_hours") hours = row.get("original_hours")
hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid" hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid"
description = str(row.get("description") or "") 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") entry_type = str(row.get("entry_type") or "arbejde")
events.append({ events.append({
@ -3562,11 +4120,70 @@ async def get_sag_timeline(sag_id: int, include_subcases: bool = Query(False)):
"timestamp": ts, "timestamp": ts,
"sag_id": row.get("sag_id"), "sag_id": row.get("sag_id"),
"sag_titel": row.get("source_sag_titel"), "sag_titel": row.get("source_sag_titel"),
"forfatter": "Tid", "forfatter": str(row.get("actor_name") or "System"),
"title": f"Tidsregistrering · {hours_text}", "title": f"Tidsregistrering · {hours_text}",
"description": description, "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: def _sort_key(item: Dict) -> datetime:
raw = str(item.get("timestamp") or "") raw = str(item.get("timestamp") or "")
try: try:

View File

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

View File

@ -13,6 +13,48 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.get("/knowledge", response_class=HTMLResponse)
async def knowledge_index(request: Request):
"""Read-only first release of the case knowledge base."""
return templates.TemplateResponse("modules/sag/templates/knowledge_index.html", {"request": request})
@router.get("/solutions", response_class=HTMLResponse)
async def solutions_management(request: Request):
"""Central management surface for case solutions and their publication flow."""
return templates.TemplateResponse("modules/sag/templates/solutions_management.html", {"request": request})
@router.get("/procurement", response_class=HTMLResponse)
async def procurement_overview_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/procurement_overview.html", {"request": request})
@router.get("/reminder-rules", response_class=HTMLResponse)
async def reminder_rules_page(request: Request):
return templates.TemplateResponse("modules/sag/templates/reminder_rules.html", {"request": request})
@router.get("/knowledge/{article_id:int}", response_class=HTMLResponse)
async def knowledge_detail(request: Request, article_id: int):
article = execute_query(
"""
SELECT ka.*, c.name AS customer_name,
COALESCE(u.full_name, u.username) AS published_by
FROM knowledge_articles ka
LEFT JOIN customers c ON c.id = ka.customer_id
LEFT JOIN users u ON u.user_id = ka.published_by_user_id
WHERE ka.id = %s AND ka.status = 'published'
""",
(article_id,),
)
if not article:
raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke")
return templates.TemplateResponse(
"modules/sag/templates/knowledge_detail.html",
{"request": request, "article": article[0]},
)
def _render_api_print_bridge(api_path: str, page_title: str) -> str: def _render_api_print_bridge(api_path: str, page_title: str) -> str:
safe_api_path = json.dumps(api_path) safe_api_path = json.dumps(api_path)
safe_title = json.dumps(page_title) safe_title = json.dumps(page_title)
@ -203,6 +245,7 @@ async def sager_liste(
assigned_group_id: str = Query(None), assigned_group_id: str = Query(None),
unassigned: bool = Query(False), unassigned: bool = Query(False),
include_deferred: bool = Query(False), include_deferred: bool = Query(False),
quick: str = Query(None),
): ):
"""Display list of all cases.""" """Display list of all cases."""
try: try:
@ -215,6 +258,7 @@ async def sager_liste(
query = """ query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
sk_first.contact_id AS kontakt_id,
CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn, CONCAT(COALESCE(cont.first_name, ''), ' ', COALESCE(cont.last_name, '')) as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
g.name AS assigned_group_name, g.name AS assigned_group_name,
@ -278,7 +322,8 @@ async def sager_liste(
query += ")" query += ")"
query += " AND (s.start_date IS NULL OR s.start_date <= NOW())" query += " AND (s.start_date IS NULL OR s.start_date <= NOW())"
normalized_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() normalized_priority = str(priority or "").strip().lower()
if normalized_status == "all": if normalized_status == "all":
pass pass
@ -289,8 +334,10 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses)) placeholders = ", ".join(["%s"] * len(closed_statuses))
query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})" query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
params.extend(closed_statuses) params.extend(closed_statuses)
if normalized_priority: if normalized_priority == "urgent":
query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s" query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
params.append(normalized_priority) params.append(normalized_priority)
if customer_id_int: if customer_id_int:
query += " AND s.customer_id = %s" query += " AND s.customer_id = %s"
@ -314,6 +361,7 @@ async def sager_liste(
fallback_query = """ fallback_query = """
SELECT s.*, SELECT s.*,
c.name as customer_name, c.name as customer_name,
NULL::integer AS kontakt_id,
'' as kontakt_navn, '' as kontakt_navn,
COALESCE(u.full_name, u.username) AS ansvarlig_navn, COALESCE(u.full_name, u.username) AS ansvarlig_navn,
NULL::text AS assigned_group_name, NULL::text AS assigned_group_name,
@ -355,8 +403,10 @@ async def sager_liste(
placeholders = ", ".join(["%s"] * len(closed_statuses)) placeholders = ", ".join(["%s"] * len(closed_statuses))
fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})" fallback_query += f" AND LOWER(COALESCE(s.status, '')) NOT IN ({placeholders})"
fallback_params.extend(closed_statuses) fallback_params.extend(closed_statuses)
if normalized_priority: if normalized_priority == "urgent":
fallback_query += " AND LOWER(COALESCE(s.priority, 'normal')) = %s" fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')"
elif normalized_priority:
fallback_query += " AND LOWER(COALESCE(s.priority::text, 'normal')) = %s"
fallback_params.append(normalized_priority) fallback_params.append(normalized_priority)
if customer_id_int: if customer_id_int:
fallback_query += " AND s.customer_id = %s" fallback_query += " AND s.customer_id = %s"
@ -435,6 +485,7 @@ async def sager_liste(
"relations_map": relations_map, "relations_map": relations_map,
"child_ids": list(child_ids), "child_ids": list(child_ids),
"statuses": status_options, "statuses": status_options,
"status_options": status_options,
"all_tags": [t['tag_navn'] for t in all_tags], "all_tags": [t['tag_navn'] for t in all_tags],
"current_status": status, "current_status": status,
"current_priority": normalized_priority, "current_priority": normalized_priority,
@ -447,16 +498,19 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": normalized_quick or "all",
"closed_statuses": closed_statuses, "closed_statuses": closed_statuses,
}) })
except Exception: except Exception:
logger.exception("❌ Error displaying case list") logger.exception("❌ Error displaying case list")
fallback_status_options = _fetch_case_status_options()
return templates.TemplateResponse("modules/sag/templates/index.html", { return templates.TemplateResponse("modules/sag/templates/index.html", {
"request": request, "request": request,
"sager": [], "sager": [],
"relations_map": {}, "relations_map": {},
"child_ids": [], "child_ids": [],
"statuses": _fetch_case_status_options(), "statuses": fallback_status_options,
"status_options": fallback_status_options,
"all_tags": [], "all_tags": [],
"current_status": status, "current_status": status,
"current_priority": str(priority or "").strip().lower(), "current_priority": str(priority or "").strip().lower(),
@ -469,6 +523,7 @@ async def sager_liste(
"current_ansvarlig_bruger_id": ansvarlig_bruger_id_int, "current_ansvarlig_bruger_id": ansvarlig_bruger_id_int,
"current_assigned_group_id": assigned_group_id_int, "current_assigned_group_id": assigned_group_id_int,
"current_unassigned": requested_unassigned, "current_unassigned": requested_unassigned,
"current_quick_filter": str(quick or "").strip().lower() or "all",
"closed_statuses": _fetch_closed_case_statuses(), "closed_statuses": _fetch_closed_case_statuses(),
}) })
@ -788,11 +843,31 @@ async def sag_detaljer(request: Request, sag_id: int):
c.phone, c.phone,
c.mobile, c.mobile,
c.title, c.title,
company.customer_name company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
WHERE cc_match.contact_id = c.id
AND cc_match.customer_id IN (
SELECT s_match.customer_id
FROM sag_sager s_match
WHERE s_match.id = sk.sag_id AND s_match.customer_id IS NOT NULL
UNION
SELECT sk_customer.customer_id
FROM sag_kunder sk_customer
WHERE sk_customer.sag_id = sk.sag_id AND sk_customer.deleted_at IS NULL
)
) AS is_external
FROM sag_kontakter sk FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT cu.name AS customer_name SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id WHERE cc.contact_id = c.id
@ -817,7 +892,7 @@ async def sag_detaljer(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s" solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,)) solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None
@ -834,7 +909,13 @@ async def sag_detaljer(request: Request, sag_id: int):
t.ekstern_nummer, t.ekstern_nummer,
t.started_at, t.started_at,
t.ended_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.username,
u.full_name, u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
@ -1143,11 +1224,25 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
c.phone, c.phone,
c.mobile, c.mobile,
c.title, c.title,
company.customer_name company.customer_name,
company.customer_id,
ARRAY(
SELECT cc_all.customer_id
FROM contact_companies cc_all
WHERE cc_all.contact_id = c.id
) AS customer_ids,
NOT EXISTS (
SELECT 1
FROM contact_companies cc_match
JOIN sag_sager s_match ON s_match.id = sk.sag_id
WHERE cc_match.contact_id = c.id
AND s_match.customer_id IS NOT NULL
AND cc_match.customer_id = s_match.customer_id
) AS is_external
FROM sag_kontakter sk FROM sag_kontakter sk
JOIN contacts c ON sk.contact_id = c.id JOIN contacts c ON sk.contact_id = c.id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT cu.name AS customer_name SELECT cu.id AS customer_id, cu.name AS customer_name
FROM contact_companies cc FROM contact_companies cc
JOIN customers cu ON cu.id = cc.customer_id JOIN customers cu ON cu.id = cc.customer_id
WHERE cc.contact_id = c.id WHERE cc.contact_id = c.id
@ -1172,7 +1267,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
comments = execute_query(comments_query, (sag_id,)) comments = execute_query(comments_query, (sag_id,))
# Fetch Solution # Fetch Solution
solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s" solution_query = "SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL"
solution_res = execute_query(solution_query, (sag_id,)) solution_res = execute_query(solution_query, (sag_id,))
solution = solution_res[0] if solution_res else None solution = solution_res[0] if solution_res else None
@ -1189,7 +1284,13 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
t.ekstern_nummer, t.ekstern_nummer,
t.started_at, t.started_at,
t.ended_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.username,
u.full_name, u.full_name,
CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, '')) AS contact_name
@ -1238,7 +1339,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
status_options.append(current_status) status_options.append(current_status)
is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline"))
return templates.TemplateResponse("modules/sag/templates/detail_v3.html", { response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", {
"request": request, "request": request,
"case": sag, "case": sag,
"customer": customer, "customer": customer,
@ -1264,6 +1365,8 @@ async def sag_detaljer_v3(request: Request, sag_id: int):
"assignment_users": _fetch_assignment_users(), "assignment_users": _fetch_assignment_users(),
"assignment_groups": _fetch_assignment_groups(), "assignment_groups": _fetch_assignment_groups(),
}) })
response.headers["Cache-Control"] = "no-store, max-age=0"
return response
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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 %}

View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
async function load(){ controller?.abort(); controller=new AbortController(); const q=input.value.trim(); state.classList.remove('d-none'); state.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Søger…'; results.innerHTML='';
try{const r=await fetch(`/api/v1/knowledge/articles?q=${encodeURIComponent(q)}`,{signal:controller.signal,credentials:'include'});if(!r.ok)throw new Error('Kunne ikke hente vidensartikler');const d=await r.json();count.textContent=`${d.total} artikler`;state.classList.toggle('d-none',d.items.length>0);if(!d.items.length)state.innerHTML='<i class="bi bi-journal-x fs-1 d-block mb-2"></i>Ingen godkendte artikler matcher søgningen.';
results.innerHTML=d.items.map(a=>`<div class="col-xl-4 col-md-6"><article class="kb-card h-100 p-4"><a href="/knowledge/${a.id}"><div class="d-flex justify-content-between gap-2 mb-2"><span class="small text-uppercase text-primary fw-semibold">${a.visibility==='general'?'Generel':a.visibility==='customer'?'Kundespecifik':'Intern'}</span><span class="small text-muted">v${a.version_number}</span></div><h3 class="h5 fw-bold">${esc(a.title)}</h3><p class="text-muted mb-3">${esc(a.summary||'Ingen kort beskrivelse')}</p><div class="d-flex flex-wrap gap-1">${(a.tags||[]).slice(0,5).map(t=>`<span class="kb-tag">${esc(t)}</span>`).join('')}</div><div class="small text-muted mt-3"><i class="bi bi-folder2-open me-1"></i>Sag ${a.sag_id}${a.customer_name?' · '+esc(a.customer_name):''}</div></a></article></div>`).join('');
}catch(e){if(e.name==='AbortError')return;state.classList.remove('d-none');state.innerHTML=`<i class="bi bi-exclamation-triangle text-danger fs-2 d-block"></i>${esc(e.message)}`;count.textContent='Fejl';}}
input.addEventListener('input',()=>{clearTimeout(timer);timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -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 %}

View 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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[char]));
async function responseMessage(response,fallback){const body=await response.json().catch(()=>({}));return body.detail||body.message||fallback}
async function load(){const response=await fetch('/api/v1/reminder-task-rules');if(!response.ok){rulesEl.innerHTML=`<div class="alert alert-danger">${escapeHtml(await responseMessage(response,'Kunne ikke hente regler'))}</div>`;return}const list=await response.json();rulesEl.innerHTML=list.map(rule=>`<article class="rule-entry"><div><div class="fw-bold fs-5">${escapeHtml(rule.title)}</div><div class="mt-2">${(rule.times_json||[]).map(time=>`<span class="rule-time me-1">${escapeHtml(time)}</span>`).join('')} ${rule.include_groups?'<span class="text-muted small ms-1">· Mine grupper er med</span>':''}</div></div><button class="btn btn-outline-primary" onclick="sendRule(${rule.id},this)">Send nu ↗</button></article>`).join('')||'<div class="empty-rules">Ingen regler endnu. Opret den første ovenfor, og test den derefter med <strong>Send nu</strong>.</div>'}
async function saveRule(){const button=document.getElementById('saveButton');const times=document.getElementById('ruleTimes').value.split(',').map(value=>value.trim()).filter(Boolean);if(!times.length)return alert('Vælg mindst ét tidspunkt');button.disabled=true;button.textContent='Gemmer…';try{const response=await fetch('/api/v1/reminder-task-rules',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:document.getElementById('ruleTitle').value,times,include_groups:document.getElementById('ruleGroups').checked,notify_mattermost:document.getElementById('ruleMattermost').checked})});if(!response.ok)throw new Error(await responseMessage(response,'Kunne ikke gemme'));await load()}catch(error){alert(error.message)}finally{button.disabled=false;button.textContent='Gem regel'}}
async function sendRule(id,button){const original=button.innerHTML;button.disabled=true;button.textContent='Sender…';try{const response=await fetch(`/api/v1/reminder-task-rules/${id}/send-now`,{method:'POST'});const result=await response.json().catch(()=>({}));if(!response.ok||!result.sent)throw new Error(result.detail||result.message||'Kunne ikke sende');alert(`Opgavelisten er sendt med ${result.count} sager.`)}catch(error){alert(error.message)}finally{button.disabled=false;button.innerHTML=original}}
load();
</script>
{% endblock %}

View File

@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[c]));
const labels={draft:'Kladde',pending:'Afventer godkendelse',approved:'Godkendt',outdated:'Forældet',rejected:'Afvist'};
async function api(url,options={}){const r=await fetch(url,{credentials:'include',...options});if(!r.ok){const e=await r.json().catch(()=>({}));throw new Error(typeof e.detail==='string'?e.detail:'Handlingen mislykkedes');}return r.json();}
async function load(){const box=document.getElementById('solState'),grid=document.getElementById('solGrid'),q=document.getElementById('solSearch').value.trim();box.classList.remove('d-none');box.innerHTML='<div class="spinner-border spinner-border-sm me-2"></div>Henter…';grid.innerHTML='';
try{const d=await api(`/api/v1/solutions?q=${encodeURIComponent(q)}&approval_status=${encodeURIComponent(state.status)}&include_archived=${state.archived}`);state.items=new Map(d.items.map(x=>[x.id,x]));document.getElementById('solCount').textContent=`${d.total} løsninger`;document.getElementById('solHeading').textContent=state.archived?'Arkiverede løsninger':'Aktive løsninger';box.classList.toggle('d-none',d.items.length>0);if(!d.items.length)box.innerHTML='<i class="bi bi-lightbulb fs-1 d-block mb-2"></i>Ingen løsninger matcher.';
grid.innerHTML=d.items.map(s=>`<div class="col-xxl-4 col-lg-6"><article class="sol-card h-100 p-4"><div class="d-flex justify-content-between gap-2 mb-2"><div class="d-flex gap-1"><span class="sol-chip ${s.approval_status==='approved'?'bg-success-subtle text-success-emphasis':s.approval_status==='pending'?'bg-warning-subtle text-warning-emphasis':'bg-secondary-subtle text-secondary-emphasis'}">${labels[s.approval_status]||esc(s.approval_status)}</span><span class="sol-chip bg-light border">${s.visibility==='general'?'Generel':s.visibility==='customer'?'Kunde':'Intern'}</span></div><span class="small text-muted">#${s.sag_id}</span></div><h3 class="h5 fw-bold">${esc(s.title)}</h3><p class="sol-summary text-muted">${esc(s.problem||s.description||'Ingen beskrivelse')}</p><div class="small text-muted mb-3">${esc(s.customer_name||'Ingen kunde')} · ${esc(s.updated_by||s.created_by||'Ukendt')}</div><div class="d-flex flex-wrap gap-2"><a class="btn btn-sm btn-outline-secondary" href="/sag/${s.sag_id}/v3#solution">Sag</a>${state.archived?`<button class="btn btn-sm btn-success" onclick="restoreManagedSolution(${s.id})"><i class="bi bi-arrow-counterclockwise me-1"></i>Gendan</button>`:`<button class="btn btn-sm btn-outline-primary" onclick="openManagedSolution(${s.id})"><i class="bi bi-pencil me-1"></i>Rediger</button>${s.approval_status==='pending'?`<button class="btn btn-sm btn-success" onclick="solutionAction(${s.sag_id},'approve')">Godkend</button>`:''}${s.approval_status==='approved'?`<button class="btn btn-sm btn-primary" onclick="publishManagedSolution(${s.sag_id})">Udgiv</button>`:''}<button class="btn btn-sm btn-outline-danger ms-auto" onclick="askArchiveSolution(${s.id})" title="Arkivér"><i class="bi bi-archive"></i></button>`}</div></article></div>`).join('');
}catch(e){box.classList.remove('d-none');box.innerHTML=`<i class="bi bi-exclamation-triangle text-danger d-block fs-2"></i>${esc(e.message)}`;}}
window.openManagedSolution=id=>{const s=state.items.get(id);if(!s)return;document.getElementById('editSagId').value=s.sag_id;document.getElementById('editorHeading').textContent=s.title;document.getElementById('editTitle').value=s.title||'';document.getElementById('editType').value=s.solution_type||'Support';document.getElementById('editResult').value=s.result||'Løst';document.getElementById('editVisibility').value=s.visibility||'internal';document.getElementById('editProblem').value=s.problem||'';document.getElementById('editCause').value=s.root_cause||'';document.getElementById('editInvestigation').value=s.investigation||'';document.getElementById('editDescription').value=s.description||'';document.getElementById('editWorkaround').value=s.workaround||'';document.getElementById('editTags').value=(s.tags||[]).join(', ');document.getElementById('editProducts').value=(s.products||[]).join(', ');document.getElementById('editNote').value='';document.getElementById('editorCaseLink').href=`/sag/${s.sag_id}/v3#solution`;bootstrap.Offcanvas.getOrCreateInstance(document.getElementById('solutionEditor')).show();};
window.saveManagedSolution=async()=>{const id=document.getElementById('editSagId').value,payload={title:document.getElementById('editTitle').value.trim(),solution_type:document.getElementById('editType').value,result:document.getElementById('editResult').value,visibility:document.getElementById('editVisibility').value,problem:document.getElementById('editProblem').value.trim(),root_cause:document.getElementById('editCause').value.trim(),investigation:document.getElementById('editInvestigation').value.trim(),description:document.getElementById('editDescription').value.trim(),workaround:document.getElementById('editWorkaround').value.trim(),tags:document.getElementById('editTags').value.split(',').map(x=>x.trim()).filter(Boolean),products:document.getElementById('editProducts').value.split(',').map(x=>x.trim()).filter(Boolean),change_note:document.getElementById('editNote').value.trim()||'Redigeret fra Løsninger'};if(!payload.title||!payload.description){alert('Titel og endelig løsning skal udfyldes');return;}try{await api(`/api/v1/sag/${id}/solution`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});bootstrap.Offcanvas.getInstance(document.getElementById('solutionEditor'))?.hide();load();}catch(e){alert(e.message);}};
window.solutionAction=async(sag,action)=>{try{await api(`/api/v1/sag/${sag}/solution/workflow`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action})});load();}catch(e){alert(e.message);}};
window.publishManagedSolution=async sag=>{try{const a=await api(`/api/v1/sag/${sag}/solution/publish`,{method:'POST'});location.href=`/knowledge/${a.id}`;}catch(e){alert(e.message);}};
window.askArchiveSolution=id=>{state.archiveId=id;document.getElementById('archiveSolutionName').textContent=state.items.get(id)?.title||'';bootstrap.Modal.getOrCreateInstance(document.getElementById('archiveSolutionModal')).show();};
document.getElementById('archiveConfirmBtn').addEventListener('click',async()=>{try{await api(`/api/v1/solutions/${state.archiveId}`,{method:'DELETE'});bootstrap.Modal.getInstance(document.getElementById('archiveSolutionModal'))?.hide();load();}catch(e){alert(e.message);}});
window.restoreManagedSolution=async id=>{try{await api(`/api/v1/solutions/${id}/restore`,{method:'POST'});load();}catch(e){alert(e.message);}};
document.querySelectorAll('.sol-filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.sol-filter').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.status=b.dataset.status;load();}));document.getElementById('archivedToggle').addEventListener('click',e=>{state.archived=!state.archived;e.currentTarget.classList.toggle('btn-danger',state.archived);e.currentTarget.classList.toggle('btn-outline-danger',!state.archived);load();});document.getElementById('solSearch').addEventListener('input',()=>{clearTimeout(state.timer);state.timer=setTimeout(load,280)});load();
})();
</script>
{% endblock %}

View File

@ -145,7 +145,7 @@
<input type="date" class="form-control" id="ordersDateTo"> <input type="date" class="form-control" id="ordersDateTo">
</div> </div>
<div class="col-md-2"> <div class="col-md-2">
<button class="btn btn-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>
<div class="col-md-3 text-end"> <div class="col-md-3 text-end">
<span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span> <span class="chip"><i class="bi bi-info-circle"></i>Alle sager</span>
@ -358,7 +358,26 @@
document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum); document.getElementById('purchaseSubtotal').textContent = formatCurrency(purchaseSum);
} }
let ordersFilterTimer = null;
function resetOrderFilters() {
['ordersSearch', 'ordersStatus', 'ordersCaseId', 'ordersCustomerId', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
const element = document.getElementById(id);
if (element) element.value = '';
});
loadOrders();
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
['ordersStatus', 'ordersDateFrom', 'ordersDateTo'].forEach(id => {
document.getElementById(id)?.addEventListener('change', loadOrders);
});
['ordersSearch', 'ordersCaseId', 'ordersCustomerId'].forEach(id => {
document.getElementById(id)?.addEventListener('input', () => {
window.clearTimeout(ordersFilterTimer);
ordersFilterTimer = window.setTimeout(loadOrders, 300);
});
});
loadOrders(); loadOrders();
}); });
</script> </script>

View File

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

View File

@ -0,0 +1 @@
"""Shipmondo shipping integration."""

View File

@ -0,0 +1 @@
"""Shipmondo backend package."""

View 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

View 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"'},
)

View 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()

View File

@ -0,0 +1 @@
"""Shipmondo API models."""

View 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]]

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