Compare commits

..

No commits in common. "main" and "checkpoint-before-bottom-bar-review-20260817" have entirely different histories.

601 changed files with 4202 additions and 93603 deletions

View File

@ -3,10 +3,6 @@
# ===================================================== # =====================================================
DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub DATABASE_URL=postgresql://bmc_hub:bmc_hub@postgres:5432/bmc_hub
HUB_BASE_URL=https://hub.bmcnetworks.dk HUB_BASE_URL=https://hub.bmcnetworks.dk
# Remote control remains disabled until migrated; uses the existing Hub on local network/VPN.
REMOTE_ENABLED=false
REMOTE_LOOKUP_KEY=
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
@ -14,12 +10,6 @@ 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
# ===================================================== # =====================================================
@ -107,30 +97,6 @@ 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,28 +117,6 @@ 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)
# ===================================================== # =====================================================

2
.gitignore vendored
View File

@ -28,6 +28,4 @@ htmlcov/
.coverage .coverage
.pytest_cache/ .pytest_cache/
.mypy_cache/ .mypy_cache/
# Generated Graphify graphs and caches (keep local only)
graphify-out/
RELEASE_NOTES_v2.2.38.md RELEASE_NOTES_v2.2.38.md

View File

@ -1,64 +0,0 @@
# 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

@ -1,34 +0,0 @@
# 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

@ -1,34 +0,0 @@
# 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

@ -1,21 +0,0 @@
# 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
# BMC Hub v2.8.17
## Sager, tid og vTiger
- Udvider sagsvisningen med sagstræ, samlet historik og relationstræ.
- Forbedrer tidskø, manuel tidsregistrering og revisionsspor for ændrede tidsregistreringer.
- Tilføjer idempotent import af individuelle vTiger-sager til Hub og eksportsporing fra Hub til vTiger.
## Drift og kundeplaner
- Tilføjer den fremtidige plan for **Eskaleringsplan** under kunden.
- Planen fastslår, at den eksisterende Drift-fane, alarmer, integrationer, mappings og sagslinks bevares ved en senere implementering.
## Øvrigt
- Tilføjer opfølgning ved 80 % og 100 % forbrug af klippekort.
- Indeholder Kuando Busylight-integration og forbedringer af sagsoprettelse og planner.
## Database
Kør migrationerne `1043` til `1046` i rækkefølge før eller sammen med opstart af versionen.

View File

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

View File

@ -1,22 +0,0 @@
# BMC Hub v2.8.22
## Arbejdsgange, sager og tid
- Tilføjer den reversible workflow-motor med kontrolleret aktivering, preview og revisionsspor for afregning.
- Udvider sagsarbejdsrummet, tidsregistrering og planlægning med tydeligere kapacitet, opgaver og opfølgning.
- Tilføjer sikker fjernstyring og forbedrede sagskommandoer uden ændring af eksisterende API-kontrakter.
## Kunder, e-mail og drift
- Forbedrer kunde- og kontaktarbejdsgange, e-mailidentitet og routing samt Drift-filtersikkerhed.
- Tilføjer planlagt onboarding/offboarding på kontaktkortet med eksplicit sagsoprettelse og audit-spor.
- Udvider hardware-intake, leverandørdata og økonomiske integrationsvisninger.
## Database
Kør migrationerne `1053` til `1076` i numerisk rækkefølge før eller sammen med opstart af versionen. Migration `1076_contact_lifecycle.sql` opretter kun nye tabeller og indeks.
## Validering
- Python: `986 passed, 103 skipped` med workflow- og remote-feature-flags slået fra i unit-testmiljøet.
- JavaScript: `86 passed`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
2.8.22 2.3.30

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -91,7 +91,7 @@
/* badges */ /* badges */
.badge-link { display:inline-flex; align-items:center; gap:.3rem; font-size:.78rem; padding:.2rem .55rem; border-radius:5px; font-weight:500; white-space:nowrap; } .badge-link { display:inline-flex; align-items:center; gap:.3rem; font-size:.78rem; padding:.2rem .55rem; border-radius:5px; font-weight:500; white-space:nowrap; }
.badge-hw { background:rgba(15,76,117,.12); color:var(--theme-primary-text, #0f4c75); } .badge-hw { background:rgba(15,76,117,.12); color:#0f4c75; }
.badge-cust{ background:rgba(40,167,69,.12); color:#1a6e30; } .badge-cust{ background:rgba(40,167,69,.12); color:#1a6e30; }
.badge-cont{ background:rgba(255,140,0,.13); color:#b36200; } .badge-cont{ background:rgba(255,140,0,.13); color:#b36200; }
.badge-sag { background:rgba(111,66,193,.13); color:#4b1d8c; } .badge-sag { background:rgba(111,66,193,.13); color:#4b1d8c; }
@ -477,7 +477,6 @@
<select id="reg-customer" onchange="onCustomerChange()"> <select id="reg-customer" onchange="onCustomerChange()">
<option value="">– vælg kunde –</option> <option value="">– vælg kunde –</option>
</select> </select>
<div class="small text-muted mt-1">AnyDesk-ID gemmes på kontaktpersonens hardware. Findes maskinen ikke, oprettes en PC under firmaet. Koblingen bruges også på tidligere uregistrerede sessioner. Sag og noter gælder kun denne session.</div>
</div> </div>
<!-- Kontakt --> <!-- Kontakt -->
@ -719,8 +718,8 @@ async function loadHardware() {
async function loadCustomers() { async function loadCustomers() {
try { try {
const data = await api('/api/v1/customers/?limit=500&is_active=true'); const data = await api('/api/v1/customers/?limit=500');
customerList = (data.customers || data || []).filter(customer => customer.is_active === true); customerList = data.customers || data || [];
} catch(e) { /* silent */ } } catch(e) { /* silent */ }
} }
@ -1037,8 +1036,8 @@ async function loadContactsForCustomer(customerId, selectedId) {
sel.innerHTML = '<option value="">– ingen / vælg –</option>'; sel.innerHTML = '<option value="">– ingen / vælg –</option>';
try { try {
const url = customerId const url = customerId
? `/api/v1/contacts/?customer_id=${customerId}&limit=100&is_active=true` ? `/api/v1/contacts/?customer_id=${customerId}&limit=300`
: '/api/v1/contacts/?limit=100&is_active=true'; : '/api/v1/contacts/?limit=300';
const data = await api(url); const data = await api(url);
currentContactList = data.contacts || data || []; currentContactList = data.contacts || data || [];
filterContactOptions(); filterContactOptions();
@ -1139,17 +1138,13 @@ async function saveRegModal() {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (hw) params.append('hardware_asset_id', hw); if (hw) params.append('hardware_asset_id', hw);
if (cust) params.append('customer_id', cust); if (cust) params.append('customer_id', cust);
if (cust) params.append('remember_machine_link', 'true');
if (contact) params.append('contact_id', contact); if (contact) params.append('contact_id', contact);
if (sag) params.append('sag_id', sag); if (sag) params.append('sag_id', sag);
if (notes) params.append('notes', notes); if (notes) params.append('notes', notes);
try { try {
const result = await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' }); await api(`/api/v1/anydesk/sessions/${id}/link?${params}`, { method: 'PATCH' });
toast(result.remembered toast('✅ Session registreret', 'success');
? `✅ AnyDesk-ID gemt på hardware · ${result.linked_sessions || 0} andre sessioner opdateret`
: '✅ Session registreret', 'success');
if (result.remembered) await loadHardware();
document.getElementById('drawer-backdrop').classList.remove('open'); document.getElementById('drawer-backdrop').classList.remove('open');
document.getElementById('session-drawer').classList.remove('open'); document.getElementById('session-drawer').classList.remove('open');
await loadSessions(); await loadSessions();

View File

@ -221,7 +221,7 @@ class UserProfileUpdate(BaseModel):
def _allowed_case_types() -> list[str]: def _allowed_case_types() -> list[str]:
fallback = ["ticket", "salg", "bogholderi", "admin", "projekt", "postit", "intern_ticket", "bug_report"] fallback = ["ticket", "pipeline", "opgave", "ordre", "projekt", "service"]
try: try:
rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or [] rows = execute_query("SELECT value FROM settings WHERE key = %s", ("case_types",)) or []
if rows: if rows:
@ -229,7 +229,7 @@ def _allowed_case_types() -> list[str]:
configured = json.loads(rows[0].get("value") or "[]") configured = json.loads(rows[0].get("value") or "[]")
values = [str(value).strip().lower() for value in configured if str(value).strip()] values = [str(value).strip().lower() for value in configured if str(value).strip()]
if values: if values:
return list(dict.fromkeys(values)) return list(dict.fromkeys(values + (["pipeline"] if "pipeline" not in values else [])))
except Exception: except Exception:
logger.warning("Could not load configured case types for profile preference", exc_info=True) logger.warning("Could not load configured case types for profile preference", exc_info=True)
return fallback return fallback

View File

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

View File

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

View File

@ -142,7 +142,7 @@
border: 1px solid rgba(0,0,0,0.08); border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px; border-radius: 10px;
padding: 0.7rem 0.85rem; padding: 0.7rem 0.85rem;
background: var(--theme-surface, #fff); background: #fff;
} }
</style> </style>
{% endblock %} {% endblock %}
@ -2916,9 +2916,7 @@ async function reviewExtractedData(fileId) {
let aiData = null; let aiData = null;
if (ext.llm_response_json) { if (ext.llm_response_json) {
try { try {
aiData = typeof ext.llm_response_json === 'string' aiData = JSON.parse(ext.llm_response_json);
? JSON.parse(ext.llm_response_json)
: ext.llm_response_json;
} catch (e) { } catch (e) {
console.error('Failed to parse llm_response_json:', e); console.error('Failed to parse llm_response_json:', e);
} }
@ -2931,14 +2929,8 @@ async function reviewExtractedData(fileId) {
.trim(); .trim();
// Check for validation warnings // Check for validation warnings
const hybridValidation = aiData?._hybrid_validation || null; const hasValidationIssues = aiData?._validation_warning || aiData?._vat_warning;
const hybridWarnings = hybridValidation?.warnings || []; const allValidationsPassed = !hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0;
const hasValidationIssues = hybridValidation?.requires_review || aiData?._validation_warning || aiData?._vat_warning;
const allValidationsPassed = hybridValidation?.status === 'validated' || (!hasValidationIssues && aiData && aiData.lines && aiData.lines.length > 0);
const sourceLabels = {
layout_text: 'PDF-layout',
ai_or_template: 'AI/template'
};
// Build modal content // Build modal content
let modalContent = ` let modalContent = `
@ -2954,15 +2946,14 @@ async function reviewExtractedData(fileId) {
${hasValidationIssues ? ` ${hasValidationIssues ? `
<div class="alert alert-warning"> <div class="alert alert-warning">
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Kræver manuel kontrol</h6> <h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Beløbs-validering</h6>
${hybridWarnings.map(warning => `<p class="mb-1">⚠️ ${escapeHtml(warning)}</p>`).join('')} ${aiData._validation_warning ? `<p class="mb-1">⚠️ ${aiData._validation_warning}</p>` : ''}
${!hybridWarnings.length && aiData._validation_warning ? `<p class="mb-1">⚠️ ${escapeHtml(aiData._validation_warning)}</p>` : ''}
${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''} ${aiData._vat_warning ? `<p class="mb-0">⚠️ ${aiData._vat_warning}</p>` : ''}
</div> </div>
` : allValidationsPassed ? ` ` : allValidationsPassed ? `
<div class="alert alert-success"> <div class="alert alert-success">
<h6 class="alert-heading"><i class="bi bi-shield-check me-2"></i>Automatisk kontrol bestået</h6> <h6 class="alert-heading"><i class="bi bi-check-circle me-2"></i>Beløbs-validering</h6>
<p class="mb-0">${hybridValidation ? `${hybridValidation.line_count} varelinjer kontrolleret · Kilde: ${sourceLabels[hybridValidation.line_source] || hybridValidation.line_source}` : 'Varelinjer og beløb er valideret'}</p> <p class="mb-0">✅ Varelinjer summer korrekt til subtotal<br>✅ Moms beregning er korrekt (25%)</p>
</div> </div>
` : ''} ` : ''}
@ -5426,7 +5417,7 @@ function showLoadingOverlay(message = 'Behandler...') {
z-index: 9999; z-index: 9999;
`; `;
overlay.innerHTML = ` overlay.innerHTML = `
<div style="background: var(--theme-surface, white); padding: 2rem; border-radius: 12px; text-align: center;"> <div style="background: white; padding: 2rem; border-radius: 12px; text-align: center;">
<div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;"> <div class="spinner-border text-primary mb-3" role="status" style="width: 3rem; height: 3rem;">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>

View File

@ -58,7 +58,7 @@
.status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); } .status-pending { background: rgba(192, 86, 33, 0.14); color: var(--sync-warn); }
.status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); } .status-exported { background: rgba(15, 76, 117, 0.14); color: var(--sync-accent); }
.status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); } .status-failed { background: rgba(197, 48, 48, 0.14); color: var(--sync-danger); }
.status-posted { background: rgba(22, 101, 52, 0.14); color: var(--theme-success-text, #166534); } .status-posted { background: rgba(22, 101, 52, 0.14); color: #166534; }
.status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); } .status-paid { background: rgba(47, 133, 90, 0.14); color: var(--sync-ok); }
.table thead th { .table thead th {

View File

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

View File

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

View File

@ -1,213 +0,0 @@
"""Additive, customer-scoped contact onboarding and offboarding API."""
from datetime import date
from typing import Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from app.core.auth_dependencies import require_any_permission
from app.core.case_visibility import hidden_case_ids, require_case
from app.core.database import atomic_transaction, execute_query, execute_query_single
router = APIRouter()
read_access = require_any_permission('cases.view', 'tickets.view')
edit_access = require_any_permission('cases.edit', 'tickets.edit')
EventType = Literal['onboarding', 'offboarding']
class LifecycleCreate(BaseModel):
customer_id: int = Field(gt=0)
event_type: EventType
event_date: date
lead_days: int = Field(default=14, ge=0, le=3650)
auto_create: bool = False
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
class LifecycleUpdate(BaseModel):
customer_id: Optional[int] = Field(default=None, gt=0)
event_date: Optional[date] = None
lead_days: Optional[int] = Field(default=None, ge=0, le=3650)
auto_create: Optional[bool] = None
template_id: Optional[int] = Field(default=None, gt=0)
responsible_user_id: Optional[int] = Field(default=None, gt=0)
parent_case_id: Optional[int] = Field(default=None, gt=0)
def _ready():
if not (execute_query_single("SELECT to_regclass('public.contact_lifecycle_events') AS relation") or {}).get('relation'):
raise HTTPException(503, detail='Onboarding/offboarding afventer databasemigration 1076')
def _contact(contact_id):
row = execute_query_single('SELECT id FROM contacts WHERE id=%s', (contact_id,))
if not row:
raise HTTPException(404, detail='Kontaktpersonen findes ikke')
def _membership(contact_id, customer_id):
if not execute_query_single('SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s',
(contact_id, customer_id)):
raise HTTPException(400, detail='Kunden er ikke tilknyttet kontaktpersonen')
def _has(user, *permissions):
return bool(user.get('is_superadmin') or set(permissions).intersection(user.get('permissions') or []))
def _validate(row, user):
_membership(row['contact_id'], row['customer_id'])
if row['auto_create'] and not _has(user, 'cases.create', 'tickets.create'):
raise HTTPException(403, detail='Du mangler rettighed til at oprette sager')
if row.get('responsible_user_id') and not execute_query_single(
'SELECT user_id FROM users WHERE user_id=%s AND is_active=TRUE', (row['responsible_user_id'],)
):
raise HTTPException(400, detail='Den ansvarlige bruger er ikke aktiv')
if row.get('template_id'):
if not _has(user, 'templates.run'):
raise HTTPException(403, detail='Du mangler rettighed til at anvende skabeloner')
if not execute_query_single('''SELECT id FROM task_templates WHERE id=%s
AND is_active=TRUE AND deleted_at IS NULL AND category=%s
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=%s))''',
(row['template_id'], row['event_type'], row['customer_id'])):
raise HTTPException(400, detail='Skabelonen er ikke tilgængelig for kunden og forløbstypen')
from app.contacts.backend.lifecycle_service import find_parent_candidates
candidates = find_parent_candidates(row['contact_id'], row['customer_id'], row['event_type'])
hidden = hidden_case_ids(user)
candidates = [c for c in candidates if c['id'] not in hidden]
# A generated case must never become its own parent during later edits.
candidates = [c for c in candidates if c['id'] != row.get('case_id')]
if row.get('parent_case_id') and row['parent_case_id'] not in [c['id'] for c in candidates]:
# Parent can have been closed after creation: retaining it is valid; changing it is not.
if not row.get('case_id'):
raise HTTPException(400, detail='Hovedsagen skal være en åben sag for samme kontakt, kunde og type')
return candidates
def _serialize(row):
from app.contacts.backend.lifecycle_service import scheduled_at
result = dict(row)
result['scheduled_at'] = scheduled_at(row['event_date'], row['lead_days']).isoformat()
return result
def _find_event(contact_id, event_id, lock=False):
row = execute_query_single('SELECT * FROM contact_lifecycle_events WHERE contact_id=%s AND id=%s' +
(' FOR UPDATE' if lock else ''), (contact_id, event_id))
if not row:
raise HTTPException(404, detail='Forløbet findes ikke')
return row
@router.get('/contacts/{contact_id}/lifecycle')
def list_lifecycle(contact_id: int, current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
companies = execute_query('''SELECT c.id,c.name FROM customers c JOIN contact_companies cc
ON cc.customer_id=c.id WHERE cc.contact_id=%s ORDER BY c.name''', (contact_id,)) or []
templates = []
if _has(current_user, 'templates.view', 'templates.run'):
templates = execute_query('''SELECT id,name,category,customer_id,template_type FROM task_templates
WHERE deleted_at IS NULL AND is_active=TRUE AND category IN ('onboarding','offboarding')
AND (template_type IN ('global','internal') OR (template_type='company' AND customer_id=ANY(%s)))
ORDER BY name''', ([c['id'] for c in companies],)) or []
hidden = hidden_case_ids(current_user)
items = []
for row in execute_query(
'SELECT * FROM contact_lifecycle_events WHERE contact_id=%s ORDER BY event_date DESC,id DESC',
(contact_id,)) or []:
item = _serialize(row)
for key in ('case_id', 'parent_case_id'):
if item.get(key) in hidden:
item[key] = None
items.append(item)
return {'items': items, 'companies': companies, 'templates': templates,
'can_edit': _has(current_user, 'cases.edit', 'tickets.edit'),
'users': execute_query('''SELECT user_id AS id,COALESCE(NULLIF(full_name,''),username) AS name
FROM users WHERE is_active=TRUE ORDER BY name''') or []}
@router.get('/contacts/{contact_id}/lifecycle/preview')
def preview_lifecycle(contact_id: int, customer_id: int = Query(gt=0),
event_type: EventType = Query(), event_date: date = Query(),
lead_days: int = Query(default=14, ge=0, le=3650),
current_user: dict = Depends(read_access)):
_ready()
_contact(contact_id)
_membership(contact_id, customer_id)
from app.contacts.backend.lifecycle_service import find_parent_candidates, scheduled_at
candidates = find_parent_candidates(contact_id, customer_id, event_type)
hidden = hidden_case_ids(current_user)
candidates = [c for c in candidates if c['id'] not in hidden]
return {'scheduled_at': scheduled_at(event_date, lead_days).isoformat(), 'candidates': candidates,
'parent_case_id': candidates[0]['id'] if len(candidates) == 1 else None,
'needs_parent': len(candidates) > 1}
@router.post('/contacts/{contact_id}/lifecycle', status_code=201)
def create_lifecycle(contact_id: int, payload: LifecycleCreate, current_user: dict = Depends(edit_access)):
_ready()
_contact(contact_id)
row = dict(payload.model_dump(), contact_id=contact_id)
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row['parent_case_id'] else 'scheduled')
columns = list(row)
result = execute_query_single(f'''INSERT INTO contact_lifecycle_events
({','.join(columns)},status,created_by) VALUES ({','.join(['%s'] * len(columns))},%s,%s) RETURNING *''',
tuple(row.values()) + (status, current_user['id']))
return _serialize(result)
@router.patch('/contacts/{contact_id}/lifecycle/{event_id}')
def update_lifecycle(contact_id: int, event_id: int, payload: LifecycleUpdate,
current_user: dict = Depends(edit_access)):
_ready()
updates = payload.model_dump(exclude_unset=True)
for required in ('customer_id', 'event_date', 'lead_days', 'auto_create'):
if required in updates and updates[required] is None:
raise HTTPException(422, detail=f'{required} må ikke være tom')
with atomic_transaction():
previous = _find_event(contact_id, event_id, lock=True)
if previous.get('case_id'):
require_case(current_user, previous['case_id'])
if previous['status'] == 'cancelled':
raise HTTPException(409, detail='Et annulleret forløb kan ikke ændres; opret et nyt')
row = dict(previous, **updates)
if row.get('case_id') and any(row.get(k) != previous.get(k) for k in
('customer_id', 'template_id', 'parent_case_id', 'responsible_user_id')):
raise HTTPException(409, detail='Kunde, skabelon, hovedsag og ansvarlig ændres på den oprettede sag')
status = previous['status']
last_error = previous.get('last_error')
if row.get('case_id'):
if row['event_date'] != previous['event_date']:
from app.contacts.backend.lifecycle_service import update_created_case_date
result = update_created_case_date(row, previous['event_date'], current_user['id'])
status, last_error = result['status'], result.get('last_error')
else:
candidates = _validate(row, current_user)
status = 'draft' if not row['auto_create'] else (
'needs_parent' if len(candidates) > 1 and not row.get('parent_case_id') else 'scheduled')
last_error = None
if row['auto_create']:
# The last user authorizing automation is the actor whose rights
# are checked by the worker, including when a disabled user is replaced.
updates['created_by'] = current_user['id']
updates.update(status=status, last_error=last_error)
columns = list(updates)
result = execute_query_single(f'''UPDATE contact_lifecycle_events SET
{','.join(c + '=%s' for c in columns)},updated_at=NOW() WHERE id=%s RETURNING *''',
tuple(updates.values()) + (event_id,))
return _serialize(result)
@router.post('/contacts/{contact_id}/lifecycle/{event_id}/cancel')
def cancel_lifecycle(contact_id: int, event_id: int, current_user: dict = Depends(edit_access)):
_ready()
with atomic_transaction():
_find_event(contact_id, event_id, lock=True)
row = execute_query_single('''UPDATE contact_lifecycle_events SET status='cancelled',auto_create=FALSE,
last_error=NULL,updated_at=NOW() WHERE id=%s RETURNING *''', (event_id,))
return _serialize(row)

View File

@ -1,199 +0,0 @@
"""Transactional contact lifecycle scheduling using Hub's existing case contracts."""
import html
import json
import logging
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo
from fastapi import HTTPException
from app.core.database import atomic_transaction, execute_query, execute_query_single
logger = logging.getLogger(__name__)
TZ = ZoneInfo("Europe/Copenhagen")
LABELS = {"onboarding": "Onboarding", "offboarding": "Offboarding"}
def scheduled_at(event_date, lead_days=14):
if isinstance(event_date, str):
event_date = date.fromisoformat(event_date)
return datetime.combine(event_date - timedelta(days=lead_days), time(8), TZ)
def _deadline(event_date):
"""Existing case timestamps store UTC without timezone information."""
if isinstance(event_date, str):
event_date = date.fromisoformat(event_date)
return datetime.combine(event_date, time(17), TZ).astimezone(timezone.utc).replace(tzinfo=None)
def closed_statuses():
values = {"lukket", "løst", "afsluttet", "closed", "resolved", "done", "annulleret", "cancelled", "canceled"}
row = execute_query_single("SELECT value FROM settings WHERE key='case_statuses'")
if row and row.get("value"):
try:
options = row["value"] if isinstance(row["value"], list) else json.loads(row["value"])
values.update(str(item["value"]).strip().lower() for item in options
if isinstance(item, dict) and item.get("is_closed") and item.get("value"))
except (TypeError, ValueError):
logger.warning("Ignoring invalid case_statuses configuration")
return sorted(values)
def find_parent_candidates(contact_id, customer_id, event_type):
"""Only explicit type/tag classification and direct person linkage qualify."""
return execute_query("""SELECT s.id,s.titel,s.status,s.customer_id
FROM sag_sager s
WHERE s.customer_id=%s AND s.deleted_at IS NULL
AND LOWER(TRIM(s.status)) <> ALL(%s)
AND EXISTS(SELECT 1 FROM sag_kontakter k WHERE k.sag_id=s.id
AND k.contact_id=%s AND k.deleted_at IS NULL)
AND (LOWER(s.type)=%s OR LOWER(s.template_key)=%s
OR EXISTS(SELECT 1 FROM sag_tags t WHERE t.sag_id=s.id
AND t.deleted_at IS NULL AND LOWER(TRIM(t.tag_navn))=%s))
AND NOT EXISTS(SELECT 1 FROM sag_relationer r WHERE r."målsag_id"=s.id
AND LOWER(r.relationstype)='undersag' AND r.deleted_at IS NULL)
ORDER BY s.id DESC""",
(customer_id, closed_statuses(), contact_id, event_type, event_type, event_type)) or []
def update_created_case_date(lifecycle, previous_event_date, actor_id):
"""Called inside the API transaction; never changes the lifecycle row itself."""
if not lifecycle.get("case_id") or lifecycle["event_date"] == previous_event_date:
return {"status": lifecycle.get("status", "created"), "last_error": lifecycle.get("last_error")}
from app.core.case_visibility import require_case
from app.core.auth_service import AuthService
actor = {"id": actor_id, "permissions": AuthService.get_user_permissions(actor_id)}
require_case(actor, lifecycle["case_id"])
case = execute_query_single("SELECT id,status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE",
(lifecycle["case_id"],))
if not case or case.get("deleted_at") or str(case["status"]).strip().lower() in closed_statuses():
return {"status": "manual_review", "last_error": "Datoen er ændret. Sagen er afsluttet eller fjernet; kontrollér deadline manuelt."}
execute_query("UPDATE sag_sager SET deadline=%s,updated_at=NOW() WHERE id=%s",
(_deadline(lifecycle["event_date"]), case["id"]))
_comment(case["id"], f"{LABELS[lifecycle['event_type']]}dato ændret fra {previous_event_date} til {lifecycle['event_date']}. Deadline opdateret.")
return {"status": "created", "last_error": None}
def _comment(case_id, message):
execute_query("""INSERT INTO sag_kommentarer
(sag_id,forfatter,indhold,er_intern,er_system_besked)
VALUES(%s,'BMC Hub',%s,TRUE,TRUE)""", (case_id, html.escape(message)))
def _actor(event):
from app.core.auth_service import AuthService
user = execute_query_single("SELECT user_id,is_active,is_superadmin,full_name FROM users WHERE user_id=%s",
(event["created_by"],))
if not user or not user["is_active"]:
raise HTTPException(403, "Forløbets opretter er ikke længere aktiv. Gem forløbet med en aktiv bruger.")
user["id"] = user["user_id"]
user["permissions"] = AuthService.get_user_permissions(user["id"])
if not user["is_superadmin"]:
permissions = set(user["permissions"])
if not permissions.intersection({"cases.create", "tickets.create"}):
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette sager.")
if event.get("template_id") and "templates.run" not in permissions:
raise HTTPException(403, "Forløbets opretter mangler rettighed til at bruge skabelonen.")
return user
async def _create_case(event):
from app.core.case_visibility import require_case
actor = _actor(event)
if not execute_query_single("SELECT id FROM contact_companies WHERE contact_id=%s AND customer_id=%s",
(event["contact_id"], event["customer_id"])):
raise HTTPException(409, "Kontakten er ikke længere tilknyttet kunden. Vælg kunde igen.")
candidates = find_parent_candidates(event["contact_id"], event["customer_id"], event["event_type"])
parent_id = event.get("parent_case_id")
if parent_id and parent_id not in {row["id"] for row in candidates}:
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
updated_at=NOW() WHERE id=%s""",
("Den valgte hovedsag kan ikke længere bruges. Vælg hovedsag igen.", event["id"]))
return False
if not parent_id and len(candidates) > 1:
execute_query("""UPDATE contact_lifecycle_events SET status='needs_parent',last_error=%s,
updated_at=NOW() WHERE id=%s""",
("Flere åbne sager matcher. Vælg hovedsag på kontaktkortet.", event["id"]))
return False
parent_id = parent_id or (candidates[0]["id"] if candidates else None)
if parent_id:
require_case(actor, parent_id)
if not actor["is_superadmin"] and not set(actor["permissions"]).intersection({"cases.edit", "tickets.edit"}):
raise HTTPException(403, "Forløbets opretter mangler rettighed til at oprette undersager.")
# Lock and recheck before adding a relation; a concurrently closed parent cannot be used.
parent = execute_query_single("SELECT status,deleted_at FROM sag_sager WHERE id=%s FOR UPDATE", (parent_id,))
if not parent or parent.get("deleted_at") or str(parent["status"]).strip().lower() in closed_statuses():
raise HTTPException(409, "Hovedsagen er afsluttet. Vælg hovedsag igen.")
contact = execute_query_single("SELECT first_name,last_name FROM contacts WHERE id=%s", (event["contact_id"],))
if not contact:
raise HTTPException(409, "Kontakten findes ikke længere.")
name = " ".join(filter(None, (contact.get("first_name"), contact.get("last_name"))))
label = LABELS[event["event_type"]]
responsible = event.get("responsible_user_id")
if responsible and not execute_query_single("SELECT user_id FROM users WHERE user_id=%s AND is_active", (responsible,)):
raise HTTPException(409, "Den ansvarlige bruger er ikke længere aktiv. Vælg ansvarlig igen.")
case = execute_query_single("""INSERT INTO sag_sager
(titel,beskrivelse,template_key,type,status,customer_id,ansvarlig_bruger_id,created_by_user_id,deadline)
VALUES(%s,%s,'ticket','ticket','Ny',%s,%s,%s,%s) RETURNING id""",
(f"{label}: {name}"[:255], f"{label} for {name}. Dato: {event['event_date']}.\nOprettet automatisk fra kontaktkortet.",
event["customer_id"], responsible, actor["id"], _deadline(event["event_date"])))
case_id = case["id"]
execute_query("INSERT INTO sag_kontakter(sag_id,contact_id,role,is_primary) VALUES(%s,%s,'Kontakt',TRUE)",
(case_id, event["contact_id"]))
execute_query("INSERT INTO sag_tags(sag_id,tag_navn) VALUES(%s,%s)", (case_id, event["event_type"]))
if parent_id:
execute_query('INSERT INTO sag_relationer(kilde_sag_id,"målsag_id",relationstype) VALUES(%s,%s,\'undersag\')',
(parent_id, case_id))
if event.get("template_id"):
if not execute_query_single("""SELECT id FROM task_templates WHERE id=%s
AND category=%s AND is_active AND deleted_at IS NULL""",
(event["template_id"], event["event_type"])):
raise HTTPException(409, "Skabelonen er ændret eller deaktiveret. Vælg skabelon igen.")
from app.modules.task_templates.backend.models import TemplateRunRequest
from app.modules.task_templates.backend.router import run_template_for_case
await run_template_for_case(case_id, TemplateRunRequest(template_id=event["template_id"], start_date=event["event_date"]), actor)
execute_query("""UPDATE contact_lifecycle_events SET case_id=%s,parent_case_id=%s,
status='created',last_error=NULL,updated_at=NOW() WHERE id=%s""",
(case_id, parent_id, event["id"]))
return True
async def process_due_lifecycles(now=None):
"""Row locks and a single transaction encompass case, relation and template writes."""
ready = execute_query_single("SELECT to_regclass('contact_lifecycle_events') IS NOT NULL AS ready")
if not ready or not ready["ready"]:
return 0
now = now or datetime.now(timezone.utc)
if now.tzinfo is None:
raise ValueError("now must include timezone")
rows = execute_query("""SELECT id FROM contact_lifecycle_events
WHERE auto_create AND case_id IS NULL AND status IN ('scheduled','error')
AND ((event_date-lead_days)+TIME '08:00') AT TIME ZONE 'Europe/Copenhagen' <= %s
ORDER BY event_date,id LIMIT 100""", (now,)) or []
created = 0
for row in rows:
try:
with atomic_transaction():
event = execute_query_single("SELECT * FROM contact_lifecycle_events WHERE id=%s FOR UPDATE SKIP LOCKED", (row["id"],))
if not event or not event["auto_create"] or event.get("case_id") or event["status"] not in {"scheduled", "error"}:
continue
if scheduled_at(event["event_date"], event["lead_days"]) > now:
continue
did_create = await _create_case(event)
created += int(did_create)
except Exception as exc:
logger.exception("Contact lifecycle %s failed", row["id"])
message = str(exc.detail) if isinstance(exc, HTTPException) else "Sagen kunne ikke oprettes. Systemet prøver igen; kontakt administrator hvis fejlen fortsætter."
execute_query("""UPDATE contact_lifecycle_events SET status='error',last_error=%s,updated_at=NOW()
WHERE id=%s AND case_id IS NULL AND status IN ('scheduled','error')""", (message, row["id"]))
return created
async def check_contact_lifecycles():
"""Isolate lifecycle failures from other scheduled jobs."""
try:
return await process_due_lifecycles()
except Exception:
logger.exception("Contact lifecycle scheduler failed")
return 0

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, UploadFile, File from fastapi import APIRouter, HTTPException, Query, Body, status
from typing import Any, Optional from typing import 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,7 +15,6 @@ 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,
@ -23,19 +22,12 @@ 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()
from app.contacts.backend.lifecycle import router as lifecycle_router
router.include_router(lifecycle_router)
class ContactCreate(BaseModel): class ContactCreate(BaseModel):
@ -71,17 +63,6 @@ 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"),
@ -98,332 +79,6 @@ CONTACT_MERGE_RELATIONS = (
) )
def _exact_email_pattern(address: str) -> str:
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _plain_email_body(parsed: dict) -> str:
text = str(parsed.get("body_text") or "").strip()
if text:
return html.unescape(text).replace("\xa0", " ")
raw_html = str(parsed.get("body_html") or "")
raw_html = re.sub(r"(?is)<(script|style).*?>.*?</\1>", " ", raw_html)
raw_html = re.sub(r"(?i)<br\s*/?>|</p>|</div>", "\n", raw_html)
return html.unescape(re.sub(r"(?s)<[^>]+>", " ", raw_html)).replace("\xa0", " ")
def _signature_lines(body: str) -> list[str]:
"""Normalize common Outlook/Markdown decoration without losing line structure."""
lines = []
for raw_line in body.splitlines():
line = re.sub(r"[*_`]+", "", raw_line)
line = re.sub(r"\s+", " ", line).strip(" \\|")
if line:
lines.append(line)
return lines
def _labelled_phone(lines: list[str], labels: str) -> Optional[str]:
phone_pattern = re.compile(r"\+?\d(?:[\s().-]*\d){5,14}")
label_pattern = re.compile(rf"(?i)^\s*(?:{labels})\s*:\s*(.*)$")
for line in lines:
labelled = label_pattern.search(line)
if labelled:
phone = phone_pattern.search(labelled.group(1))
if phone:
return phone.group(0).strip()
return None
def _contact_suggestions_from_email(contact: dict, parsed: dict) -> list[dict]:
"""Conservative, review-only extraction from headers and labelled signature lines."""
body = _plain_email_body(parsed)
signature_lines = _signature_lines(body)
sender_email = str(parsed.get("sender_email") or "").strip().lower()
sender_name = str(parsed.get("sender_name") or "").strip()
parsed_sender_name, parsed_sender_address = parseaddr(sender_name)
if parsed_sender_name:
sender_name = parsed_sender_name
if not sender_email and parsed_sender_address:
sender_email = parsed_sender_address.strip().lower()
current_email = str(contact.get("email") or "").strip().lower()
candidates: dict[str, tuple[str, str]] = {}
# A received Outlook message normally represents the sender. If this looks
# like a sent message to the current contact, do not suggest our own sender.
recipient_text = str(parsed.get("recipient_email") or "").lower()
sender_represents_contact = not current_email or current_email == sender_email or current_email not in recipient_text
if sender_represents_contact and sender_email:
candidates["email"] = (sender_email, "Mailens afsender")
if sender_represents_contact and sender_name and "@" not in sender_name:
name_parts = sender_name.split()
if name_parts:
candidates["first_name"] = (name_parts[0], "Afsendernavn")
if len(name_parts) > 1:
candidates["last_name"] = (" ".join(name_parts[1:]), "Afsendernavn")
mobile = _labelled_phone(signature_lines, r"mobil|mobile|mob\.?")
phone = _labelled_phone(signature_lines, r"telefon|phone|tel\.?|direkte")
if mobile:
candidates["mobile"] = (mobile, "Mailens signatur")
if phone:
candidates["phone"] = (phone, "Mailens signatur")
for field, labels_pattern in (("title", r"titel|stilling|job title"), ("department", r"afdeling|department")):
pattern = re.compile(rf"(?i)^\s*(?:{labels_pattern})\s*:\s*(.{{2,100}}?)\s*$")
for line in signature_lines:
match = pattern.search(line)
if match:
candidates[field] = (match.group(1).strip(), "Mailens signatur")
break
# Outlook signatures often put an unlabelled job title directly below the
# sender's name. This remains a review-only suggestion.
contact_full_name = " ".join(
part for part in (str(contact.get("first_name") or "").strip(), str(contact.get("last_name") or "").strip()) if part
)
signature_names = [name.casefold() for name in (contact_full_name, sender_name) if len(name.split()) >= 2]
rejected_titles = {
"kind regards", "best regards", "regards", "med venlig hilsen",
"venlig hilsen", "mvh", "thanks", "thank you", "tak",
}
for signature_name in dict.fromkeys(signature_names):
for index in range(len(signature_lines) - 2, -1, -1):
if signature_lines[index].casefold() != signature_name:
continue
possible_title = signature_lines[index + 1]
if (
"title" not in candidates
and 2 <= len(possible_title) <= 100
and possible_title.casefold().rstrip(",.! ") not in rejected_titles
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|e-mail|web|www)\s*:", possible_title)
and not re.search(r"[@\d]|https?://", possible_title)
):
candidates["title"] = (possible_title, "Linjen under afsendernavnet")
break
if "title" in candidates:
break
labels = {
"first_name": "Fornavn", "last_name": "Efternavn", "email": "E-mail",
"phone": "Telefon", "mobile": "Mobil", "title": "Titel", "department": "Afdeling",
}
suggestions = []
for field, (value, source) in candidates.items():
current = str(contact.get(field) or "").strip()
if value and current.casefold() != value.casefold():
suggestions.append({"field": field, "label": labels[field], "current": current or None, "suggested": value, "source": source})
return suggestions
def _company_from_email_body(body: str, contact_name: str = "") -> dict:
lines = _signature_lines(body)
joined = "\n".join(lines)
cvr_match = re.search(r"(?i)\b(?:CVR(?:[- ]?nr\.?)?|VAT)\s*[:#]?\s*(?:DK\s*)?(\d[\d .-]{6,12}\d)", joined)
cvr_number = re.sub(r"\D", "", cvr_match.group(1)) if cvr_match else ""
if len(cvr_number) != 8:
cvr_number = ""
company_name = ""
labelled_name = re.search(r"(?im)^\s*(?:firma|company|virksomhed)\s*:\s*(.{2,100})$", joined)
if labelled_name:
company_name = labelled_name.group(1).strip()
if not company_name:
for index, line in enumerate(lines):
if not (re.search(r"\b\d{4}\s+[A-Za-zÆØÅæøå]", line) or re.search(r"[A-Za-zÆØÅæøå]{3,}\s+\d+[A-Za-z]?\b", line)):
continue
if index:
candidate = lines[index - 1].strip()
if (
candidate.casefold() != contact_name.casefold()
and not re.search(r"(?i)^(?:mobile|mobil|phone|telefon|email|web|cvr)\s*:", candidate)
and not re.search(r"[@+]|https?://", candidate)
):
company_name = candidate
break
return {"name": company_name or None, "cvr_number": cvr_number or None}
@router.post("/contacts/analyze-email")
async def analyze_email_for_new_contact(file: UploadFile = File(...), use_ai: bool = False):
"""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(10 * 1024 * 1024 + 1)
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}
if use_ai:
from app.services.contact_mail_ai import extract_contact
try:
ai_contact = await extract_contact(parsed)
contact.update(ai_contact)
except Exception:
raise HTTPException(status_code=502, detail="AI kunne ikke analysere mailen. Kontrollér AI-indstillingerne og prøv igen.")
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
@ -492,32 +147,28 @@ async def get_contacts(
where_clauses = [] where_clauses = []
params = [] params = []
normalized_search = " ".join(str(search or "").split()) if search:
if normalized_search: where_clauses.append(
for token in normalized_search.split(" "): """
token_clause = """
( (
CONCAT_WS(' ', c.first_name, c.last_name) ILIKE %s c.first_name ILIKE %s
OR CONCAT_WS(' ', c.last_name, c.first_name) ILIKE %s OR c.last_name ILIKE %s
OR COALESCE(c.email, '') ILIKE %s OR c.email ILIKE %s
OR COALESCE(c.title, '') ILIKE %s OR c.phone ILIKE %s
OR COALESCE(c.department, '') ILIKE %s OR c.mobile ILIKE %s
OR COALESCE(c.user_company, '') ILIKE %s OR c.user_company ILIKE %s
OR EXISTS ( OR EXISTS (
SELECT 1 FROM contact_companies cc2 SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id AND cu2.name ILIKE %s WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
) )
""" """
token_params: list[Any] = [f"%{token}%"] * 7 )
digits = "".join(ch for ch in token if ch.isdigit()) like = f"%{search}%"
if len(digits) >= 2: params.extend([like, like, like, like, like, like, like])
token_clause += " OR REGEXP_REPLACE(COALESCE(c.phone, ''), '[^0-9]', '', 'g') ILIKE %s"
token_clause += " OR REGEXP_REPLACE(COALESCE(c.mobile, ''), '[^0-9]', '', 'g') ILIKE %s"
token_params.extend([f"%{digits}%", f"%{digits}%"])
token_clause += ")"
where_clauses.append(token_clause)
params.extend(token_params)
if is_active is not None: if is_active is not None:
where_clauses.append("c.is_active = %s") where_clauses.append("c.is_active = %s")
@ -537,33 +188,16 @@ 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 {rank_order_sql} c.last_name, c.first_name, c.id ORDER BY c.last_name, c.first_name, c.id
LIMIT %s OFFSET %s 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 []
@ -689,9 +323,6 @@ def _contact_merge_counts(contact_id: int) -> list[dict]:
for label, table, column in CONTACT_MERGE_RELATIONS: for label, table, column in CONTACT_MERGE_RELATIONS:
row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {} row = execute_query_single(f"SELECT COUNT(*)::int AS count FROM {table} WHERE {column} = %s", (contact_id,)) or {}
counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)}) counts.append({"key": table, "label": label, "count": int(row.get("count") or 0)})
if (execute_query_single("SELECT to_regclass('contact_lifecycle_events') AS relation") or {}).get('relation'):
row = execute_query_single("SELECT COUNT(*)::int AS count FROM contact_lifecycle_events WHERE contact_id=%s", (contact_id,)) or {}
counts.append({"key": "contact_lifecycle_events", "label": "Onboarding/offboarding", "count": int(row.get('count') or 0)})
conversation_row = execute_query_single( conversation_row = execute_query_single(
""" """
SELECT COUNT(DISTINCT conversation.id)::int AS count SELECT COUNT(DISTINCT conversation.id)::int AS count
@ -822,11 +453,6 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
moved[table] = int(cursor.fetchone()["count"] or 0) moved[table] = int(cursor.fetchone()["count"] or 0)
cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id)) cursor.execute(f"UPDATE {table} SET {column} = %s WHERE {column} = %s", (contact_id, source_id))
cursor.execute("SELECT to_regclass('contact_lifecycle_events') AS relation")
if cursor.fetchone()['relation']:
cursor.execute("UPDATE contact_lifecycle_events SET contact_id=%s,updated_at=NOW() WHERE contact_id=%s", (contact_id, source_id))
moved['contact_lifecycle_events'] = cursor.rowcount
cursor.execute( cursor.execute(
""" """
INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations) INSERT INTO contact_merge_history (target_contact_id, source_contact_id, source_snapshot, moved_relations)
@ -848,16 +474,6 @@ async def merge_contact(contact_id: int, request: ContactMergeRequest):
release_db_connection(conn) release_db_connection(conn)
@router.get("/contacts/{contact_id}/vendors")
async def get_contact_vendors(contact_id: int):
"""Supplier relations, kept separate from customer/company semantics."""
if not execute_query_single("SELECT id FROM contacts WHERE id=%s", (contact_id,)):
raise HTTPException(status_code=404, detail="Contact not found")
return execute_query("""SELECT v.id, v.name, vc.role, vc.is_primary
FROM vendor_contacts vc JOIN vendors v ON v.id=vc.vendor_id
WHERE vc.contact_id=%s ORDER BY vc.is_primary DESC, v.name, v.id""", (contact_id,)) or []
@router.get("/contacts/{contact_id}") @router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int): async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies""" """Get a single contact by ID with linked companies"""

View File

@ -116,21 +116,6 @@
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 %}
@ -165,19 +150,6 @@
</div> </div>
</div> </div>
<div id="contactVendorSummary" class="alert alert-warning d-none mb-3" aria-live="polite"></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>
@ -201,16 +173,6 @@
<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
@ -314,14 +276,6 @@
</div> </div>
</div> </div>
<div class="col-12">
<div class="info-card" id="contactLifecycle" aria-labelledby="contactLifecycleHeading">
<h5 class="fw-bold mb-2" id="contactLifecycleHeading">Onboarding og offboarding</h5>
<p class="text-muted small mb-3">Planlæg start og fratrædelse, og få oprettet en sag i god tid.</p>
<div id="contactLifecycleFeedback" class="d-none" role="status" aria-live="polite"></div>
<div class="row g-3" id="contactLifecycleCards"><div class="col-12 text-muted">Henter forløb…</div></div>
</div>
</div>
<div class="col-12"> <div class="col-12">
<div class="info-card"> <div class="info-card">
<h5 class="fw-bold mb-3">System Info</h5> <h5 class="fw-bold mb-3">System Info</h5>
@ -357,10 +311,6 @@
<div class="spinner-border text-primary"></div> <div class="spinner-border text-primary"></div>
</div> </div>
</div> </div>
<div class="mb-4 d-none" id="contactVendorsSection">
<h5 class="fw-bold mt-4">Tilknyttede leverandører</h5>
<div class="row g-4" id="contactVendorsContainer"></div>
</div>
</div> </div>
<!-- Contacts Tab --> <!-- Contacts Tab -->
@ -393,24 +343,6 @@
</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>
@ -886,24 +818,6 @@
</div> </div>
<!-- Worklog Suggestion Modal --> <!-- Worklog Suggestion Modal -->
<div class="modal fade" id="remoteCaseModal" tabindex="-1" aria-labelledby="remoteCaseTitle">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="remoteCaseTitle">Tilknyt sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<label class="form-label" for="remoteCaseSearch">Søg på sagsnummer eller titel</label>
<input class="form-control mb-2" id="remoteCaseSearch" type="search" placeholder="Skriv fx 1234 eller Outlook" oninput="scheduleRemoteCaseSearch()">
<div class="small text-muted mb-3">Kun sager fra sessionens firma. Klik på en sag for at tilknytte den.</div>
<div id="remoteCaseError" class="alert alert-danger d-none" role="alert"></div>
<div id="remoteCaseResults" class="list-group"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="worklogSuggestionModal" tabindex="-1"> <div class="modal fade" id="worklogSuggestionModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -918,31 +832,9 @@
</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 %}
<script src="/static/js/contact-lifecycle.js?v=20261102-1"></script>
<!-- Alert Notes Components --> <!-- Alert Notes Components -->
<script src="/static/alert_notes/alert_check.js"></script> <script src="/static/alert_notes/alert_check.js"></script>
{% include "alert_notes/frontend/alert_box.html" %} {% include "alert_notes/frontend/alert_box.html" %}
@ -957,17 +849,10 @@ let kontaktHistoryItems = [];
let kontaktHistoryFilter = 'all'; let kontaktHistoryFilter = 'all';
let mergeSourceContactId = null; let mergeSourceContactId = null;
let mergeSearchTimer = null; let mergeSearchTimer = null;
let pendingContactEmailSuggestions = [];
let remoteSessionRows = [];
let remoteCaseSessionId = null;
let remoteCaseTimer = null;
let remoteCaseSearchVersion = 0;
let remoteCaseSaving = false;
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) {
@ -996,9 +881,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
document.querySelector('a[href="#cases"]')?.addEventListener('shown.bs.tab', () => loadContactCases());
document.querySelector('a[href="#emails"]')?.addEventListener('shown.bs.tab', () => loadContactEmails());
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]'); const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) { if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => { subscriptionsTab.addEventListener('shown.bs.tab', () => {
@ -1056,150 +938,6 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
function initializeContactEmailDropzone() {
const zone = document.getElementById('contactEmailDropzone');
const input = document.getElementById('contactEmailFileInput');
if (!zone || !input) return;
const choose = () => input.click();
zone.addEventListener('click', choose);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); choose(); }
});
input.addEventListener('change', () => {
if (input.files?.[0]) analyzeContactEmailFile(input.files[0]);
input.value = '';
});
['dragenter', 'dragover'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.add('is-dragging');
}));
['dragleave', 'drop'].forEach(name => zone.addEventListener(name, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
const file = event.dataTransfer?.files?.[0];
if (file) analyzeContactEmailFile(file);
});
}
async function analyzeContactEmailFile(file) {
const status = document.getElementById('contactEmailAnalyzeStatus');
const lower = String(file?.name || '').toLowerCase();
if (!lower.endsWith('.msg') && !lower.endsWith('.eml')) {
status.className = 'small text-danger';
status.textContent = 'Vælg en .msg- eller .eml-fil';
return;
}
status.className = 'small text-muted';
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyserer…';
const form = new FormData();
form.append('file', file);
try {
const response = await fetch(`/api/v1/contacts/${contactId}/analyze-email`, { method: 'POST', body: form });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Mailen kunne ikke analyseres');
pendingContactEmailSuggestions = Array.isArray(data.suggestions) ? data.suggestions : [];
renderContactEmailSuggestions(data);
bootstrap.Modal.getOrCreateInstance(document.getElementById('contactEmailSuggestionsModal')).show();
status.className = 'small text-success';
status.textContent = `${pendingContactEmailSuggestions.length} forslag fundet`;
} catch (error) {
status.className = 'small text-danger';
status.textContent = error.message;
}
}
function renderContactEmailSuggestions(data) {
const mail = data.mail || {};
document.getElementById('contactEmailSuggestionMeta').textContent = [data.filename, mail.subject, mail.sender_email].filter(Boolean).join(' · ');
const body = document.getElementById('contactEmailSuggestionsBody');
const button = document.getElementById('applyContactEmailSuggestionsBtn');
if (!pendingContactEmailSuggestions.length) {
body.innerHTML = '<div class="text-center text-muted py-4"><i class="bi bi-check-circle fs-2 d-block mb-2"></i>Ingen nye kontaktoplysninger fundet.</div>';
button.disabled = true;
return;
}
button.disabled = false;
body.innerHTML = `<div class="table-responsive"><table class="table align-middle"><thead><tr><th></th><th>Felt</th><th>Nuværende</th><th>Foreslået</th><th>Kilde</th></tr></thead><tbody>${pendingContactEmailSuggestions.map((item, index) => `
<tr><td><input class="form-check-input contact-email-suggestion-check" type="checkbox" data-index="${index}" checked></td><td class="fw-semibold">${escapeHtml(item.label)}</td><td class="text-muted">${escapeHtml(item.current || '—')}</td><td>${escapeHtml(item.suggested)}</td><td><span class="badge bg-light text-dark border">${escapeHtml(item.source)}</span></td></tr>`).join('')}</tbody></table></div>`;
}
async function applyContactEmailSuggestions() {
const selected = Array.from(document.querySelectorAll('.contact-email-suggestion-check:checked'))
.map(input => pendingContactEmailSuggestions[Number(input.dataset.index)])
.filter(Boolean);
if (!selected.length) return;
const payload = Object.fromEntries(selected.map(item => [item.field, item.suggested]));
const button = document.getElementById('applyContactEmailSuggestionsBtn');
button.disabled = true;
try {
const response = await fetch(`/api/v1/contacts/${contactId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.detail || 'Opdateringen kunne ikke gemmes');
bootstrap.Modal.getInstance(document.getElementById('contactEmailSuggestionsModal'))?.hide();
await loadContact();
const status = document.getElementById('contactEmailAnalyzeStatus');
status.className = 'small text-success';
status.textContent = `${selected.length} felt(er) opdateret`;
} catch (error) {
alert(error.message);
button.disabled = false;
}
}
const contactRelatedPageSize = 25;
let contactCasesOffset = 0;
let contactEmailsOffset = 0;
function contactPagination(hostId, total, offset, loaderName) {
const next = offset + contactRelatedPageSize;
document.getElementById(hostId).innerHTML = `
<button class="btn btn-sm btn-outline-secondary" ${offset === 0 ? 'disabled' : ''} onclick="${loaderName}(${Math.max(0, offset - contactRelatedPageSize)})">Forrige</button>
<span class="small text-muted">${total ? offset + 1 : 0}–${Math.min(next, total)} af ${total}</span>
<button class="btn btn-sm btn-outline-secondary" ${next >= total ? 'disabled' : ''} onclick="${loaderName}(${next})">Næste</button>`;
}
async function loadContactCases(offset = contactCasesOffset) {
contactCasesOffset = Math.max(0, offset);
const host = document.getElementById('contactCasesContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/cases?limit=${contactRelatedPageSize}&offset=${contactCasesOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
document.getElementById('contactCasesCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Sag</th><th>Status</th><th>Kunde</th><th>Ansvarlig</th><th>Opdateret</th></tr></thead><tbody>${data.items.map(item => `
<tr role="button" onclick="window.location.href='/sag/${item.id}'"><td><strong>#${item.id}</strong> ${escapeHtml(item.titel || '')}</td><td>${escapeHtml(item.status || '-')}</td><td>${escapeHtml(item.customer_name || '-')}</td><td>${escapeHtml(item.responsible_name || 'Ikke tildelt')}</td><td>${escapeHtml(item.updated_at ? new Date(item.updated_at).toLocaleString('da-DK') : '-')}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen direkte tilknyttede sager</div>';
contactPagination('contactCasesPagination', data.total, contactCasesOffset, 'loadContactCases');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactCasesPagination').innerHTML = '';
}
}
async function loadContactEmails(offset = contactEmailsOffset) {
contactEmailsOffset = Math.max(0, offset);
const host = document.getElementById('contactEmailsContainer');
host.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/contacts/${contactId}/emails?limit=${contactRelatedPageSize}&offset=${contactEmailsOffset}`);
if (!response.ok) throw new Error('Kunne ikke hente e-mails');
const data = await response.json();
document.getElementById('contactEmailsCount').textContent = data.total;
host.innerHTML = data.items.length ? `<table class="table table-hover align-middle"><thead><tr><th>Dato</th><th>Emne</th><th>Fra / til</th><th>Sag</th><th></th></tr></thead><tbody>${data.items.map(email => `
<tr role="button" onclick="window.location.href='/emails?open=${email.id}'"><td>${escapeHtml(email.received_date ? new Date(email.received_date).toLocaleString('da-DK') : '-')}</td><td>${email.is_read ? '' : '<span class="badge bg-primary me-1">Ny</span>'}${escapeHtml(email.subject || '(Intet emne)')}</td><td><div>${escapeHtml(email.sender_name || email.sender_email || '-')}</div><small class="text-muted">Til: ${escapeHtml(email.recipient_email || '-')}</small></td><td>${email.linked_case_id ? `<a href="/sag/${email.linked_case_id}" onclick="event.stopPropagation()">#${email.linked_case_id} ${escapeHtml(email.linked_case_title || '')}</a>` : '-'}</td><td>${email.has_attachments ? '<i class="bi bi-paperclip"></i>' : ''}</td></tr>`).join('')}</tbody></table>` : '<div class="text-center text-muted py-5">Ingen e-mails fundet for kontaktens adresse</div>';
contactPagination('contactEmailsPagination', data.total, contactEmailsOffset, 'loadContactEmails');
} catch (error) {
host.innerHTML = `<div class="alert alert-danger">${escapeHtml(error.message)}</div>`;
document.getElementById('contactEmailsPagination').innerHTML = '';
}
}
async function loadContact() { async function loadContact() {
try { try {
const response = await fetch(`/api/v1/contacts/${contactId}`); const response = await fetch(`/api/v1/contacts/${contactId}`);
@ -1244,7 +982,6 @@ function displayContact(contact) {
? '<span class="badge bg-success">Aktiv</span>' ? '<span class="badge bg-success">Aktiv</span>'
: '<span class="badge bg-secondary">Inaktiv</span>'; : '<span class="badge bg-secondary">Inaktiv</span>';
document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0; document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0;
loadContactVendors();
populateSessionCompanySelect(contact); populateSessionCompanySelect(contact);
@ -1387,21 +1124,6 @@ async function loadCompanies() {
displayCompanies(contactData.companies); displayCompanies(contactData.companies);
} }
async function loadContactVendors() {
const summary=document.getElementById('contactVendorSummary');
try {
const response=await fetch(`/api/v1/contacts/${contactId}/vendors`);
if(!response.ok)throw new Error('Leverandørkoblinger kunne ikke hentes');
const vendors=await response.json();
summary.classList.toggle('d-none',vendors.length===0);
summary.innerHTML=vendors.length ? `<strong><i class="bi bi-truck me-2"></i>Leverandørkontakt</strong><div class="mt-2">${vendors.map(v=>`<a class="badge bg-warning text-dark border me-2 mb-1 text-decoration-none" href="/vendors/${Number(v.id)}"><span class="fw-bold">Leverandør:</span> ${escapeHtml(v.name)}</a>`).join('')}</div>` : '';
document.getElementById('contactVendorsSection').classList.toggle('d-none',vendors.length===0);
document.getElementById('contactVendorsContainer').innerHTML=vendors.map(v=>`<div class="col-md-6"><div class="company-card border border-warning"><span class="badge bg-warning text-dark mb-2"><i class="bi bi-truck me-1"></i>Leverandør</span><h6 class="fw-bold"><a href="/vendors/${Number(v.id)}">${escapeHtml(v.name)}</a></h6>${v.is_primary?'<span class="badge bg-primary mb-2">Primær kontakt</span>':''}${v.role?`<div class="small"><span class="text-muted">Rolle:</span> ${escapeHtml(v.role)}</div>`:''}</div></div>`).join('');
} catch(error) {
summary.classList.remove('d-none');summary.textContent=error.message;
}
}
function displayCompanies(companies) { function displayCompanies(companies) {
const container = document.getElementById('companiesContainer'); const container = document.getElementById('companiesContainer');
@ -1826,7 +1548,7 @@ async function loadContactHardware() {
container.classList.remove('d-none'); container.classList.remove('d-none');
try { try {
const response = await fetch(`/api/v1/hardware/by-contact/${contactId}?direct_only=true`); const response = await fetch(`/api/v1/hardware/by-contact/${contactId}`);
if (!response.ok) { if (!response.ok) {
throw new Error('Kunne ikke hente hardware'); throw new Error('Kunne ikke hente hardware');
} }
@ -1858,10 +1580,6 @@ async function loadContactHardware() {
const serial = item.serial_number || item.customer_asset_id || '—'; const serial = item.serial_number || item.customer_asset_id || '—';
const location = locationsById[item.current_location_id] || item.location_name || '—'; const location = locationsById[item.current_location_id] || item.location_name || '—';
const status = item.status || '—'; const status = item.status || '—';
const anydeskId = String(item.anydesk_id || '').replace(/[\s-]/g, '');
const anydeskAction = /^\d+$/.test(anydeskId)
? `<a class="btn btn-sm btn-outline-danger me-1" href="anydesk:${anydeskId}" title="Åbn AnyDesk (${anydeskId})" aria-label="Åbn AnyDesk (${anydeskId})"><i class="bi bi-display me-1"></i>AnyDesk</a>`
: '';
return ` return `
<tr> <tr>
<td class="fw-semibold">${escapeHtml(assetLabel)}</td> <td class="fw-semibold">${escapeHtml(assetLabel)}</td>
@ -1870,7 +1588,6 @@ async function loadContactHardware() {
<td>${escapeHtml(location)}</td> <td>${escapeHtml(location)}</td>
<td>${escapeHtml(status)}</td> <td>${escapeHtml(status)}</td>
<td class="text-end"> <td class="text-end">
${anydeskAction}
<a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}"> <a class="btn btn-sm btn-outline-primary" href="/hardware/${item.id}">
<i class="bi bi-eye"></i> <i class="bi bi-eye"></i>
</a> </a>
@ -2383,7 +2100,6 @@ async function loadRemoteSessions() {
} }
function displayRemoteSessions(sessions) { function displayRemoteSessions(sessions) {
remoteSessionRows = sessions || [];
const container = document.getElementById('remoteSessionsContainer'); const container = document.getElementById('remoteSessionsContainer');
if (!container) return; if (!container) return;
@ -2392,110 +2108,39 @@ function displayRemoteSessions(sessions) {
return; return;
} }
const statusLabels = { container.innerHTML = sessions.map(session => {
draft: 'Kladde', pending: 'Afventer', active: 'Aktiv', completed: 'Afsluttet',
failed: 'Fejlet', cancelled: 'Annulleret'
};
const rows = sessions.map(session => {
const badgeClass = getSessionBadgeClass(session.status); const badgeClass = getSessionBadgeClass(session.status);
const duration = session.duration_minutes != null const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—';
? `${Number(session.duration_minutes).toLocaleString('da-DK')} min` : '—'; const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-';
const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '—'; const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-';
const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '—';
const caseLink = session.sag_id
? `<a href="/sag/${Number(session.sag_id)}" class="text-decoration-none"><strong>#${Number(session.sag_id)}</strong>${session.sag_title ? `<span class="d-block small">${escapeHtml(session.sag_title)}</span>` : ''}</a>`
: '<span class="text-muted">Ingen sag</span>';
const linkButton = session.session_link const linkButton = session.session_link
? `<a class="btn btn-sm btn-outline-primary" href="${escapeHtml(session.session_link)}" target="_blank" rel="noopener" title="Åbn sessionslink"><i class="bi bi-display me-1"></i>Åbn</a>` ? `<a class="btn btn-sm btn-outline-primary" href="${session.session_link}" target="_blank">Åbn Link</a>`
: ''; : '';
const worklogButton = session.status === 'completed' const worklogButton = session.status === 'completed'
? `<button class="btn btn-sm btn-outline-secondary" onclick="showWorklogSuggestion(${Number(session.id)})" title="Vis forslag til tidsregistrering">Tidsforslag</button>` ? `<button class="btn btn-sm btn-outline-secondary" onclick="showWorklogSuggestion(${session.id})">Vis forslag</button>`
: ''; : '';
return `<tr>
<td class="text-nowrap">${escapeHtml(startedAt)}<div class="small text-muted">Session #${Number(session.id)}</div></td> return `
<td class="text-nowrap">${escapeHtml(endedAt)}</td> <div class="col-12">
<td class="text-nowrap fw-semibold">${escapeHtml(duration)}</td> <div class="session-card">
<td style="min-width:180px;">${caseLink}<button type="button" class="btn btn-sm btn-link p-0 d-block" onclick="openRemoteCasePicker(${Number(session.id)})">${session.sag_id ? 'Skift sag' : 'Tilknyt sag'}</button></td> <div class="d-flex flex-wrap justify-content-between align-items-start gap-3">
<td><span class="badge ${badgeClass}">${escapeHtml(statusLabels[session.status] || session.status || 'Ukendt')}</span></td> <div>
<td><div class="d-flex gap-1 justify-content-end">${linkButton}${worklogButton || (!linkButton ? '<span class="text-muted">—</span>' : '')}</div></td> <div class="d-flex align-items-center gap-2 mb-2">
</tr>`; <span class="badge ${badgeClass}">${escapeHtml(session.status || 'ukendt')}</span>
<strong>Session #${session.id}</strong>
</div>
<div class="session-meta">Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}</div>
${session.sag_id ? `<div class="session-meta">Sag ID: ${session.sag_id}</div>` : ''}
</div>
<div class="d-flex gap-2">
${linkButton}
${worklogButton}
</div>
</div>
</div>
</div>
`;
}).join(''); }).join('');
container.innerHTML = `<div class="col-12"><div class="table-responsive border rounded">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light"><tr><th>Start</th><th>Slut</th><th>Varighed</th><th>Sag</th><th>Status</th><th class="text-end">Handling</th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div></div>`;
}
function openRemoteCasePicker(sessionId) {
if (remoteCaseSaving) return;
remoteCaseSessionId = sessionId;
clearTimeout(remoteCaseTimer);
document.getElementById('remoteCaseTitle').textContent = `Tilknyt sag · session #${sessionId}`;
document.getElementById('remoteCaseSearch').value = '';
document.getElementById('remoteCaseError').classList.add('d-none');
const modalElement = document.getElementById('remoteCaseModal');
modalElement.addEventListener('shown.bs.modal', () => document.getElementById('remoteCaseSearch').focus(), { once: true });
bootstrap.Modal.getOrCreateInstance(modalElement).show();
searchRemoteSessionCases();
}
function scheduleRemoteCaseSearch() {
clearTimeout(remoteCaseTimer);
remoteCaseSearchVersion++;
remoteCaseTimer = setTimeout(searchRemoteSessionCases, 220);
}
async function searchRemoteSessionCases() {
const version = ++remoteCaseSearchVersion;
const session = remoteSessionRows.find(row => Number(row.id) === Number(remoteCaseSessionId));
const host = document.getElementById('remoteCaseResults');
if (!session?.customer_id) {
host.innerHTML = '<div class="text-muted p-2">Tilknyt først et firma til sessionen via AnyDesk-sessioner.</div>';
return;
}
host.innerHTML = '<div class="text-muted p-2">Henter sager…</div>';
const params = new URLSearchParams({ customer_id: session.customer_id, limit: '8' });
const query = document.getElementById('remoteCaseSearch').value.trim().replace(/^#/, '');
if (query) params.set('q', query);
try {
const response = await fetch(`/api/v1/sag?${params}`);
if (!response.ok) throw new Error('Kunne ikke hente sager');
const data = await response.json();
if (version !== remoteCaseSearchVersion) return;
const cases = data.sager || (Array.isArray(data) ? data : []);
host.innerHTML = cases.length ? cases.map(item => `<button type="button" data-remote-case class="list-group-item list-group-item-action" onclick="linkRemoteSessionCase(${Number(item.id)})">
<div class="d-flex justify-content-between gap-2"><strong>#${Number(item.id)}</strong><span class="small text-muted">${escapeHtml(item.status || '')}</span></div>
<div>${escapeHtml(item.titel || item.title || '(Ingen titel)')}</div>
</button>`).join('') : '<div class="text-muted p-2">Ingen sager fundet. Prøv et andet sagsnummer eller søgeord.</div>';
} catch (error) {
if (version === remoteCaseSearchVersion) host.innerHTML = `<div class="text-danger p-2">${escapeHtml(error.message)}</div>`;
}
}
async function linkRemoteSessionCase(caseId) {
if (remoteCaseSaving) return;
remoteCaseSaving = true;
const sessionId = remoteCaseSessionId;
const errorBox = document.getElementById('remoteCaseError');
errorBox.classList.add('d-none');
document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = true);
try {
const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/link?sag_id=${caseId}&require_same_customer=true`, { method: 'PATCH' });
if (!response.ok) {
const error = await response.json();
throw new Error(typeof error.detail === 'string' ? error.detail : 'Kunne ikke tilknytte sagen');
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('remoteCaseModal')).hide();
await loadRemoteSessions();
} catch (error) {
errorBox.textContent = error.message;
errorBox.classList.remove('d-none');
} finally {
remoteCaseSaving = false;
document.querySelectorAll('[data-remote-case]').forEach(button => button.disabled = false);
}
} }
async function startRemoteSession() { async function startRemoteSession() {

View File

@ -490,24 +490,6 @@
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%;
@ -546,17 +528,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex gap-2 flex-wrap">
<div id="createFromEmailDropzone" class="create-mail-dropzone d-flex align-items-center gap-2" role="button" tabindex="0" title="Træk en mail direkte fra Outlook hertil">
<i class="bi bi-envelope-arrow-down fs-5"></i>
<div><div class="fw-semibold small">Træk Outlook-mail hertil</div><div class="text-muted" style="font-size:.7rem">eller klik for at vælge</div></div>
</div>
<input id="createFromEmailInput" type="file" accept=".msg,.eml,message/rfc822,application/vnd.ms-outlook" hidden>
<button class="btn btn-primary" onclick="showCreateContactModal()"> <button class="btn btn-primary" onclick="showCreateContactModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Kontakt <i class="bi bi-plus-lg me-2"></i>Opret Kontakt
</button> </button>
</div> </div>
</div>
<div class="mb-4 d-flex gap-2 flex-wrap"> <div class="mb-4 d-flex gap-2 flex-wrap">
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')"> <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">
@ -657,55 +632,6 @@
</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">
@ -893,8 +819,6 @@ let selectedCompanyIds = new Set();
let currentContactsData = []; let currentContactsData = [];
let pendingCreateModalCustomerId = null; let pendingCreateModalCustomerId = null;
let pendingCreateReturnTo = null; let pendingCreateReturnTo = null;
let pendingCreateVendorId = null;
let analyzedMailCompany = null;
let currentSort = { let currentSort = {
key: 'name', key: 'name',
direction: 'asc' direction: 'asc'
@ -908,18 +832,10 @@ 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';
pendingCreateReturnTo = urlParams.get('return_to') || null; pendingCreateReturnTo = urlParams.get('return_to') || null;
const vendorId = Number(urlParams.get('vendor_id'));
pendingCreateVendorId = Number.isInteger(vendorId) && vendorId > 0 ? vendorId : null;
pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0 pendingCreateModalCustomerId = Number.isFinite(preselectedCustomerId) && preselectedCustomerId > 0
? preselectedCustomerId ? preselectedCustomerId
: null; : null;
@ -1002,42 +918,6 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
}); });
function initializeCreateFromEmailDropzone() {
const zone = document.getElementById('createFromEmailDropzone');
const input = document.getElementById('createFromEmailInput');
if (!zone || !input) return;
const chooseFile = () => input.click();
zone.addEventListener('click', chooseFile);
zone.addEventListener('keydown', event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
chooseFile();
}
});
['dragenter', 'dragover'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
event.stopPropagation();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy';
zone.classList.add('is-dragging');
}));
['dragleave', 'dragend'].forEach(type => zone.addEventListener(type, event => {
event.preventDefault();
zone.classList.remove('is-dragging');
}));
zone.addEventListener('drop', event => {
event.preventDefault();
event.stopPropagation();
zone.classList.remove('is-dragging');
const file = event.dataTransfer?.files?.[0]
|| Array.from(event.dataTransfer?.items || []).find(item => item.kind === 'file')?.getAsFile();
if (!file) {
alert('Outlook afleverede ikke mailen som en fil. Prøv at gemme eller trække den som .msg/.eml, eller klik på boksen.');
return;
}
analyzeEmailForNewContact(file);
});
}
function setFilter(filter) { function setFilter(filter) {
currentFilter = filter; currentFilter = filter;
currentPage = 0; currentPage = 0;
@ -1545,105 +1425,6 @@ 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();
@ -1713,17 +1494,6 @@ async function createContact() {
const createdContactId = Number(newContact?.id) || null; const createdContactId = Number(newContact?.id) || null;
if (pendingCreateVendorId && createdContactId) {
const linkResponse = await fetch(`/api/v1/vendors/${pendingCreateVendorId}/contacts`, {
method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({contact_id:createdContactId,role:contactData.role || '',is_primary:contactData.is_primary})
}).catch(() => null);
if (!linkResponse || !linkResponse.ok) {
alert('Kontaktpersonen er oprettet, men kunne ikke tilknyttes leverandøren. Tilknyt den fra leverandørkortet.');
window.location.href = `/contacts/${createdContactId}`;
return;
}
}
if (pendingCreateReturnTo) { if (pendingCreateReturnTo) {
window.location.href = pendingCreateReturnTo; window.location.href = pendingCreateReturnTo;
return; return;

View File

@ -59,12 +59,12 @@
<style> <style>
.list-group-item-action { cursor: pointer; border-left: 3px solid transparent; } .list-group-item-action { cursor: pointer; border-left: 3px solid transparent; }
.list-group-item-action:hover { background-color: var(--theme-surface-soft, #f8f9fa); } .list-group-item-action:hover { background-color: #f8f9fa; }
.list-group-item-action.active { background-color: var(--theme-surface-soft, #e9ecef); color: var(--theme-text, #000); border-left-color: var(--bs-primary); border-color: rgba(0,0,0,0.125); } .list-group-item-action.active { background-color: #e9ecef; color: #000; border-left-color: var(--bs-primary); border-color: rgba(0,0,0,0.125); }
.timestamp-link { cursor: pointer; color: var(--bs-primary); text-decoration: none; font-weight: 500;} .timestamp-link { cursor: pointer; color: var(--bs-primary); text-decoration: none; font-weight: 500;}
.timestamp-link:hover { text-decoration: underline; } .timestamp-link:hover { text-decoration: underline; }
.transcript-line { transition: background-color 0.2s; border-radius: 4px; padding: 2px 4px; } .transcript-line { transition: background-color 0.2s; border-radius: 4px; padding: 2px 4px; }
.transcript-line:hover { background-color: var(--theme-warning-bg, #fff3cd); } .transcript-line:hover { background-color: #fff3cd; }
</style> </style>
<script> <script>

View File

@ -1,199 +0,0 @@
"""Shared case confidentiality policy for HTML, APIs, feeds and work queues."""
from contextvars import ContextVar
import json
import re
from fastapi import HTTPException
from starlette.responses import JSONResponse
from psycopg2.errors import DeadlockDetected,SerializationFailure
from app.core.database import execute_query, execute_query_single
_scope = ContextVar('case_visibility_scope', default=None)
_DROP = object()
def hidden_case_ids(user):
if user.get('is_superadmin'):
return set()
current = _scope.get()
if current is not None and current[0] == user.get('id'):
return current[1]
if 'permissions' in user:
readable=bool(set(user.get('permissions') or []) & {'cases.view','tickets.view'})
else:
row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM users WHERE user_id=%s AND is_superadmin)
OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('cases.view','tickets.view')) AS allowed''',
(user.get('id'),user.get('id')))
readable=bool(row and row['allowed'])
if not readable:
return {r['id'] for r in execute_query('SELECT id FROM sag_sager') or []}
exists = execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
if not exists or not exists['present']:
return set()
return {r['sag_id'] for r in execute_query('''SELECT v.sag_id FROM sag_case_visibility v
WHERE v.restricted AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=v.sag_id AND a.user_id=%s)
AND NOT EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
WHERE a.sag_id=v.sag_id AND ug.user_id=%s)''',(user.get('id'),user.get('id'))) or []}
def require_case(user,sag_id):
if sag_id in hidden_case_ids(user):
# 404 avoids confirming whether a confidential case exists.
raise HTTPException(404,'Sagen findes ikke')
def notification_recipients(sag_id,user_ids):
exists=execute_query_single("SELECT to_regclass('sag_case_visibility') IS NOT NULL AS present")
if not exists or not exists['present']:
return user_ids,False
users=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
(u.is_superadmin OR EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=u.user_id AND p.code IN ('cases.view','tickets.view')))''',(user_ids,)) or []
user_ids=[r['user_id'] for r in users]
private=execute_query_single('SELECT restricted FROM sag_case_visibility WHERE sag_id=%s',(sag_id,))
if not private or not private['restricted']:
return user_ids,False
allowed=execute_query('''SELECT u.user_id FROM users u WHERE u.user_id=ANY(%s) AND u.is_active AND
(u.is_superadmin OR EXISTS(SELECT 1 FROM sag_case_visibility_users a WHERE a.sag_id=%s AND a.user_id=u.user_id)
OR EXISTS(SELECT 1 FROM sag_case_visibility_groups a JOIN user_groups ug ON ug.group_id=a.group_id
WHERE a.sag_id=%s AND ug.user_id=u.user_id))''',(user_ids,sag_id,sag_id)) or []
return [r['user_id'] for r in allowed],True
def linked_ids(value,case_list=False):
result = set()
if not isinstance(value,dict):
return result
for key in ('sag_id','hub_case_id','kilde_sag_id','målsag_id','parent_sag_id'):
if isinstance(value.get(key),int):
result.add(value[key])
if value.get('reference_type')=='case' and isinstance(value.get('reference_id'),int):
result.add(value['reference_id'])
if 'titel' in value and isinstance(value.get('case_id'),int):
result.add(value['case_id'])
elif (case_list or 'titel' in value or value.get('entity_type')=='case') and isinstance(value.get('id'),int):
result.add(value['id'])
if value.get('type')=='relation' and isinstance(value.get('case_id'),int):
result.add(value['case_id'])
for key in ('url','action','href','download_url','link','case_url','action_url','context_path'):
if isinstance(value.get(key),str):
result.update(int(s) for s in re.findall(r'/sag/(\d+)(?:/|\?|$)',value[key]))
if isinstance(value.get('extendedProps'),dict):
result.update(linked_ids(value['extendedProps']))
for key in ('case','linked_case','parent_case'):
if isinstance(value.get(key),dict):
result.update(linked_ids(value[key]))
return result
def filter_data(value,hidden,context=''):
"""Drop confidential records; calendar reservations retain only busy intervals."""
if not hidden:
return value
if isinstance(value,list):
result=[]
for item in value:
filtered=filter_data(item,hidden,context)
if filtered is not _DROP:
result.append(filtered)
return result
if not isinstance(value,dict):
return value
case_list='cases' in context or context in {'/api/v1/sag','/api/v1/planner/cases','urgent','unassigned','today_tasks'}
if linked_ids(value,case_list) & hidden:
start=value.get('starts_at') or value.get('start')
end=value.get('ends_at') or value.get('end')
if start and end:
safe={k:value[k] for k in ('starts_at','ends_at','start','end','user_id') if k in value}
safe.update(title='Optaget',case_title='Optaget',customer_name='',private=True,locked=True,editable=False,conflict_status='none')
safe['id']='busy-'+str(value.get('id','reservation'))
return safe
return _DROP
result={}
for key,item in value.items():
filtered=filter_data(item,hidden,key if key not in {'items','list'} else context)
if filtered is not _DROP:
result[key]=filtered
return result
def requested_case_ids(value):
result=set()
if isinstance(value,list):
for item in value:
result.update(requested_case_ids(item))
elif isinstance(value,dict):
if value.get('type')=='relation':
raw=value.get('case_id') or value.get('caseId') or value.get('target_id')
try:
result.add(int(str(raw).lstrip('#')))
except (ValueError,TypeError):
pass
for key,item in value.items():
if key in {'sag_id','hub_case_id','kilde_sag_id','målsag_id'} and isinstance(item,int):
result.add(item)
elif key=='sag_ids' and isinstance(item,list):
result.update(i for i in item if isinstance(i,int))
elif isinstance(item,(dict,list)):
result.update(requested_case_ids(item))
return result
def scrub_absence_time(value,user_id,can_see_reason):
if isinstance(value,list):
return [scrub_absence_time(item,user_id,can_see_reason) for item in value]
if not isinstance(value,dict):
return value
result={k:scrub_absence_time(v,user_id,can_see_reason) for k,v in value.items()}
owner=value.get('medarbejder_id',value.get('user_id'))
if value.get('planner_absence_id') and owner!=user_id and not can_see_reason:
for key in ('planner_activity_code','planner_absence_id'):
if key in result:
result[key]=None
if 'description' in result:
result['description']='Fravær'
return result
async def protect_request(request,call_next,user):
hidden=hidden_case_ids(user)
token=_scope.set((user.get('id'),hidden))
try:
request.state.case_superadmin=bool(user.get('is_superadmin'))
refs={int(s) for s in re.findall(r'/(?:sag|planner/cases)/(\d+)(?:/|$)',request.url.path)}
for key in ('sag_id','kilde_sag_id','målsag_id'):
raw=request.query_params.get(key)
if raw and raw.isdigit():
refs.add(int(raw))
if hidden and request.method in {'POST','PUT','PATCH','DELETE'} and 'application/json' in request.headers.get('content-type',''):
try:
refs.update(requested_case_ids(await request.json()))
except (ValueError,UnicodeError):
pass # Normal endpoint validation handles malformed JSON.
if refs & hidden:
return JSONResponse({'detail':'Sagen findes ikke'},status_code=404)
try:
response=await call_next(request)
except (DeadlockDetected,SerializationFailure):
if request.url.path.startswith('/api/v1/planner'):
return JSONResponse({'detail':'Planlægningen blev ændret samtidig. Genindlæs og prøv igen.'},status_code=409)
raise
check_absence=request.url.path.startswith('/api/v1/timetracking')
if (hidden or check_absence) and request.url.path.startswith('/api/') and 'application/json' in response.headers.get('content-type',''):
body=b''.join([chunk async for chunk in response.body_iterator])
data=json.loads(body)
filtered=filter_data(data,hidden,request.url.path.rstrip('/'))
if check_absence and b'planner_absence_id' in body:
allowed=bool(user.get('is_superadmin') or set(user.get('permissions') or []) & {'planner.admin','planner.absence_reason'})
if not allowed and 'permissions' not in user:
row=execute_query_single('''SELECT EXISTS(SELECT 1 FROM user_groups ug JOIN group_permissions gp ON gp.group_id=ug.group_id
JOIN permissions p ON p.id=gp.permission_id WHERE ug.user_id=%s AND p.code IN ('planner.admin','planner.absence_reason')) AS allowed''',(user.get('id'),))
allowed=bool(row and row['allowed'])
filtered=scrub_absence_time(filtered,user.get('id'),allowed)
result=JSONResponse(None if filtered is _DROP else filtered,status_code=response.status_code,background=response.background)
result.raw_headers += [(k,v) for k,v in response.raw_headers if k.lower() not in {b'content-length',b'content-type'}]
return result
return response
finally:
_scope.reset(token)

View File

@ -15,21 +15,6 @@ 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"
API_PORT: int = 8000 API_PORT: int = 8000
@ -37,14 +22,6 @@ class Settings(BaseSettings):
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
HUB_BASE_URL: str = "https://hub.bmcnetworks.dk" HUB_BASE_URL: str = "https://hub.bmcnetworks.dk"
# Local-network/VPN remote control. Configure a separate random key before enabling.
REMOTE_ENABLED: bool = False
REMOTE_LOOKUP_KEY: str = ""
# 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
@ -127,17 +104,6 @@ class Settings(BaseSettings):
# Ollama LLM # Ollama LLM
OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434" OLLAMA_ENDPOINT: str = "http://172.16.31.195:11434"
OLLAMA_MODEL: str = "llama3.2" OLLAMA_MODEL: str = "llama3.2"
# Shared workflows are opt-in. Existing billing remains authoritative by default.
WORKFLOWS_ENABLED: bool = False
WORKFLOWS_MODE: str = "shadow"
WORKFLOWS_CUSTOMER_IDS: List[int] = []
WORKFLOWS_INTERNAL_ENABLED: bool = False
WORKFLOW_AI_AUTO_THRESHOLD: float = 90
WORKFLOW_AI_SUGGEST_THRESHOLD: float = 70
WORKFLOW_AI_ENABLED: bool = False
# 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
@ -193,8 +159,6 @@ class Settings(BaseSettings):
VTIGER_URL: str = "" VTIGER_URL: str = ""
VTIGER_USERNAME: str = "" VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = "" VTIGER_API_KEY: str = ""
# VTiger Cases custom checkbox/flag used after a case has been imported into Hub.
VTIGER_CASE_HUBBED_FIELD: str = "cf_cases_hubbet"
# Data Consistency Settings # Data Consistency Settings
VTIGER_SYNC_ENABLED: bool = True VTIGER_SYNC_ENABLED: bool = True
@ -246,8 +210,6 @@ class Settings(BaseSettings):
ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30 ARCHIVED_VTIGER_SYNC_INTERVAL_MINUTES: int = 30
ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000 ARCHIVED_VTIGER_SYNC_LIMIT: int = 5000
ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False ARCHIVED_VTIGER_SYNC_INCLUDE_MESSAGES: bool = False
PROJECT_CT_ARCHIVE_SYNC_ENABLED: bool = True
PROJECT_CT_ARCHIVE_SYNC_INTERVAL_MINUTES: int = 360
# Backup System Configuration # Backup System Configuration
BACKUP_ENABLED: bool = True BACKUP_ENABLED: bool = True
@ -364,24 +326,6 @@ 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

@ -5,38 +5,17 @@ PostgreSQL connection and helpers using psycopg2
import psycopg2 import psycopg2
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from psycopg2.pool import ThreadedConnectionPool from psycopg2.pool import SimpleConnectionPool
from functools import lru_cache from functools import lru_cache
from typing import Optional from typing import Optional
import logging import logging
from contextlib import contextmanager
from contextvars import ContextVar
from app.core.config import settings from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Connection pool # Connection pool
connection_pool: Optional[ThreadedConnectionPool] = None connection_pool: Optional[SimpleConnectionPool] = None
_transaction_connection = ContextVar("bmc_transaction_connection", default=None)
@contextmanager
def atomic_transaction():
"""Opt-in transaction shared by existing helpers; legacy calls are unchanged."""
if _transaction_connection.get() is not None:
raise RuntimeError("Nested atomic transactions are not supported")
conn = get_db_connection()
token = _transaction_connection.set(conn)
try:
yield conn
conn.commit()
except BaseException:
conn.rollback()
raise
finally:
_transaction_connection.reset(token)
release_db_connection(conn)
def init_db(): def init_db():
@ -44,8 +23,7 @@ def init_db():
global connection_pool global connection_pool
try: try:
# Synchronous API handlers run in worker threads, including remote polls. connection_pool = SimpleConnectionPool(
connection_pool = ThreadedConnectionPool(
minconn=1, minconn=1,
maxconn=10, maxconn=10,
dsn=settings.DATABASE_URL dsn=settings.DATABASE_URL
@ -85,8 +63,7 @@ def get_db():
def execute_query(query: str, params: tuple = None, fetch: bool = True): def execute_query(query: str, params: tuple = None, fetch: bool = True):
"""Execute a SQL query and return results""" """Execute a SQL query and return results"""
owned = _transaction_connection.get() is None conn = get_db_connection()
conn = _transaction_connection.get() or get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params) cursor.execute(query, params)
@ -96,7 +73,7 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper() clean_query = "\n".join([line for line in query.split("\n") if not line.strip().startswith("--")]).strip().upper()
is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT')) is_write = clean_query.startswith(('INSERT', 'UPDATE', 'DELETE', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'COMMENT'))
if is_write and owned: if is_write:
conn.commit() conn.commit()
# Only fetch if there are results to fetch (cursor.description is not None) # Only fetch if there are results to fetch (cursor.description is not None)
@ -105,23 +82,19 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
return cursor.rowcount return cursor.rowcount
except Exception as e: except Exception as e:
if owned:
conn.rollback() conn.rollback()
logger.error(f"Query error: {e}") logger.error(f"Query error: {e}")
raise raise
finally: finally:
if owned:
release_db_connection(conn) release_db_connection(conn)
def execute_insert(query: str, params: tuple = None): def execute_insert(query: str, params: tuple = None):
"""Execute INSERT query and return new ID (requires RETURNING clause)""" """Execute INSERT query and return new ID (requires RETURNING clause)"""
owned = _transaction_connection.get() is None conn = get_db_connection()
conn = _transaction_connection.get() or get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params) cursor.execute(query, params)
if owned:
conn.commit() conn.commit()
result = cursor.fetchone() result = cursor.fetchone()
if result: if result:
@ -129,32 +102,26 @@ def execute_insert(query: str, params: tuple = None):
return result[list(result.keys())[0]] if result else None return result[list(result.keys())[0]] if result else None
return None return None
except Exception as e: except Exception as e:
if owned:
conn.rollback() conn.rollback()
logger.error(f"Insert error: {e}") logger.error(f"Insert error: {e}")
raise raise
finally: finally:
if owned:
release_db_connection(conn) release_db_connection(conn)
def execute_update(query: str, params: tuple = None): def execute_update(query: str, params: tuple = None):
"""Execute UPDATE/DELETE query and return affected rows""" """Execute UPDATE/DELETE query and return affected rows"""
owned = _transaction_connection.get() is None conn = get_db_connection()
conn = _transaction_connection.get() or get_db_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor: with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute(query, params) cursor.execute(query, params)
if owned:
conn.commit() conn.commit()
return cursor.rowcount return cursor.rowcount
except Exception as e: except Exception as e:
if owned:
conn.rollback() conn.rollback()
logger.error(f"Update error: {e}") logger.error(f"Update error: {e}")
raise raise
finally: finally:
if owned:
release_db_connection(conn) release_db_connection(conn)

View File

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

View File

@ -1,309 +0,0 @@
"""Local, atomic customer consolidation; never writes to external systems.
Relations are discovered from the actual schema, including legacy integer Hub
references without foreign keys. Conflicts abort the entire operation. Preview
executes the same transaction and rolls it back, including trigger effects.
"""
import hashlib
import json
import logging
from contextlib import contextmanager
import psycopg2
from psycopg2 import sql
from psycopg2.extras import RealDictCursor, Json
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from app.core.database import get_db_connection, release_db_connection
from app.core.auth_dependencies import require_permission
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
class MergeSelection(BaseModel):
target_id: int = Field(gt=0)
source_ids: list[int] = Field(min_length=1, max_length=50)
class MergeConfirmation(MergeSelection):
preview_token: str = Field(min_length=64, max_length=64)
@contextmanager
def transaction(commit=False):
conn = get_db_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("SET LOCAL lock_timeout = '5s'")
cursor.execute("SET LOCAL statement_timeout = '30s'")
yield cursor
if commit:
conn.commit()
else:
conn.rollback()
except BaseException:
conn.rollback()
raise
finally:
release_db_connection(conn)
def selections(target, sources):
sources = sorted(set(sources))
if not sources or target in sources or any(i <= 0 for i in sources):
raise HTTPException(422, 'Vælg en hovedkunde og mindst én anden kunde.')
return sources
def relations(cur):
"""Only real tables in current schema; never confuse tmodule customer IDs.
A numeric customer reference with an FK to another entity is excluded.
Partition children are handled through their parent, avoiding double moves.
"""
cur.execute("""
SELECT c.relname AS table_name, a.attname AS column_name,
EXISTS(SELECT 1 FROM pg_attribute x WHERE x.attrelid=c.oid
AND x.attname='entity_type' AND NOT x.attisdropped) AS polymorphic,
EXISTS(SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid='customers'::regclass) AS customer_fk,
EXISTS(SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid<>'customers'::regclass) AS other_fk
FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace
JOIN pg_attribute a ON a.attrelid=c.oid AND a.attnum>0 AND NOT a.attisdropped
WHERE n.nspname=current_schema() AND c.relkind IN ('r','p') AND NOT c.relispartition
AND c.relname<>'customer_merge_history'
AND a.atttypid IN ('int2'::regtype,'int4'::regtype,'int8'::regtype)
AND (a.attname='customer_id' OR a.attname LIKE '%%_customer_id'
OR a.attname='entity_id' OR EXISTS(
SELECT 1 FROM pg_constraint f WHERE f.conrelid=c.oid AND f.contype='f'
AND a.attnum=ANY(f.conkey) AND f.confrelid='customers'::regclass))
ORDER BY c.relname,a.attname
""")
return [r for r in cur.fetchall() if not r['other_fk']
and r['column_name'] != 'merged_into_customer_id'
and (r['column_name'] != 'entity_id' or r['polymorphic'])
# These logs use IDs from the time module, not Hub IDs.
and not (r['table_name'] in ('tmodule_sync_log', 'tticket_audit_log')
and r['column_name'] == 'entity_id')]
def where(ref):
clause = sql.SQL('{} = ANY(%s)').format(sql.Identifier(ref['column_name']))
if ref['column_name'] == 'entity_id':
clause += sql.SQL(" AND entity_type='customer'")
return clause
def signature(value):
return hashlib.sha256(json.dumps(value, sort_keys=True, default=str).encode()).hexdigest()
def mentions_customer(value, sources):
if isinstance(value,list):
return any(mentions_customer(v,sources) for v in value)
if not isinstance(value,dict):
return False
candidates=[value.get('customer_id')]
if value.get('field') == 'customer_id':
candidates.extend(value.get('value') if isinstance(value.get('value'),list) else [value.get('value')])
return any(str(v) in {str(i) for i in sources} for v in candidates if v is not None) or any(
mentions_customer(v,sources) for v in value.values() if isinstance(v,(dict,list)))
IDENTITY_FIELDS = ('economic_customer_number', 'vtiger_id', 'vtiger_account_no', 'cvr_number')
IDENTITY_LABELS = {'economic_customer_number':'e-conomic-kundenumre','vtiger_id':'vTiger-id’er',
'vtiger_account_no':'vTiger-kundenumre','cvr_number':'CVR-numre'}
FILL_FIELDS = ('email', 'phone', 'address', 'email_domain', 'city', 'postal_code',
'country', 'website', 'invoice_email', 'mobile_phone', 'wiki_slug')
def field_plan(customers, target):
changes, differences = {}, []
for field in IDENTITY_FIELDS + FILL_FIELDS:
values = {str(c[field]).strip(): c[field] for c in customers if c.get(field) not in (None, '')}
if field in IDENTITY_FIELDS and len(values) > 1:
raise HTTPException(409, f'Kunderne har forskellige {IDENTITY_LABELS[field]}. Afklar relationen før sammenlægning.')
if not target.get(field) and len(values) == 1:
changes[field] = next(iter(values.values()))
elif len(values) > 1:
differences.append(field)
excluded=set(IDENTITY_FIELDS+FILL_FIELDS) | {'id','created_at','updated_at','deleted_at','last_synced_at',
'merged_into_customer_id','parent_customer_id','billing_customer_id'}
for field in sorted(set().union(*(c.keys() for c in customers))-excluded):
values={json.dumps(c.get(field),default=str,sort_keys=True) for c in customers}
if len(values)>1: differences.append(field)
return changes, differences
def prepare(cur, target_id, source_ids):
cur.execute("SELECT to_regclass('customer_merge_history') AS history")
if not cur.fetchone()['history']:
raise HTTPException(503, 'Kundesammenlægning kræver migration 1074_customer_merge.sql.')
# Consistent locking order for merges; FK inserts are serialized with these locks.
cur.execute('SELECT * FROM customers WHERE id=ANY(%s) ORDER BY id FOR UPDATE',
(sorted([target_id] + source_ids),))
customers = [dict(r) for r in cur.fetchall()]
if len(customers) != len(source_ids) + 1:
raise HTTPException(404, 'En valgt kunde findes ikke længere.')
if any(c.get('merged_into_customer_id') or c.get('deleted_at') or c.get('is_active') is False for c in customers):
raise HTTPException(409, 'En valgt kunde er allerede samlet, slettet eller deaktiveret. Genindlæs listen.')
target = next(c for c in customers if c['id'] == target_id)
changes, differences = field_plan(customers, target)
refs = relations(cur)
# Lock relation tables so legacy references without FKs cannot change mid-merge.
tables = {r['table_name'] for r in refs} | {'customers'}
for table in ('bmc_workflows','bmc_workflow_versions','bmc_workflow_events','bmc_workflow_runtime'):
cur.execute('SELECT to_regclass(%s) AS name',(table,))
if cur.fetchone()['name']: tables.add(table)
for table in sorted(tables):
cur.execute(sql.SQL('LOCK TABLE {} IN SHARE ROW EXCLUSIVE MODE').format(sql.Identifier(table)))
state, movements, origins = [], [], []
for ref in refs:
cur.execute(sql.SQL('SELECT * FROM {} WHERE {}').format(sql.Identifier(ref['table_name']),where(ref)),
([target_id]+source_ids,))
rows = [dict(r) for r in cur.fetchall()]
if ref['table_name']=='ordre_drafts':
economic=changes.get('economic_customer_number',target.get('economic_customer_number'))
if any(r.get('exported_economic_customer_number') not in (None,'') and
str(r['exported_economic_customer_number']).strip()!=str(economic).strip() for r in rows):
raise HTTPException(409,'En ordre eller fakturakladde er eksporteret til et andet e-conomic-kundenummer. Afklar fakturarelationen før sammenlægning.')
if rows:
state.append({'table':ref['table_name'],'column':ref['column_name'],
'rows':sorted(rows,key=lambda r:json.dumps(r,sort_keys=True,default=str))})
cur.execute('''SELECT a.attname FROM pg_index i JOIN pg_attribute a
ON a.attrelid=i.indrelid AND a.attnum=ANY(i.indkey)
WHERE i.indrelid=%s::regclass AND i.indisprimary ORDER BY a.attname''',
(ref['table_name'],))
keys=[r['attname'] for r in cur.fetchall()]
if not keys:
raise HTTPException(409,f"Tabellen {ref['table_name']} mangler en entydig nøgle til flyttehistorikken.")
origins.extend({'table':ref['table_name'],'column':ref['column_name'],
'key':{k:r[k] for k in keys},'original_customer_id':r[ref['column_name']]}
for r in rows if r[ref['column_name']] in source_ids)
if ref['table_name'] in ('contact_companies','entity_tags'):
origins.append({'table':ref['table_name'],'original_memberships':rows})
count = sum(r[ref['column_name']] in source_ids for r in rows)
if count:
movements.append(dict(ref, count=count))
# bmc_workflows stores Hub IDs as text, so numeric FK discovery cannot see it.
cur.execute("SELECT to_regclass('bmc_workflows') AS name")
if cur.fetchone()['name']:
cur.execute('SELECT * FROM bmc_workflows WHERE customer_id=ANY(%s) ORDER BY id',
([str(i) for i in [target_id]+source_ids],))
rows=[dict(r) for r in cur.fetchall()]
state.append({'table':'bmc_workflows','rows':rows})
origins.extend({'table':'bmc_workflows','column':'customer_id','key':{'id':r['id']},
'original_customer_id':r['customer_id']} for r in rows
if r['customer_id'] in [str(i) for i in source_ids])
count=sum(r['customer_id'] in [str(i) for i in source_ids] for r in rows)
if count: movements.append(dict(table_name='bmc_workflows',column_name='customer_id',count=count))
cur.execute("SELECT to_regclass('bmc_workflow_runtime') AS name")
if cur.fetchone()['name']:
cur.execute('SELECT customer_ids FROM bmc_workflow_runtime')
if any(set(r['customer_ids'] or []) & set(source_ids) for r in cur.fetchall()):
raise HTTPException(409, 'En kildekunde er valgt i workflowmotorens kundeliste. Afklar aktiveringen før sammenlægning.')
if set(getattr(settings,'WORKFLOWS_CUSTOMER_IDS',[]) or []) & set(source_ids):
raise HTTPException(409,'En kildekunde er valgt i workflowkonfigurationen. Afklar aktiveringen før sammenlægning.')
for table in ('bmc_workflow_events','bmc_workflow_versions'):
cur.execute('SELECT to_regclass(%s) AS name',(table,))
if not cur.fetchone()['name']: continue
if table == 'bmc_workflow_events':
cur.execute("SELECT context,entity_type,entity_id FROM bmc_workflow_events WHERE status IN ('pending','processing','failed')")
blocked=any(mentions_customer(r['context'],source_ids) or
(r['entity_type']=='customer' and str(r['entity_id']) in {str(i) for i in source_ids}) for r in cur.fetchall())
else:
cur.execute('''SELECT v.definition FROM bmc_workflow_versions v JOIN bmc_workflows w
ON w.active_version_id=v.id WHERE w.enabled''')
blocked=any(mentions_customer(r['definition'],source_ids) for r in cur.fetchall())
if blocked:
raise HTTPException(409,'En kildekunde bruges i aktive workflowregler eller uafsluttede job. Afklar dem før sammenlægning.')
token=signature({'customers':customers,'state':state,'target':target_id,'sources':source_ids})
return customers, changes, differences, movements, token, origins
def move(cur, target, sources, customers, changes, movements):
"""Any unhandled unique constraint is a blocking conflict, never data loss."""
archived=[]
# Transfer unique external IDs only after saving original customer snapshots.
for field in IDENTITY_FIELDS:
if field in customers[0] and (field != 'cvr_number' or field in changes):
cur.execute(sql.SQL('UPDATE customers SET {}=NULL WHERE id=ANY(%s)').format(sql.Identifier(field)),(sources,))
for field,value in changes.items():
cur.execute(sql.SQL('UPDATE customers SET {}=%s WHERE id=%s').format(sql.Identifier(field)),(value,target))
if any(r['table_name']=='customer_relationships' for r in movements):
cur.execute('''DELETE FROM customer_relationships WHERE parent_customer_id=ANY(%s)
AND child_customer_id=ANY(%s) RETURNING *''',([target]+sources,[target]+sources))
archived.extend({'table':'customer_relationships','row':dict(r)} for r in cur.fetchall())
# Shared tag/contact memberships are consolidated; retain their complete old rows in audit.
for table,key,condition in [('contact_companies','contact_id',''),('entity_tags','tag_id'," AND entity_type='customer'")]:
if not any(r['table_name']==table for r in movements): continue
column='entity_id' if table=='entity_tags' else 'customer_id'
cur.execute(sql.SQL('SELECT * FROM {} WHERE {}=ANY(%s)'+condition+' ORDER BY {}').format(
sql.Identifier(table),sql.Identifier(column),sql.Identifier('id')),([target]+sources,))
rows=[dict(r) for r in cur.fetchall()];seen={}
for row in sorted(rows,key=lambda r:(r[column]!=target,r['id'])):
if row[key] not in seen:
seen[row[key]]=row;continue
kept=seen[row[key]]
archived.append({'table':table,'row':row})
if table=='contact_companies':
notes='\n'.join(dict.fromkeys(str(v) for v in (kept.get('notes'),row.get('notes')) if v))
roles=' / '.join(dict.fromkeys(str(v) for v in (kept.get('role'),row.get('role')) if v))
cur.execute('UPDATE contact_companies SET notes=%s,role=%s,is_primary=%s WHERE id=%s',
(notes or None,roles or None,bool(kept.get('is_primary') or row.get('is_primary')),kept['id']))
kept.update(notes=notes,role=roles,is_primary=bool(kept.get('is_primary') or row.get('is_primary')))
cur.execute(sql.SQL('DELETE FROM {} WHERE id=%s').format(sql.Identifier(table)),(row['id'],))
if table=='contact_companies' and any(r[column]==target and r.get('is_primary') for r in rows):
cur.execute('UPDATE contact_companies SET is_primary=FALSE WHERE customer_id=ANY(%s)',(sources,))
for ref in movements:
table,column=ref['table_name'],ref['column_name']
# Avoid introducing self-referential parent/billing relations on merged rows.
if table=='customers':
cur.execute(sql.SQL('UPDATE customers SET {}=NULL WHERE id=ANY(%s) AND {}=ANY(%s)').format(
sql.Identifier(column),sql.Identifier(column)),([target]+sources,sources))
value=str(target) if table=='bmc_workflows' else target
ids=[str(i) for i in sources] if table=='bmc_workflows' else sources
cur.execute(sql.SQL('UPDATE {} SET {}=%s WHERE {}').format(sql.Identifier(table),sql.Identifier(column),where(ref)),(value,ids))
cur.execute('UPDATE customers SET is_active=FALSE,merged_into_customer_id=%s,updated_at=now() WHERE id=ANY(%s)',(target,sources))
return archived
def run(selection, actor_id, preview_token=None):
sources=selections(selection.target_id,selection.source_ids)
try:
with transaction(commit=preview_token is not None) as cur:
customers,changes,differences,movements,token,origins=prepare(cur,selection.target_id,sources)
if preview_token is not None and preview_token != token:
raise HTTPException(409,'Data er ændret siden forhåndsvisningen. Vis sammenlægningen igen.')
archived=move(cur,selection.target_id,sources,customers,changes,movements)
if preview_token is not None:
cur.execute('''INSERT INTO customer_merge_history(target_customer_id,source_customer_ids,actor_id,snapshot,movements)
VALUES(%s,%s,%s,%s,%s) RETURNING id''',
(selection.target_id,sources,actor_id,Json({'customers':customers,'relation_origins':origins,'archived_memberships':archived},dumps=lambda v:json.dumps(v,default=str)),Json(movements)))
history_id=cur.fetchone()['id']
else: history_id=None
return {'target_id':selection.target_id,'source_ids':sources,'preview_token':token,
'customers':[{'id':c['id'],'name':c['name']} for c in customers],
'relations':[{'table':r['table_name'],'column':r['column_name'],'count':r['count']} for r in movements],
'filled_fields':list(changes),'kept_main_fields':differences,'history_id':history_id,
'external_systems_changed':False}
except psycopg2.IntegrityError as exc:
table=exc.diag.table_name or 'en relateret tabel'
raise HTTPException(409,f'Sammenlægningen har en konflikt i {table}. Ingen data er flyttet; afklar de overlappende relationer først.') from exc
except psycopg2.Error as exc:
logger.warning('Customer merge rolled back (%s)',exc.pgcode)
raise HTTPException(409,'Sammenlægningen kunne ikke gennemføres. Ingen data er flyttet. Genindlæs og prøv igen.') from exc
@router.post('/customers/merge/preview')
def preview(selection:MergeSelection,actor=Depends(require_permission('customers.edit'))):
return run(selection,actor['id'])
@router.post('/customers/merge')
def merge(selection:MergeConfirmation,actor=Depends(require_permission('customers.edit'))):
return run(selection,actor['id'],selection.preview_token)

View File

@ -6,11 +6,10 @@ Adapted from OmniSync for BMC Hub
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field 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
@ -23,10 +22,6 @@ logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Static merge routes precede the legacy /customers/{customer_id} routes.
from app.customers.backend.merge import router as customer_merge_router
router.include_router(customer_merge_router)
def _ensure_customer_supplier_tag(customer_id: int) -> None: def _ensure_customer_supplier_tag(customer_id: int) -> None:
"""Ensure linked customers are tagged as suppliers.""" """Ensure linked customers are tagged as suppliers."""
@ -116,9 +111,6 @@ 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
invoice_fee_exempt: bool = False
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False)
class CustomerCreate(CustomerBase): class CustomerCreate(CustomerBase):
@ -141,9 +133,6 @@ 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
invoice_fee_exempt: Optional[bool] = None
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False)
class EmailDomainCreate(BaseModel): class EmailDomainCreate(BaseModel):
@ -176,19 +165,6 @@ class ContactSyncRequest(BaseModel):
add_sync_ok_tag: Optional[bool] = False add_sync_ok_tag: Optional[bool] = False
class HouseOrganizationCreate(BaseModel):
name: str
location_id: Optional[int] = None
notes: Optional[str] = None
class HouseUserCreate(ContactCreate):
location_id: Optional[int] = None
external_customer_id: Optional[int] = None
external_company_name: Optional[str] = None
notes: Optional[str] = None
@router.get("/customers") @router.get("/customers")
async def list_customers( async def list_customers(
limit: int = Query(default=50, ge=1, le=1000), limit: int = Query(default=50, ge=1, le=1000),
@ -196,8 +172,7 @@ async def list_customers(
search: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None),
source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None source: Optional[str] = Query(default=None), # 'vtiger', 'local', or None
is_active: Optional[bool] = Query(default=None), is_active: Optional[bool] = Query(default=None),
vip: Optional[bool] = Query(default=None), vip: Optional[bool] = Query(default=None)
include_house_companies: bool = Query(default=False)
): ):
""" """
List customers with pagination and filtering List customers with pagination and filtering
@ -231,11 +206,6 @@ async def list_customers(
""" """
params = [] params = []
if not include_house_companies:
# Existing customers remain visible. New firms registered only as house
# users are intentionally hidden from the ordinary customer picker.
query += " AND COALESCE(c.show_in_customer_list, TRUE) = TRUE"
# Add search filter # Add search filter
if search: if search:
query += """ AND ( query += """ AND (
@ -327,81 +297,6 @@ async def list_customers(
} }
@router.get("/customers/duplicates")
async def find_customer_duplicates():
"""Read-only duplicate candidates grouped by normalized customer name."""
rows = execute_query(
"""
WITH normalized_customers AS (
SELECT
c.id,
c.name,
c.economic_customer_number,
c.cvr_number,
c.email,
c.is_active,
c.is_bmc_customer,
c.show_in_customer_list,
c.created_at,
LOWER(REGEXP_REPLACE(BTRIM(c.name), '[[:space:]]+', ' ', 'g')) AS duplicate_key
FROM customers c
WHERE COALESCE(c.is_active, TRUE) = TRUE
), grouped AS (
SELECT duplicate_key
FROM normalized_customers
GROUP BY duplicate_key
HAVING COUNT(*) > 1
)
SELECT
nc.duplicate_key,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', nc.id,
'name', nc.name,
'economic_customer_number', nc.economic_customer_number,
'cvr_number', nc.cvr_number,
'email', nc.email,
'is_bmc_customer', nc.is_bmc_customer,
'show_in_customer_list', nc.show_in_customer_list,
'created_at', nc.created_at,
'relation_counts', JSONB_BUILD_OBJECT(
'contacts', (SELECT COUNT(*) FROM contact_companies cc WHERE cc.customer_id = nc.id),
'sager', (SELECT COUNT(*) FROM sag_sager ss WHERE ss.customer_id = nc.id AND ss.deleted_at IS NULL),
'lokationer', (SELECT COUNT(*) FROM locations_locations ll WHERE ll.customer_id = nc.id),
'hardware', (SELECT COUNT(*) FROM hardware_assets ha WHERE ha.current_owner_customer_id = nc.id),
'abonnementer', (SELECT COUNT(*) FROM sag_subscriptions sub WHERE sub.customer_id = nc.id)
)
)
ORDER BY
(nc.economic_customer_number IS NOT NULL) DESC,
(nc.cvr_number IS NOT NULL) DESC,
nc.created_at ASC,
nc.id ASC
) AS candidates,
ARRAY_REMOVE(ARRAY_AGG(DISTINCT nc.economic_customer_number), NULL) AS economic_numbers
FROM normalized_customers nc
JOIN grouped g ON g.duplicate_key = nc.duplicate_key
GROUP BY nc.duplicate_key
ORDER BY nc.duplicate_key
"""
) or []
groups = []
for row in rows:
numbers = row.get("economic_numbers") or []
groups.append({
"key": row.get("duplicate_key"),
"candidates": row.get("candidates") or [],
"economic_numbers": numbers,
"economic_status": (
"same_economic" if len(numbers) == 1 else
"conflicting_economic" if len(numbers) > 1 else
"missing_economic"
),
})
return {"groups": groups, "total_groups": len(groups)}
@router.get("/customers/verify-linking") @router.get("/customers/verify-linking")
async def verify_customer_linking(): async def verify_customer_linking():
""" """
@ -685,76 +580,6 @@ async def get_customer(customer_id: int):
} }
def _email_address_pattern(address: str) -> str:
"""PostgreSQL regex matching one exact address inside common address lists."""
return rf"(^|[^a-z0-9._%+@-]){re.escape(address.lower())}([^a-z0-9._%+@-]|$)"
def _email_domain_pattern(domain: str) -> str:
"""PostgreSQL regex matching an address at exactly this domain."""
return rf"(^|[^a-z0-9._%+-])[a-z0-9._%+-]+@{re.escape(domain.lower())}([^a-z0-9.-]|$)"
@router.get("/customers/{customer_id}/emails")
async def get_customer_emails(
customer_id: int,
limit: int = Query(default=25, ge=1, le=100),
offset: int = Query(default=0, ge=0),
):
"""List emails safely associated with a customer without mutating links."""
customer = execute_query_single("SELECT id, email FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
contact_rows = execute_query(
"""
SELECT DISTINCT LOWER(TRIM(c.email)) AS email
FROM contacts c
JOIN contact_companies cc ON cc.contact_id = c.id
WHERE cc.customer_id = %s AND NULLIF(TRIM(c.email), '') IS NOT NULL
""",
(customer_id,),
) or []
domain_rows = execute_query(
"SELECT LOWER(TRIM(domain)) AS domain FROM email_domain_customer_mappings WHERE customer_id = %s",
(customer_id,),
) or []
addresses = {str(row["email"]).lower() for row in contact_rows if row.get("email")}
if customer.get("email"):
addresses.add(str(customer["email"]).strip().lower())
domains = {str(row["domain"]).lower() for row in domain_rows if row.get("domain")}
haystack = "LOWER(CONCAT_WS(' ', em.sender_email, em.recipient_email, em.cc))"
match_clauses = ["em.customer_id = %s"]
match_params: list[Any] = [customer_id]
for address in sorted(addresses):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_address_pattern(address))
for domain in sorted(domains):
match_clauses.append(f"{haystack} ~ %s")
match_params.append(_email_domain_pattern(domain))
predicate = f"em.deleted_at IS NULL AND ({' OR '.join(match_clauses)})"
total_row = execute_query_single(
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {predicate}", tuple(match_params)
)
rows = execute_query(
f"""
SELECT em.id, em.subject, em.sender_email, em.sender_name, em.recipient_email,
em.received_date, em.is_read, em.has_attachments, em.attachment_count,
em.linked_case_id, s.titel AS linked_case_title
FROM email_messages em
LEFT JOIN sag_sager s ON s.id = em.linked_case_id
WHERE {predicate}
ORDER BY em.received_date DESC NULLS LAST, em.id DESC
LIMIT %s OFFSET %s
""",
tuple([*match_params, limit, offset]),
) or []
return {"items": rows, "total": int((total_row or {}).get("count") or 0), "limit": limit, "offset": offset}
@router.get("/customers/{customer_id}/utility-company") @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):
@ -914,8 +739,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, supplier_service_enrolled, invoice_fee_exempt, invoice_fee_amount) country, website, is_active, invoice_email, mobile_phone)
VALUES (%s, %s, %s, %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)
RETURNING id""", RETURNING id""",
( (
customer.name, customer.name,
@ -930,10 +755,7 @@ 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,
customer.invoice_fee_exempt,
customer.invoice_fee_amount
) )
) )
@ -968,8 +790,6 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
params = [] params = []
update_dict = update.dict(exclude_unset=True) update_dict = update.dict(exclude_unset=True)
if update_dict.get("invoice_fee_exempt", False) is None:
raise HTTPException(status_code=422, detail="Fritagelse skal være ja eller nej")
fields_changed = list(update_dict.keys()) fields_changed = list(update_dict.keys())
for field, value in update_dict.items(): for field, value in update_dict.items():
@ -1448,163 +1268,21 @@ async def lock_customer_subscriptions(customer_id: int, lock_request: dict):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/house-users")
async def get_customer_house_users(customer_id: int):
"""Return external firms and people using this customer's house/location."""
customer = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
organizations = execute_query(
"""
SELECT cho.id, cho.location_id, cho.notes, cho.relationship_type,
ext.id AS external_customer_id, ext.name, ext.email, ext.phone,
loc.name AS location_name
FROM customer_house_organizations cho
JOIN customers ext ON ext.id = cho.external_customer_id
LEFT JOIN locations_locations loc ON loc.id = cho.location_id
WHERE cho.host_customer_id = %s AND cho.is_active = TRUE
ORDER BY ext.name, loc.name NULLS FIRST
""",
(customer_id,),
) or []
users = execute_query(
"""
SELECT clu.id, clu.location_id, clu.external_customer_id, clu.relation_type,
clu.role, clu.notes, c.id AS contact_id, c.first_name, c.last_name,
c.email, c.phone, c.mobile, ext.name AS external_company_name,
loc.name AS location_name
FROM contact_location_users clu
JOIN contacts c ON c.id = clu.contact_id
LEFT JOIN customers ext ON ext.id = clu.external_customer_id
LEFT JOIN locations_locations loc ON loc.id = clu.location_id
WHERE clu.host_customer_id = %s AND clu.is_active = TRUE AND c.is_active = TRUE
ORDER BY c.first_name, c.last_name
""",
(customer_id,),
) or []
return {"organizations": organizations, "users": users}
@router.post("/customers/{customer_id}/house-organizations")
async def create_customer_house_organization(customer_id: int, organization: HouseOrganizationCreate):
"""Register a firm that uses a customer's house without making it a BMC customer."""
host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not host:
raise HTTPException(status_code=404, detail="Customer not found")
name = organization.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Firmanavn er påkrævet")
external_customer_id = execute_insert(
"""
INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list)
VALUES (%s, TRUE, FALSE, FALSE)
RETURNING id
""",
(name,),
)
execute_update(
"""
INSERT INTO customer_house_organizations
(host_customer_id, external_customer_id, location_id, notes)
VALUES (%s, %s, %s, %s)
ON CONFLICT (host_customer_id, external_customer_id, location_id)
DO UPDATE SET is_active = TRUE, notes = COALESCE(EXCLUDED.notes, customer_house_organizations.notes), updated_at = NOW()
""",
(customer_id, external_customer_id, organization.location_id, organization.notes),
)
return execute_query_single("SELECT * FROM customers WHERE id = %s", (external_customer_id,))
@router.post("/customers/{customer_id}/house-users")
async def create_customer_house_user(customer_id: int, user: HouseUserCreate):
"""Create a person who uses this house, optionally under an external firm."""
host = execute_query_single("SELECT id FROM customers WHERE id = %s", (customer_id,))
if not host:
raise HTTPException(status_code=404, detail="Customer not found")
external_customer_id = user.external_customer_id
company_name = (user.external_company_name or "").strip()
if company_name and not external_customer_id:
external_customer_id = execute_insert(
"""
INSERT INTO customers (name, is_active, is_bmc_customer, show_in_customer_list)
VALUES (%s, TRUE, FALSE, FALSE)
RETURNING id
""",
(company_name,),
)
if external_customer_id:
external = execute_query_single("SELECT id FROM customers WHERE id = %s", (external_customer_id,))
if not external:
raise HTTPException(status_code=404, detail="Eksternt firma blev ikke fundet")
execute_update(
"""
INSERT INTO customer_house_organizations (host_customer_id, external_customer_id, location_id)
VALUES (%s, %s, %s)
ON CONFLICT (host_customer_id, external_customer_id, location_id)
DO UPDATE SET is_active = TRUE, updated_at = NOW()
""",
(customer_id, external_customer_id, user.location_id),
)
normalized_email = (user.email or "").strip().lower() or None
contact_id = None
if normalized_email:
existing = execute_query_single(
"SELECT id FROM contacts WHERE LOWER(COALESCE(email, '')) = %s ORDER BY id LIMIT 1",
(normalized_email,),
)
contact_id = existing.get("id") if existing else None
if not contact_id:
contact_id = execute_insert(
"""
INSERT INTO contacts (first_name, last_name, email, phone, mobile, title, department)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(user.first_name.strip(), user.last_name.strip(), normalized_email, user.phone, user.mobile, user.title, user.department),
)
if external_customer_id:
execute_update(
"""
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, FALSE, %s)
ON CONFLICT (contact_id, customer_id) DO NOTHING
""",
(contact_id, external_customer_id, user.role),
)
execute_update(
"""
INSERT INTO contact_location_users
(contact_id, host_customer_id, location_id, external_customer_id, relation_type, role, notes)
VALUES (%s, %s, %s, %s, 'house_user', %s, %s)
""",
(contact_id, customer_id, user.location_id, external_customer_id, user.role, user.notes),
)
return execute_query_single("SELECT * FROM contacts WHERE id = %s", (contact_id,))
@router.get("/customers/{customer_id}/contacts") @router.get("/customers/{customer_id}/contacts")
async def get_customer_contacts(customer_id: int): async def get_customer_contacts(customer_id: int):
"""Get direct contacts plus people registered as users of this customer's house.""" """Get all contacts for a specific customer"""
rows = execute_query(""" rows = execute_query("""
SELECT DISTINCT ON (c.id) SELECT
c.*, c.*,
COALESCE(cc.is_primary, FALSE) AS is_primary, cc.is_primary,
COALESCE(cc.role, clu.role) AS role, cc.role,
COALESCE(cc.notes, clu.notes) AS notes, cc.notes
ext.name AS external_company_name,
CASE WHEN clu.id IS NULL THEN FALSE ELSE TRUE END AS is_house_user
FROM contacts c FROM contacts c
LEFT JOIN contact_companies cc ON c.id = cc.contact_id AND cc.customer_id = %s JOIN contact_companies cc ON c.id = cc.contact_id
LEFT JOIN contact_location_users clu WHERE cc.customer_id = %s AND c.is_active = TRUE
ON clu.contact_id = c.id AND clu.host_customer_id = %s AND clu.is_active = TRUE ORDER BY cc.is_primary DESC, c.first_name, c.last_name
LEFT JOIN customers ext ON ext.id = clu.external_customer_id """, (customer_id,))
WHERE c.is_active = TRUE AND (cc.customer_id IS NOT NULL OR clu.id IS NOT NULL)
ORDER BY c.id, cc.is_primary DESC, clu.id DESC
""", (customer_id, customer_id))
return rows or [] return rows or []

File diff suppressed because it is too large Load Diff

View File

@ -106,15 +106,10 @@
</button> </button>
</div> </div>
</div> </div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="openDuplicateCustomersModal()">
<i class="bi bi-intersect me-2"></i>Find dubletter
</button>
<button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal"> <button type="button" id="openCreateCustomerBtn" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createCustomerModal">
<i class="bi bi-plus-lg me-2"></i>Opret Kunde <i class="bi bi-plus-lg me-2"></i>Opret Kunde
</button> </button>
</div> </div>
</div>
<div class="mb-4 d-flex gap-2"> <div class="mb-4 d-flex gap-2">
<button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button> <button class="filter-btn active" data-filter="all" type="button">Alle Kunder</button>
@ -155,18 +150,6 @@
</div> </div>
</div> </div>
<div class="modal fade" id="duplicateCustomersModal" tabindex="-1" aria-labelledby="duplicateCustomersModalLabel" 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" id="duplicateCustomersModalLabel">Mulige kundedubletter</h5><small class="text-muted">Vælg hovedkunde og kunder, der skal samles. Gennemse flytningen før du bekræfter.</small></div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body" id="duplicateCustomersContent"><div class="text-center py-5"><div class="spinner-border text-primary"></div></div></div>
</div>
</div>
</div>
<div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true"> <div class="modal fade" id="createCustomerModal" tabindex="-1" aria-labelledby="createCustomerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content"> <div class="modal-content">
@ -535,158 +518,6 @@ async function loadCustomers(page = 1) {
} }
} }
function duplicateEscape(value) {
return escapeHtml(value === null || value === undefined || value === '' ? '–' : value);
}
function duplicateRelationSummary(counts) {
const labels = { contacts: 'kontakter', sager: 'sager', lokationer: 'lokationer', hardware: 'hardware', abonnementer: 'abonnementer' };
return Object.entries(labels).map(([key, label]) => `${counts?.[key] || 0} ${label}`).join(' · ');
}
async function openDuplicateCustomersModal() {
customerMergePreviews.clear();
const modalElement = document.getElementById('duplicateCustomersModal');
const content = document.getElementById('duplicateCustomersContent');
bootstrap.Modal.getOrCreateInstance(modalElement).show();
content.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div><div class="text-muted mt-2">Finder dubletter…</div></div>';
try {
const response = await fetch('/api/v1/customers/duplicates', { credentials: 'include' });
const data = await response.json();
if (!response.ok) throw new Error(data.detail || 'Kunne ikke hente dubletter');
const groups = Array.isArray(data.groups) ? data.groups : [];
if (!groups.length) {
content.innerHTML = '<div class="alert alert-success mb-0"><i class="bi bi-check-circle me-2"></i>Ingen aktive kunder med samme navn blev fundet.</div>';
return;
}
const status = {
same_economic: ['success', 'Samme e-conomic-relation'],
conflicting_economic: ['danger', 'Forskellige e-conomic-relationer – kræver kontrol'],
missing_economic: ['warning', 'Mangler e-conomic-relation']
};
content.innerHTML = `<p class="text-muted small">${groups.length} grupper fundet. Vælg én hovedkunde og markér de kunder, der skal samles med den. Hovedkundens oplysninger bevares; tomme felter udfyldes, når værdien er entydig. Kildekunder deaktiveres. Eksterne systemer ændres ikke.</p>` + groups.map((group, groupIndex) => {
const [colour, label] = status[group.economic_status] || status.missing_economic;
const rows = group.candidates.map((customer, index) => `
<tr class="${index === 0 ? 'table-light' : ''}">
<td><input class="form-check-input" type="radio" name="merge-target-${groupIndex}" value="${Number(customer.id)}" ${index === 0 ? 'checked' : ''} aria-label="Behold som hovedkunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><input class="form-check-input merge-source" type="checkbox" value="${Number(customer.id)}" checked aria-label="Medtag kunde: ${duplicateEscape(customer.name)}" onchange="resetCustomerMerge(${groupIndex})"></td>
<td><a href="/customers/${customer.id}" class="fw-semibold">${duplicateEscape(customer.name)}</a>${index === 0 ? '<span class="badge text-bg-primary ms-2">Foreslået hovedkunde</span>' : ''}</td>
<td>${duplicateEscape(customer.economic_customer_number)}</td>
<td>${duplicateEscape(customer.cvr_number)}</td>
<td class="small text-muted">${duplicateRelationSummary(customer.relation_counts)}</td>
</tr>`).join('');
return `<section id="customer-merge-group-${groupIndex}" class="border rounded-3 mb-3 overflow-hidden">
<div class="d-flex justify-content-between gap-3 align-items-center px-3 py-2 bg-light"><strong>${duplicateEscape(group.candidates?.[0]?.name || group.key)}</strong><span class="badge text-bg-${colour}">${label}</span></div>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Hovedkunde</th><th>Medtag</th><th>Kunde</th><th>e-conomic</th><th>CVR</th><th>Data knyttet til posten</th></tr></thead><tbody>${rows}</tbody></table></div>
<div class="p-3 border-top"><button class="btn btn-sm btn-primary" type="button" onclick="previewCustomerMerge(${groupIndex}, this)">Vis sammenlægning</button><div class="merge-preview mt-2" aria-live="polite"></div></div>
</section>`;
}).join('');
} catch (error) {
content.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
}
}
const customerMergePreviews = new Map();
function resetCustomerMerge(index) {
customerMergePreviews.delete(index);
document.querySelector(`#customer-merge-group-${index} .merge-preview`).replaceChildren();
}
function customerMergeSelection(index) {
const section = document.getElementById(`customer-merge-group-${index}`);
const target_id = Number(section.querySelector('input[type=radio]:checked').value);
const source_ids = Array.from(section.querySelectorAll('.merge-source:checked'), input => Number(input.value)).filter(id => id !== target_id);
if (!source_ids.length) throw new Error('Markér mindst én anden kunde, der skal samles med hovedkunden.');
return { target_id, source_ids };
}
async function customerMergeRequest(path, body) {
const response = await fetch(`/api/v1/customers/merge${path}`, {
method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok) throw new Error(typeof data.detail === 'string' ? data.detail : 'Kunne ikke samle kunderne. Kontrollér dit valg.');
return data;
}
function customerMergeLabel(table) {
const labels = {contact_companies: 'Kontakttilknytninger', sag_sager: 'Sager', sag_kunder: 'Ekstra sagstilknytninger',
ordre_drafts: 'Ordrer og fakturakladder', supplier_invoices: 'Leverandørfakturaer', supplier_invoice_lines: 'Fakturalinjer',
entity_tags: 'Tags', alert_notes: 'Vigtige kundenoter', customer_notes: 'Kundenoter',
hardware_assets: 'Hardware', hardware_ownership_history: 'Hardwarehistorik',
tmodule_customers: 'Koblinger til tidsmodulet', tmodule_orders: 'Ordrekoblinger',
sag_subscriptions: 'Abonnementer', locations_locations: 'Lokationer',
email_messages: 'E-mails', conversations: 'Samtaler', tticket_tickets: 'Tickets',
quick_time_items: 'Tidsregistrering og planlægning', customers: 'Kundehenvisninger',
bmc_workflows: 'Kundeworkflows', customer_relationships: 'Kundeforbindelser',
bmc_office_subscriptions: 'Office-abonnementer', customer_fixed_price_agreements: 'Fastprisaftaler',
customer_vendor_links: 'Leverandørtilknytninger', email_domain_customer_mappings: 'E-maildomæner',
email_templates: 'E-mailskabeloner', hardware: 'Hardwaretilknytninger',
customer_house_organizations: 'Virksomheder i huset', contact_location_users: 'Brugere i huset',
economic_price_rules: 'Prisregler', pipeline_opportunities: 'Salgsmuligheder',
task_templates: 'Opgaveskabeloner', knowledge_articles: 'Vidensartikler',
locations_wall_outlets: 'Netværksudtag', links: 'Links og adgange',
link_access_log: 'Adgangshistorik', link_runbooks: 'Driftsvejledninger'};
if (labels[table]) return labels[table];
const families = {also: 'ALSO-tilknytninger', anydesk: 'AnyDesk', bmc_workflow: 'Workflowtilknytninger',
drift: 'Driftstilknytninger', internet_connections: 'Internetforbindelser', invoice_error_finder: 'Fakturakontrol',
migration_center: 'Importerede kundetilknytninger', nextcloud: 'Nextcloud', simply: 'Simply-abonnementer',
vtiger: 'vTiger-tilknytninger', webshop: 'Webshop', tticket: 'Tickets og klippekort',
workflow_tag: 'Taghistorik', fedex: 'FedEx-forsendelser', shipmondo: 'Shipmondo-forsendelser'};
return Object.entries(families).find(([prefix]) => table.startsWith(prefix))?.[1] || 'Øvrige kundetilknytninger';
}
function customerMergeField(field) {
return ({email:'E-mail', phone:'Telefon', address:'Adresse', email_domain:'E-maildomæne', city:'By',
postal_code:'Postnummer', country:'Land', website:'Website', invoice_email:'Faktura-e-mail',
mobile_phone:'Mobil', wiki_slug:'Wiki', economic_customer_number:'e-conomic-kundenummer',
vtiger_id:'vTiger-id', vtiger_account_no:'vTiger-kundenummer', cvr_number:'CVR', name:'Navn',
payment_terms_number:'Betalingsbetingelser', payment_terms_type:'Betalingstype', vat_zone_number:'Momszone',
customer_group_number:'Kundegruppe', barred:'Spærring', currency_code:'Valuta', ean:'EAN',
public_entry_number:'Offentligt kundenummer', subscriptions_locked:'Abonnementslås',
standard_margin_percent:'Avance', special_freight_price:'Fragtpris', supplier_service_enrolled:'Leverandørservice',
invoice_fee_amount:'Fakturagebyr', invoice_fee_exempt:'Gebyrfritagelse', standard_hourly_rate:'Timepris',
is_bmc_customer:'Kundestatus', show_in_customer_list:'Visning i kundelisten',is_active:'Aktiv status'})[field] || 'Kundeoplysning';
}
async function previewCustomerMerge(index, button) {
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
resetCustomerMerge(index);
button.disabled = true;
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input').forEach(input => input.disabled = true);
area.textContent = 'Kontrollerer alle relationer og mulige konflikter…';
try {
const selection = customerMergeSelection(index);
const data = await customerMergeRequest('/preview', selection);
customerMergePreviews.set(index, {...selection, preview_token: data.preview_token});
const main = data.customers.find(customer => customer.id === data.target_id);
const total = data.relations.reduce((sum, item) => sum + item.count, 0);
area.innerHTML = `<div class="alert alert-info mb-2"><strong>${data.source_ids.length} kunder samles under ${duplicateEscape(main.name)} (#${data.target_id})</strong><div>${total} relationer berøres. Sager, kontakter og lokale økonomirelationer følger med. Fakturaer i e-conomic forbliver på det eksisterende kundenummer.</div></div>` +
`<ul class="small mb-2">${data.relations.map(item => `<li>${duplicateEscape(customerMergeLabel(item.table))}: ${item.count}</li>`).join('')}</ul>` +
(data.filled_fields.length ? `<div class="small">Tomme felter udfyldes: ${duplicateEscape(data.filled_fields.map(customerMergeField).join(', '))}.</div>` : '') +
(data.kept_main_fields.length ? `<div class="small text-warning">Forskellige oplysninger: ${duplicateEscape(data.kept_main_fields.map(customerMergeField).join(', '))}. Hovedkundens værdier bevares; originalerne gemmes i historikken.</div>` : '') +
`<div class="small text-muted my-2">Kildekunder deaktiveres og knyttes til hovedkunden. Flytningen og de oprindelige kundeoplysninger gemmes i historikken.</div><button type="button" class="btn btn-sm btn-success" onclick="confirmCustomerMerge(${index}, this)">Bekræft sammenlægning af ${data.source_ids.length + 1} kunder</button>`;
} catch (error) {
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)}</div>`;
} finally {
button.disabled = false;
section.querySelectorAll('input').forEach(input => input.disabled = false);
}
}
async function confirmCustomerMerge(index, button) {
const preview = customerMergePreviews.get(index);
if (!preview) return;
button.disabled = true;
const area = document.querySelector(`#customer-merge-group-${index} .merge-preview`);
try {
const result = await customerMergeRequest('', preview);
customerMergePreviews.delete(index);
const section = document.getElementById(`customer-merge-group-${index}`);
section.querySelectorAll('input, button').forEach(input => input.disabled = true);
area.innerHTML = `<div class="alert alert-success mb-0">Kunderne er samlet. <a href="/customers/${result.target_id}">Åbn hovedkunden</a>. Historik #${result.history_id}.</div>`;
loadCustomers();
} catch (error) {
customerMergePreviews.delete(index);
area.innerHTML = `<div class="alert alert-danger mb-0">${duplicateEscape(error.message)} Vis sammenlægningen igen før et nyt forsøg.</div>`;
}
}
function toggleClearButton(value) { function toggleClearButton(value) {
document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value); document.getElementById('searchClearBtn')?.classList.toggle('d-none', !value);
} }

View File

@ -22,7 +22,7 @@
border: 1px solid rgba(0,0,0,0.08); border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px; border-radius: 10px;
padding: 0.75rem; padding: 0.75rem;
background: var(--theme-surface, white); background: white;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }

View File

@ -1,7 +1,6 @@
import json import json
import logging import logging
import io import io
import re
import time import time
from datetime import datetime from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
@ -26,7 +25,6 @@ class MissionCallEvent(BaseModel):
call_id: str = Field(..., min_length=1, max_length=128) call_id: str = Field(..., min_length=1, max_length=128)
caller_number: Optional[str] = None caller_number: Optional[str] = None
queue_name: Optional[str] = None queue_name: Optional[str] = None
recipient_extension: Optional[str] = None
timestamp: Optional[datetime] = None timestamp: Optional[datetime] = None
@ -159,7 +157,6 @@ def _event_from_query(request: Request) -> MissionCallEvent:
call_id=call_id, call_id=call_id,
caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"), caller_number=_first_query_param(request, "caller_number", "caller", "from", "number", "phone"),
queue_name=_first_query_param(request, "queue_name", "queue", "group", "line"), queue_name=_first_query_param(request, "queue_name", "queue", "group", "line"),
recipient_extension=_first_query_param(request, "recipient_extension", "extension", "active_user", "local"),
timestamp=_parse_query_timestamp(request), timestamp=_parse_query_timestamp(request),
) )
@ -170,24 +167,15 @@ def _get_webhook_token() -> str:
return db_token.strip() or env_token return db_token.strip() or env_token
def _validate_mission_webhook_token( def _validate_mission_webhook_token(request: Request, token: Optional[str] = None) -> None:
request: Request,
token: Optional[str] = None,
*,
allow_telefoni_token: bool = False,
) -> None:
configured = _get_webhook_token() configured = _get_webhook_token()
telefoni_token = (MissionService.get_setting_value("telefoni_shared_secret", "") or "").strip()
if not telefoni_token:
telefoni_token = (getattr(settings, "TELEFONI_SHARED_SECRET", "") or "").strip()
path = request.url.path path = request.url.path
accepted_tokens = {value for value in (configured, telefoni_token if allow_telefoni_token else "") if value} if not configured:
if not accepted_tokens:
logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path) logger.warning("❌ Mission webhook rejected path=%s reason=token_not_configured", path)
raise HTTPException(status_code=403, detail="Mission webhook token not configured") raise HTTPException(status_code=403, detail="Mission webhook token not configured")
candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token") candidate = token or request.headers.get("x-mission-token") or request.query_params.get("token")
if not candidate or candidate.strip() not in accepted_tokens: if not candidate or candidate.strip() != configured:
source = "query_or_arg" source = "query_or_arg"
if not token and request.headers.get("x-mission-token"): if not token and request.headers.get("x-mission-token"):
source = "header" source = "header"
@ -206,25 +194,6 @@ def _validate_mission_webhook_token(
raise HTTPException(status_code=403, detail="Forbidden") raise HTTPException(status_code=403, detail="Forbidden")
def _display_destination(event: MissionCallEvent) -> str:
"""Return the person/phone receiving a Yealink incoming call."""
raw = (event.recipient_extension or "").strip()
match = re.search(r"(?:sip:)?(\d+)", raw, flags=re.IGNORECASE)
extension = match.group(1) if match else ""
if extension:
user = execute_query_single(
"""SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), '')) AS name
FROM users
WHERE telefoni_aktiv = TRUE AND telefoni_extension = %s
ORDER BY user_id LIMIT 1""",
(extension,),
)
if user and user.get("name"):
return f"{user['name']} ({extension})"
return f"Telefon {extension}"
return (event.queue_name or "Ukendt telefon").strip()
def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]: def _normalize_uptime_payload(payload: MissionUptimeWebhook) -> Dict[str, Any]:
raw = dict(payload.payload or {}) raw = dict(payload.payload or {})
@ -720,7 +689,7 @@ async def mission_ws(websocket: WebSocket):
@router.post("/mission/webhook/telefoni/ringing") @router.post("/mission/webhook/telefoni/ringing")
async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)): async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, token: Optional[str] = Query(None)):
_validate_mission_webhook_token(request, token, allow_telefoni_token=True) _validate_mission_webhook_token(request, token)
logger.info( logger.info(
"☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s", "☎️ Mission webhook ringing call_id=%s caller=%s queue=%s method=%s",
@ -733,7 +702,6 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
timestamp = event.timestamp or datetime.utcnow() timestamp = event.timestamp or datetime.utcnow()
context = MissionService.resolve_contact_context(event.caller_number) context = MissionService.resolve_contact_context(event.caller_number)
queue_name = (event.queue_name or "Ukendt kø").strip() queue_name = (event.queue_name or "Ukendt kø").strip()
recipient_name = _display_destination(event)
execute_query( execute_query(
""" """
@ -752,15 +720,9 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
state = 'ringing', state = 'ringing',
ended_at = NULL, ended_at = NULL,
answered_at = NULL, answered_at = NULL,
started_at = EXCLUDED.started_at, started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at),
updated_at = NOW(), updated_at = NOW(),
last_payload = jsonb_set( last_payload = EXCLUDED.last_payload
EXCLUDED.last_payload,
'{ringing_phones}',
COALESCE(mission_call_state.last_payload->'ringing_phones', '[]'::jsonb)
|| COALESCE(EXCLUDED.last_payload->'ringing_phones', '[]'::jsonb),
true
)
""", """,
( (
event.call_id, event.call_id,
@ -770,7 +732,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
context.get("company_name"), context.get("company_name"),
context.get("customer_tag"), context.get("customer_tag"),
timestamp, timestamp,
json.dumps({**event.model_dump(mode="json"), "ringing_phones": [recipient_name]}), json.dumps(event.model_dump(mode="json")),
), ),
) )
@ -792,8 +754,6 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
"call_id": event.call_id, "call_id": event.call_id,
"queue_name": queue_name, "queue_name": queue_name,
"caller_number": event.caller_number, "caller_number": event.caller_number,
"recipient_extension": event.recipient_extension,
"ringing_phones": [recipient_name],
**context, **context,
"timestamp": timestamp, "timestamp": timestamp,
} }
@ -807,7 +767,7 @@ async def mission_telefoni_ringing(event: MissionCallEvent, request: Request, to
@router.get("/mission/webhook/telefoni/ringing") @router.get("/mission/webhook/telefoni/ringing")
async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)): async def mission_telefoni_ringing_get(request: Request, token: Optional[str] = Query(None)):
_validate_mission_webhook_token(request, token, allow_telefoni_token=True) _validate_mission_webhook_token(request, token)
# Allow token-only GET calls (no call payload) for phone webhook validation/ping. # Allow token-only GET calls (no call payload) for phone webhook validation/ping.
if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"): if not _first_query_param(request, "call_id", "callid", "id", "session_id", "uuid"):

View File

@ -26,10 +26,12 @@ class MissionService:
@staticmethod @staticmethod
def get_ring_timeout_seconds() -> int: def get_ring_timeout_seconds() -> int:
# A Yealink "Incoming call" event has no matching event when the raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180"
# caller abandons the call. Phones may not emit another event while try:
# ringing, so allow a full ring window before clearing a stale entry. value = int(raw)
return 120 except (TypeError, ValueError):
value = 180
return max(30, min(value, 3600))
@staticmethod @staticmethod
def expire_stale_ringing_calls() -> None: def expire_stale_ringing_calls() -> None:
@ -44,7 +46,7 @@ class MissionService:
ended_at = COALESCE(ended_at, NOW()), ended_at = COALESCE(ended_at, NOW()),
updated_at = NOW() updated_at = NOW()
WHERE state = 'ringing' WHERE state = 'ringing'
AND updated_at < (NOW() - make_interval(secs => %s)) AND started_at < (NOW() - make_interval(secs => %s))
""", """,
(timeout_seconds,), (timeout_seconds,),
) )
@ -211,10 +213,10 @@ class MissionService:
logger.warning("Mission table missing: mission_call_state (active calls unavailable)") logger.warning("Mission table missing: mission_call_state (active calls unavailable)")
return [] return []
MissionService.expire_stale_ringing_calls()
rows = execute_query( rows = execute_query(
""" """
SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at, SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at
COALESCE(last_payload->'ringing_phones', jsonb_build_array(queue_name)) AS ringing_phones
FROM mission_call_state FROM mission_call_state
WHERE state = 'ringing' WHERE state = 'ringing'
ORDER BY started_at DESC ORDER BY started_at DESC
@ -1474,7 +1476,7 @@ class MissionService:
return { return {
"kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default), "kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default),
"active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, None), "active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []),
"employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []), "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []),
"active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []),
"live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []),

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException
from app.core.database import execute_query, execute_query_single from app.core.database import execute_query, execute_query_single
from typing import Dict, Any, List from typing import Dict, Any, List
import logging import logging
@ -171,7 +171,7 @@ async def global_search(q: str):
@router.get("/search/sag", response_model=List[Dict[str, Any]]) @router.get("/search/sag", response_model=List[Dict[str, Any]])
async def search_sag(q: str, request: Request): async def search_sag(q: str):
""" """
Search for cases (sager) with customer information Search for cases (sager) with customer information
""" """
@ -181,9 +181,6 @@ async def search_sag(q: str, request: Request):
search_term = f"%{q}%" search_term = f"%{q}%"
try: try:
from app.core.case_visibility import hidden_case_ids
hidden=hidden_case_ids({'id':getattr(request.state,'user_id',None),
'is_superadmin':getattr(request.state,'case_superadmin',False)})
# Search cases with customer names # Search cases with customer names
sager = execute_query(""" sager = execute_query("""
SELECT SELECT
@ -236,10 +233,9 @@ async def search_sag(q: str, request: Request):
AND b.word ILIKE %s AND b.word ILIKE %s
) )
) )
AND NOT(s.id=ANY(%s))
ORDER BY s.created_at DESC ORDER BY s.created_at DESC
LIMIT 20 LIMIT 20
""", (search_term, search_term, search_term, search_term, search_term, search_term, search_term,list(hidden))) """, (search_term, search_term, search_term, search_term, search_term, search_term, search_term))
return sager or [] return sager or []
except Exception as e: except Exception as e:

View File

@ -37,7 +37,7 @@
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
text-decoration: none; text-decoration: none;
display: block; display: block;
background: var(--theme-surface, white); background: white;
border: none; border: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden; overflow: hidden;
@ -100,7 +100,7 @@
font-weight: 600; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--theme-muted, #64748b); color: #64748b;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;
} }
@ -124,22 +124,22 @@
.trend-badge.positive { .trend-badge.positive {
background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%); background: linear-gradient(135deg, #d4fc79 0%, #96e6a1 100%);
color: var(--theme-success-text, #166534); color: #166534;
} }
.trend-badge.negative { .trend-badge.negative {
background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%); background: linear-gradient(135deg, #ffeaa7 0%, #fab1a0 100%);
color: var(--theme-danger-text, #991b1b); color: #991b1b;
} }
.trend-badge.neutral { .trend-badge.neutral {
background: var(--theme-surface-soft, #e2e8f0); background: #e2e8f0;
color: var(--theme-muted, #64748b); color: #64748b;
} }
/* Content Cards */ /* Content Cards */
.content-card { .content-card {
background: var(--theme-surface, white); background: white;
border-radius: 20px; border-radius: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow: 0 2px 8px rgba(0,0,0,0.04);
overflow: hidden; overflow: hidden;
@ -281,7 +281,7 @@
} }
.activity-feed::-webkit-scrollbar-track { .activity-feed::-webkit-scrollbar-track {
background: var(--theme-surface-soft, #f1f5f9); background: #f1f5f9;
} }
.activity-feed::-webkit-scrollbar-thumb { .activity-feed::-webkit-scrollbar-thumb {

View File

@ -2956,10 +2956,8 @@
} }
const call = state.activeCalls[0]; const call = state.activeCalls[0];
const phones = [...new Set((Array.isArray(call.ringing_phones) ? call.ringing_phones : [call.queue_name]).filter(Boolean))];
title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`; title.textContent = `${call.queue_name || 'Ukendt kø'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = []; const parts = [];
if (phones.length) parts.push(`Ringer hos: ${phones.join(' · ')}`);
if (call.contact_name) parts.push(call.contact_name); if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name); if (call.company_name) parts.push(call.company_name);
parts.push(`Start: ${formatDate(call.started_at)}`); parts.push(`Start: ${formatDate(call.started_at)}`);
@ -2975,20 +2973,16 @@
return; return;
} }
list.innerHTML = state.activeCalls.map((call) => { list.innerHTML = state.activeCalls.map((call) => `
const phones = [...new Set((Array.isArray(call.ringing_phones) ? call.ringing_phones : [call.queue_name]).filter(Boolean))];
return `
<div class="mc-feed-item"> <div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div> <div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt kø')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta"> <div class="mc-feed-meta">
${phones.length ? `Ringer hos: ${escapeHtml(phones.join(' · '))} • ` : ''}
${escapeHtml(call.contact_name || 'Ukendt kontakt')} ${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''} ${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
${call.started_at ? ` • ${escapeHtml(formatDate(call.started_at))}` : ''} ${call.started_at ? ` • ${escapeHtml(formatDate(call.started_at))}` : ''}
</div> </div>
</div> </div>
`; `).join('');
}).join('');
} }
function renderCallHistory() { function renderCallHistory() {

View File

@ -54,7 +54,7 @@
height: 200px; height: 200px;
object-fit: cover; object-fit: cover;
border-radius: 8px; border-radius: 8px;
background: var(--theme-surface-soft, #f8f9fa); background: #f8f9fa;
} }
</style> </style>
{% endblock %} {% endblock %}

File diff suppressed because it is too large Load Diff

View File

@ -198,19 +198,19 @@
<div class="row g-3 mb-4" id="kpiRow"> <div class="row g-3 mb-4" id="kpiRow">
<div class="col-6 col-xl-2"> <div class="col-6 col-xl-2">
<div class="also-kpi"> <div class="also-kpi">
<div class="label">Omsætning · denne måned</div> <div class="label">Omsætning</div>
<div class="value" id="kpiRevenue">0 kr.</div> <div class="value" id="kpiRevenue">0 kr.</div>
</div> </div>
</div> </div>
<div class="col-6 col-xl-2"> <div class="col-6 col-xl-2">
<div class="also-kpi"> <div class="also-kpi">
<div class="label">Kost · denne måned</div> <div class="label">Kost</div>
<div class="value" id="kpiCost">0 kr.</div> <div class="value" id="kpiCost">0 kr.</div>
</div> </div>
</div> </div>
<div class="col-6 col-xl-2"> <div class="col-6 col-xl-2">
<div class="also-kpi"> <div class="also-kpi">
<div class="label">Margin · denne måned</div> <div class="label">Margin</div>
<div class="value" id="kpiMargin">0 kr.</div> <div class="value" id="kpiMargin">0 kr.</div>
</div> </div>
</div> </div>
@ -368,7 +368,7 @@
<div class="also-card card"> <div class="also-card card">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0">Faktureringslinjer</h5> <h5 class="mb-0">Queue Snapshot</h5>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<select class="form-select form-select-sm" id="queueStatusFilter" style="width: auto;"> <select class="form-select form-select-sm" id="queueStatusFilter" style="width: auto;">
<option value="">Alle statusser</option> <option value="">Alle statusser</option>
@ -395,7 +395,7 @@
<th>Beløb</th> <th>Beløb</th>
<th>Kost</th> <th>Kost</th>
<th>Margin</th> <th>Margin</th>
<th>Ordrekladde</th> <th>Draft</th>
</tr> </tr>
</thead> </thead>
<tbody id="queueBody"> <tbody id="queueBody">
@ -467,11 +467,7 @@
const statusBadge = (status) => { const statusBadge = (status) => {
const s = String(status || 'unknown').toLowerCase(); const s = String(status || 'unknown').toLowerCase();
const labels = { new: 'Ny', matching_customers: 'Matcher kunder', matching_products: 'Matcher produkter', return `<span class="status-pill status-${s}">${s.replaceAll('_', ' ')}</span>`;
ready_for_approval: 'Klar til godkendelse', approved: 'Godkendt', invoiced: 'Faktureret',
error: 'Fejl', completed: 'Afsluttet', processing: 'Behandles', partial: 'Delvist afsluttet',
failed: 'Mislykket', deleted: 'Slettet', unknown: 'Ukendt' };
return `<span class="status-pill status-${escapeHtml(s)}">${escapeHtml(labels[s] || s.replaceAll('_', ' '))}</span>`;
}; };
const formatCurrency = (value) => { const formatCurrency = (value) => {
@ -492,11 +488,6 @@
const fetchJson = async (url, options = {}) => { const fetchJson = async (url, options = {}) => {
const res = await fetch(url, options); const res = await fetch(url, options);
if (res.status === 401) {
const next = `${window.location.pathname}${window.location.search}`;
window.location.assign(`/login?next=${encodeURIComponent(next)}`);
throw new Error('Sessionen mangler eller er udløbet. Du sendes til login.');
}
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
let message = text || `HTTP ${res.status}`; let message = text || `HTTP ${res.status}`;
@ -525,16 +516,16 @@
`Linjer modtaget: ${formatNumber(payload.import_result?.received || 0)}`, `Linjer modtaget: ${formatNumber(payload.import_result?.received || 0)}`,
`Linjer indsat: ${formatNumber(payload.import_result?.inserted || 0)}`, `Linjer indsat: ${formatNumber(payload.import_result?.inserted || 0)}`,
`Dubletter: ${formatNumber(payload.import_result?.duplicates || 0)}`, `Dubletter: ${formatNumber(payload.import_result?.duplicates || 0)}`,
`Klar til godkendelse efter validering: ${formatNumber(payload.validation_result?.ready_for_approval || 0)}`, `Klar til approval efter validering: ${formatNumber(payload.validation_result?.ready_for_approval || 0)}`,
`Oprettede ordrekladder: ${formatNumber(payload.approval_result?.created_drafts || 0)}`, `Oprettede ordrekladder: ${formatNumber(payload.approval_result?.created_drafts || 0)}`,
`Godkendte linjer: ${formatNumber(payload.queue_totals?.approved_lines || 0)}`, `Approved linjer: ${formatNumber(payload.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(payload.queue_totals?.error_lines || 0)}` `Fejllinjer: ${formatNumber(payload.queue_totals?.error_lines || 0)}`
]; ];
el('uploadResult').textContent = lines.join('\n'); el('uploadResult').textContent = lines.join('\n');
}; };
const loadConfig = async () => { const loadConfig = async () => {
const config = await fetchJson('/api/v1/also/config', { cache: 'no-store' }); const config = await fetchJson('/api/v1/also/config');
const label = !config.enabled const label = !config.enabled
? 'ALSO deaktiveret' ? 'ALSO deaktiveret'
: config.read_only : config.read_only
@ -597,10 +588,10 @@
el('differencesBody').innerHTML = rows.length el('differencesBody').innerHTML = rows.length
? rows.map(row => ` ? rows.map(row => `
<tr> <tr>
<td>${escapeHtml(row.customer_name || '-')}</td> <td>${row.customer_name || '-'}</td>
<td> <td>
<div>${escapeHtml(row.product_name || '-')}</div> <div>${row.product_name || '-'}</div>
<div class="small text-muted mono">${escapeHtml(row.material_number || '-')}</div> <div class="small text-muted mono">${row.material_number || '-'}</div>
</td> </td>
<td>${formatNumber(row.previous_month_qty)}</td> <td>${formatNumber(row.previous_month_qty)}</td>
<td>${formatNumber(row.current_month_qty)}</td> <td>${formatNumber(row.current_month_qty)}</td>
@ -621,7 +612,7 @@
<tr data-job-row="${row.id}" class="${state.selectedJobId === row.id ? 'active-row' : ''}"> <tr data-job-row="${row.id}" class="${state.selectedJobId === row.id ? 'active-row' : ''}">
<td class="fw-semibold">#${row.id}</td> <td class="fw-semibold">#${row.id}</td>
<td>${statusBadge(row.status)}</td> <td>${statusBadge(row.status)}</td>
<td>${escapeHtml(row.file_name || '-')}</td> <td>${row.file_name || '-'}</td>
<td>${formatDateTime(row.imported_at)}</td> <td>${formatDateTime(row.imported_at)}</td>
<td>${formatNumber(row.line_count)}</td> <td>${formatNumber(row.line_count)}</td>
</tr> </tr>
@ -676,27 +667,10 @@
renderJobLines(); renderJobLines();
}; };
const renderCustomerSearchResults = (lineId) => {
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || '';
return results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateCustomerSearchResults = (lineId) => {
const host = document.querySelector(`[data-company-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderCustomerSearchResults(lineId);
};
const renderMapEditor = (line) => { const renderMapEditor = (line) => {
const lineId = Number(line.id); const lineId = Number(line.id);
const selected = state.lineCustomerSelections[lineId]; const selected = state.lineCustomerSelections[lineId];
const results = state.lineCustomerSearchResults[lineId] || [];
const query = state.lineCustomerSearchQueries[lineId] || ''; const query = state.lineCustomerSearchQueries[lineId] || '';
return ` return `
@ -720,8 +694,16 @@
` : ` ` : `
<div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div> <div class="small text-muted mb-2">Vælg kunde og gem mappingen for denne ALSO-company.</div>
`} `}
<div class="map-results mb-2" data-company-results-line-id="${lineId}"> <div class="map-results mb-2">
${renderCustomerSearchResults(lineId)} ${results.length
? results.map(customer => `
<button type="button" class="map-result-item js-company-result" data-line-id="${lineId}" data-customer-id="${customer.id}" data-customer-name="${escapeHtml(customer.name)}">
<strong>${escapeHtml(customer.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(customer.id)}${customer.cvr_nummer ? ` · CVR ${escapeHtml(customer.cvr_nummer)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen kunder fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}> <button class="btn btn-sm btn-primary js-company-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -733,27 +715,10 @@
`; `;
}; };
const renderProductSearchResults = (lineId) => {
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || '';
return results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`;
};
const updateProductSearchResults = (lineId) => {
const host = document.querySelector(`[data-product-results-line-id="${lineId}"]`);
if (host) host.innerHTML = renderProductSearchResults(lineId);
};
const renderProductMapEditor = (line) => { const renderProductMapEditor = (line) => {
const lineId = Number(line.id); const lineId = Number(line.id);
const selected = state.lineProductSelections[lineId]; const selected = state.lineProductSelections[lineId];
const results = state.lineProductSearchResults[lineId] || [];
const query = state.lineProductSearchQueries[lineId] || ''; const query = state.lineProductSearchQueries[lineId] || '';
return ` return `
@ -777,8 +742,16 @@
` : ` ` : `
<div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div> <div class="small text-muted mb-2">Vælg vare og gem mappingen for dette ALSO-produkt.</div>
`} `}
<div class="map-results mb-2" data-product-results-line-id="${lineId}"> <div class="map-results mb-2">
${renderProductSearchResults(lineId)} ${results.length
? results.map(product => `
<button type="button" class="map-result-item js-product-result" data-line-id="${lineId}" data-product-id="${product.id}" data-product-name="${escapeHtml(product.name)}">
<strong>${escapeHtml(product.name)}</strong>
<div class="small text-muted">ID ${escapeHtml(product.id)}${product.sku_internal ? ` · SKU ${escapeHtml(product.sku_internal)}` : ''}${product.supplier_sku ? ` · Leverandør-SKU ${escapeHtml(product.supplier_sku)}` : ''}</div>
</button>
`).join('')
: `<div class="small text-muted p-3">${query.trim().length >= 2 ? 'Ingen varer fundet.' : 'Skriv mindst 2 tegn for at søge.'}</div>`
}
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}> <button class="btn btn-sm btn-primary js-product-map-save" data-line-id="${lineId}" type="button" ${selected ? '' : 'disabled'}>
@ -846,14 +819,13 @@
state.lineCustomerSearchQueries[lineId] = query; state.lineCustomerSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) { if ((query || '').trim().length < 2) {
state.lineCustomerSearchResults[lineId] = []; state.lineCustomerSearchResults[lineId] = [];
updateCustomerSearchResults(lineId); renderJobLines();
return; return;
} }
const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`); const rows = await fetchJson(`/api/v1/search/customers?q=${encodeURIComponent(query.trim())}`);
if (state.lineCustomerSearchQueries[lineId] !== query) return;
state.lineCustomerSearchResults[lineId] = rows || []; state.lineCustomerSearchResults[lineId] = rows || [];
updateCustomerSearchResults(lineId); renderJobLines();
}; };
const saveCompanyMapping = async (lineId) => { const saveCompanyMapping = async (lineId) => {
@ -881,8 +853,8 @@
el('uploadResult').textContent = [ el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.customer_name} (#${result.customer_id})`, `Linje #${result.line_id} mappet til ${result.customer_name} (#${result.customer_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`, `Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`, `Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`, `Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`, `Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n'); ].join('\n');
@ -896,14 +868,13 @@
state.lineProductSearchQueries[lineId] = query; state.lineProductSearchQueries[lineId] = query;
if ((query || '').trim().length < 2) { if ((query || '').trim().length < 2) {
state.lineProductSearchResults[lineId] = []; state.lineProductSearchResults[lineId] = [];
updateProductSearchResults(lineId); renderJobLines();
return; return;
} }
const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`); const rows = await fetchJson(`/api/v1/search/products?q=${encodeURIComponent(query.trim())}`);
if (state.lineProductSearchQueries[lineId] !== query) return;
state.lineProductSearchResults[lineId] = rows || []; state.lineProductSearchResults[lineId] = rows || [];
updateProductSearchResults(lineId); renderJobLines();
}; };
const saveProductMapping = async (lineId) => { const saveProductMapping = async (lineId) => {
@ -931,8 +902,8 @@
el('uploadResult').textContent = [ el('uploadResult').textContent = [
`Linje #${result.line_id} mappet til ${result.product_name} (#${result.product_id})`, `Linje #${result.line_id} mappet til ${result.product_name} (#${result.product_id})`,
`Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`, `Berørte linjer i job: ${formatNumber(result.affected_line_count || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`, `Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`, `Approved linjer: ${formatNumber(result.approval_result?.approved_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`, `Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
].join('\n'); ].join('\n');
@ -971,8 +942,8 @@
`Oprettede company mappings: ${formatNumber(result.customer_mappings_created || 0)}`, `Oprettede company mappings: ${formatNumber(result.customer_mappings_created || 0)}`,
`Oprettede product mappings: ${formatNumber(result.product_mappings_created || 0)}`, `Oprettede product mappings: ${formatNumber(result.product_mappings_created || 0)}`,
`Oprettede varer: ${formatNumber(result.products_created || 0)}`, `Oprettede varer: ${formatNumber(result.products_created || 0)}`,
`Klar efter validering: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`, `Ready after validation: ${formatNumber(result.validation_result?.ready_for_approval || 0)}`,
`Godkendte linjer: ${formatNumber(result.queue_totals?.approved_lines || 0)}`, `Approved linjer: ${formatNumber(result.queue_totals?.approved_lines || 0)}`,
`Fejllinjer: ${formatNumber(result.queue_totals?.error_lines || 0)}`, `Fejllinjer: ${formatNumber(result.queue_totals?.error_lines || 0)}`,
`Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`, `Oprettede ordrekladder: ${formatNumber(result.approval_result?.created_drafts || 0)}`,
]; ];
@ -1069,14 +1040,14 @@
<tr> <tr>
<td class="mono">#${row.id}</td> <td class="mono">#${row.id}</td>
<td>${statusBadge(row.queue_status)}</td> <td>${statusBadge(row.queue_status)}</td>
<td>${escapeHtml(row.matched_customer_name || row.company || 'Ukendt kunde')}</td> <td>${row.matched_customer_id || row.company || '-'}</td>
<td> <td>
<div>${escapeHtml(row.product_name || '-')}</div> <div>${row.product_name || '-'}</div>
<div class="small text-muted mono">${escapeHtml(row.material_number || '-')}</div> <div class="small text-muted mono">${row.material_number || '-'}</div>
</td> </td>
<td>${formatCurrency(row.total_price ?? row.sales_price ?? 0)}</td> <td>${formatCurrency(row.total_price || 0)}</td>
<td>${formatCurrency(row.effective_cost_amount ?? row.cost_amount ?? 0)}</td> <td>${formatCurrency(row.effective_cost_amount || row.cost_amount || 0)}</td>
<td>${formatCurrency(row.effective_margin_amount ?? (Number(row.total_price ?? row.sales_price ?? 0) - Number(row.effective_cost_amount ?? row.cost_amount ?? 0)))}</td> <td>${formatCurrency(row.effective_margin_amount || (Number(row.total_price || 0) - Number(row.effective_cost_amount || row.cost_amount || 0)))}</td>
<td>${row.order_draft_id ? `<a href="/ordre/${row.order_draft_id}" class="mono">#${row.order_draft_id}</a>` : '-'}</td> <td>${row.order_draft_id ? `<a href="/ordre/${row.order_draft_id}" class="mono">#${row.order_draft_id}</a>` : '-'}</td>
</tr> </tr>
`).join('') `).join('')
@ -1086,7 +1057,7 @@
const uploadZip = async () => { const uploadZip = async () => {
const file = el('alsoZipFile').files?.[0]; const file = el('alsoZipFile').files?.[0];
if (!file) { if (!file) {
alert('Vælg en Excel-fil (.xlsx) eller ZIP-fil først.'); alert('Vælg en ZIP-fil først.');
return; return;
} }
const formData = new FormData(); const formData = new FormData();

File diff suppressed because one or more lines are too long

View File

@ -6,7 +6,7 @@ API endpoints for email viewing, classification, and rule management
import logging import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
import unicodedata import unicodedata
import re import re
@ -15,7 +15,6 @@ from app.core.config import settings
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.utils.safe_html import sanitize_safe_html from app.utils.safe_html import sanitize_safe_html
from app.services.email_processor_service import EmailProcessorService from app.services.email_processor_service import EmailProcessorService
from app.services.email_service import EmailService
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.ollama_service import ollama_service from app.services.ollama_service import ollama_service
from app.services.simple_classifier import simple_classifier from app.services.simple_classifier import simple_classifier
@ -721,80 +720,6 @@ async def search_customers(q: str = Query(..., min_length=1), limit: int = Query
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/{email_id}/sender-draft")
async def sender_creation_draft(email_id: int, use_ai: bool = False):
from app.services.email_sender_create import sender_suggestions, matching_companies
mail = execute_query_single("SELECT sender_name, sender_email, subject, body_text, body_html FROM email_messages WHERE id=%s AND deleted_at IS NULL", (email_id,))
if not mail:
raise HTTPException(404, 'Mailen blev ikke fundet')
draft = sender_suggestions(mail)
draft['ai_status'] = 'not_requested'
if use_ai:
import asyncio
from app.services.contact_mail_ai import extract_contact, extract_sender_company
results = await asyncio.gather(extract_contact(draft['parsed']), extract_sender_company(draft['parsed']), return_exceptions=True)
for field, result in zip(('contact', 'company'), results):
if isinstance(result, dict):
draft[field].update(result)
draft['ai_status'] = 'failed' if any(isinstance(result, Exception) for result in results) else 'completed'
# The actual sender address, rather than AI's interpretation, is the default contact email.
from email.utils import parseaddr
draft['contact']['email'] = parseaddr(str(mail.get('sender_email') or ''))[1].lower()
draft['companies'] = matching_companies(draft['company'], mail.get('sender_email'))
draft['contacts'] = execute_query("SELECT id, first_name, last_name, email FROM contacts WHERE LOWER(TRIM(email))=%s AND is_active=true ORDER BY id", (draft['contact'].get('email') or '',)) or []
draft.pop('parsed', None)
return draft
from app.services.email_sender_create import SenderCreateRequest
@router.post("/emails/{email_id}/create-sender")
async def create_sender_from_mail(email_id: int, payload: SenderCreateRequest, request: Request):
from app.services.email_sender_create import create_sender
return await create_sender(email_id, payload, getattr(request.state, 'user_id', None))
@router.get("/emails/purchase-options")
async def purchase_mail_options(q: str = Query('', max_length=200)):
like = '%' + q.strip() + '%'
return execute_query("""SELECT p.id, p.sag_id, p.description, p.quantity, p.external_ref,
p.status, s.titel AS case_title, c.name AS customer_name
FROM sag_salgsvarer p JOIN sag_sager s ON s.id = p.sag_id
LEFT JOIN customers c ON c.id = s.customer_id
WHERE p.type = 'purchase' AND p.status <> 'cancelled' AND s.deleted_at IS NULL
AND (p.description ILIKE %s OR COALESCE(p.external_ref, '') ILIKE %s
OR s.titel ILIKE %s OR COALESCE(c.name, '') ILIKE %s OR s.id::text ILIKE %s)
ORDER BY p.updated_at DESC, p.id DESC LIMIT 15""", (like,) * 5) or []
class PurchaseMailLinkRequest(BaseModel):
purchase_id: int = Field(gt=0)
kind: str
tracking_number: Optional[str] = Field(default=None, max_length=100)
@router.post("/emails/{email_id}/purchase-link")
async def link_purchase_email(email_id: int, payload: PurchaseMailLinkRequest, request: Request):
from app.services.email_purchase_link import link_purchase_mail
return link_purchase_mail(email_id, payload.purchase_id, payload.kind,
payload.tracking_number, getattr(request.state, "user_id", None))
@router.get("/emails/{email_id}/sender-identity")
async def get_sender_identity(email_id: int):
"""Read-only, all exact sender candidates for the simplified inbox."""
from app.services.email_sender_identity import find_sender_candidates
row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s AND deleted_at IS NULL",
(email_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Email not found")
return find_sender_candidates(row.get("sender_email"), execute_query, IGNORED_SENDER_DOMAINS)
@router.get("/emails/{email_id}/domain-customer-suggestion") @router.get("/emails/{email_id}/domain-customer-suggestion")
async def get_domain_customer_suggestion(email_id: int): async def get_domain_customer_suggestion(email_id: int):
"""Suggest customer based on sender domain for mails without known contact/customer.""" """Suggest customer based on sender domain for mails without known contact/customer."""
@ -1041,7 +966,6 @@ async def list_emails(
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
classification: Optional[str] = Query(None), classification: Optional[str] = Query(None),
folder: Optional[str] = Query(None), folder: Optional[str] = Query(None),
unread: Optional[bool] = Query(None),
q: Optional[str] = Query(None), q: Optional[str] = Query(None),
limit: int = Query(50, le=500), limit: int = Query(50, le=500),
offset: int = Query(0, ge=0) offset: int = Query(0, ge=0)
@ -1063,14 +987,10 @@ async def list_emails(
where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)") where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
params.append(folder) params.append(folder)
if unread is not None:
where_clauses.append("COALESCE(em.is_read, FALSE) = %s")
params.append(not unread)
if q: if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, '') ILIKE %s)") where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
search_term = f"%{q}%" search_term = f"%{q}%"
params.extend([search_term, search_term, search_term, search_term]) params.extend([search_term, search_term, search_term])
where_sql = " AND ".join(where_clauses) where_sql = " AND ".join(where_clauses)
@ -1105,59 +1025,6 @@ async def list_emails(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/count")
async def count_emails(
status: Optional[str] = Query(None),
classification: Optional[str] = Query(None),
folder: Optional[str] = Query(None),
unread: Optional[bool] = Query(None),
q: Optional[str] = Query(None),
):
"""Return the number of mails matching the list filters without changing list responses."""
try:
where_clauses = ["em.deleted_at IS NULL"]
params = []
if status:
where_clauses.append("em.status = %s")
params.append(status)
if classification:
where_clauses.append("em.classification = %s")
params.append(classification)
if folder:
where_clauses.append("LOWER(COALESCE(em.folder, '')) = LOWER(%s)")
params.append(folder)
if unread is not None:
where_clauses.append("COALESCE(em.is_read, FALSE) = %s")
params.append(not unread)
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s OR COALESCE(em.body_text, '') ILIKE %s)")
search_term = f"%{q}%"
params.extend([search_term, search_term, search_term, search_term])
row = execute_query_single(
f"SELECT COUNT(*) AS count FROM email_messages em WHERE {' AND '.join(where_clauses)}",
tuple(params),
) or {}
return {"count": int(row.get("count") or 0)}
except Exception as e:
logger.error(f"❌ Error counting emails: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/emails/queue-counts")
async def get_email_queue_counts():
"""Counts for the mutually exclusive inbox status tabs."""
return execute_query_single(
"""SELECT
COUNT(*) FILTER (WHERE status = 'new') AS active,
COUNT(*) FILTER (WHERE COALESCE(is_read, FALSE) = FALSE) AS unread,
COUNT(*) FILTER (WHERE status = 'awaiting_user_action') AS awaiting_user_action,
COUNT(*) FILTER (WHERE status = 'processed') AS processed,
COUNT(*) AS all
FROM email_messages
WHERE deleted_at IS NULL"""
) or {'active': 0, 'unread': 0, 'awaiting_user_action': 0, 'processed': 0, 'all': 0}
@router.get("/emails/{email_id:int}", response_model=EmailDetail) @router.get("/emails/{email_id:int}", response_model=EmailDetail)
async def get_email(email_id: int, request: Request): async def get_email(email_id: int, request: Request):
"""Get email detail by ID""" """Get email detail by ID"""
@ -1375,7 +1242,7 @@ async def link_email(email_id: int, payload: Dict):
execute_update(query, tuple(params)) execute_update(query, tuple(params))
customer_id = payload.get('customer_id') customer_id = payload.get('customer_id')
if customer_id and payload.get('remember_domain', True): if customer_id:
email_row = execute_query_single( email_row = execute_query_single(
"SELECT sender_email FROM email_messages WHERE id = %s", "SELECT sender_email FROM email_messages WHERE id = %s",
(email_id,), (email_id,),
@ -1664,6 +1531,12 @@ async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest
"message": "Leverandørfaktura sendt til behandling", "message": "Leverandørfaktura sendt til behandling",
} }
if not customer_id and not supplier_id:
raise HTTPException(
status_code=409,
detail="Fastslå kunde eller leverandør, før mailen arkiveres",
)
execute_update( execute_update(
""" """
UPDATE email_messages UPDATE email_messages
@ -1675,7 +1548,7 @@ async def run_email_quick_action(email_id: int, payload: EmailQuickActionRequest
return { return {
"success": True, "success": True,
"action": action, "action": action,
"message": "Mailen er arkiveret", "message": "Mailen er arkiveret under den fastsatte kunde eller leverandør",
} }
@ -2389,37 +2262,6 @@ async def reprocess_email(email_id: int):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/{email_id}/recover-attachments")
async def recover_email_attachments(email_id: int):
"""Recover a missing Graph attachment, then run the normal email workflow.
Intended for the old metadata-only attachment imports. It is safe to retry:
the normal invoice checksum and workflow safeguards remain in force.
"""
try:
recovery = await EmailService().recover_graph_attachments(email_id)
if not recovery.get("success"):
raise HTTPException(status_code=409, detail=recovery.get("reason", "Could not recover attachments"))
email_rows = execute_query(
"SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL", (email_id,)
)
if not email_rows:
raise HTTPException(status_code=404, detail="Email not found after recovery")
processing = await EmailProcessorService().process_single_email(email_rows[0])
return {
"success": True,
**recovery,
"workflows_executed": processing.get("workflows_executed", 0),
"awaiting_user_action": processing.get("awaiting_user_action", False),
}
except HTTPException:
raise
except Exception as e:
logger.exception("❌ Error recovering email attachments for %s", email_id)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/process") @router.post("/emails/process")
async def process_emails( async def process_emails(
limit: Optional[int] = Query(default=None, ge=1, le=500), limit: Optional[int] = Query(default=None, ge=1, le=500),
@ -2537,10 +2379,10 @@ async def upload_emails(files: List[UploadFile] = File(...)):
logger.info(f"💾 Saved to database with ID: {email_id}") logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity # Log activity
await activity_logger.log_fetched( activity_logger.log_fetched(
email_id=email_id, email_id=email_id,
source="manual_upload", source="manual_upload",
message_id=email_data.get("message_id", file.filename) metadata={"filename": file.filename}
) )
# Auto-classify # Auto-classify

View File

@ -190,14 +190,14 @@
font-weight: 500; font-weight: 500;
} }
.classification-invoice { background: var(--theme-success-bg, #d4edda); color: var(--theme-success-text, #155724); } .classification-invoice { background: #d4edda; color: #155724; }
.classification-order_confirmation { background: var(--theme-info-bg, #d1ecf1); color: var(--theme-info-text, #0c5460); } .classification-order_confirmation { background: #d1ecf1; color: #0c5460; }
.classification-freight_note { background: var(--theme-warning-bg, #fff3cd); color: var(--theme-warning-text, #856404); } .classification-freight_note { background: #fff3cd; color: #856404; }
.classification-time_confirmation { background: var(--theme-surface-soft, #e2e3e5); color: var(--theme-text, #383d41); } .classification-time_confirmation { background: #e2e3e5; color: #383d41; }
.classification-case_notification { background: var(--theme-primary-bg, #cce5ff); color: var(--theme-primary-text, #004085); } .classification-case_notification { background: #cce5ff; color: #004085; }
.classification-bankruptcy { background: var(--theme-danger-bg, #f8d7da); color: var(--theme-danger-text, #721c24); } .classification-bankruptcy { background: #f8d7da; color: #721c24; }
.classification-spam { background: #343a40; color: #fff; } .classification-spam { background: #343a40; color: #fff; }
.classification-general { background: var(--theme-surface-soft, #e9ecef); color: var(--theme-text, #495057); } .classification-general { background: #e9ecef; color: #495057; }
[data-bs-theme="dark"] .classification-badge { [data-bs-theme="dark"] .classification-badge {
opacity: 0.9; opacity: 0.9;
@ -359,13 +359,13 @@
.email-actions .btn-danger-soft { .email-actions .btn-danger-soft {
background: rgba(220, 53, 69, 0.08); background: rgba(220, 53, 69, 0.08);
color: var(--theme-danger-text, #a72a37); color: #a72a37;
border-color: rgba(220, 53, 69, 0.28); border-color: rgba(220, 53, 69, 0.28);
} }
.email-actions .btn-danger-soft:hover { .email-actions .btn-danger-soft:hover {
background: rgba(220, 53, 69, 0.14); background: rgba(220, 53, 69, 0.14);
color: var(--theme-danger-text, #8e1f2d); color: #8e1f2d;
} }
.email-body { .email-body {
@ -390,7 +390,7 @@
height: min(72vh, 980px); height: min(72vh, 980px);
border: 1px solid rgba(0,0,0,0.08); border: 1px solid rgba(0,0,0,0.08);
border-radius: 10px; border-radius: 10px;
background: var(--theme-surface, #ffffff); background: #ffffff;
display: block; display: block;
} }
@ -813,7 +813,7 @@
/* Workflow Step Builder */ /* Workflow Step Builder */
.workflow-step-item { .workflow-step-item {
background: var(--theme-surface, white); background: white;
border: 2px solid #e0e0e0; border: 2px solid #e0e0e0;
border-radius: 8px; border-radius: 8px;
margin-bottom: 0.75rem; margin-bottom: 0.75rem;

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from app.core.database import execute_query from app.core.database import execute_query
from app.core.config import settings
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -128,18 +127,6 @@ async def reports_dashboard(request: Request):
SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit
FROM customer_fixed_price_agreements FROM customer_fixed_price_agreements
""" """
if getattr(settings, 'WORKFLOWS_ENABLED', False):
available = execute_query("SELECT 1 FROM information_schema.columns WHERE table_schema='public' AND table_name='customer_fixed_price_agreements' AND column_name='monthly_price'")
if available:
stats_query = """
SELECT
COUNT(*) FILTER (WHERE status = 'active') as active_agreements,
COUNT(*) as total_agreements,
SUM(COALESCE(monthly_price, monthly_hours * hourly_rate)) as total_revenue,
SUM(COALESCE(monthly_price, monthly_hours * hourly_rate)
- monthly_hours * internal_cost_rate) as estimated_profit
FROM customer_fixed_price_agreements
"""
stats = execute_query(stats_query) stats = execute_query(stats_query)
# Get performance data from view # Get performance data from view

View File

@ -29,10 +29,6 @@ async def check_reminders():
try: try:
logger.info("🔔 Checking for pending reminders...") logger.info("🔔 Checking for pending reminders...")
from app.modules.sag.backend.reminders import process_task_list_rules
await process_task_list_rules()
from app.modules.planner.backend.checks import run_planning_checks
run_planning_checks()
# 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

@ -5,24 +5,17 @@ Creates ordre drafts and advances subscription periods
Runs daily at 04:00 Runs daily at 04:00
""" """
from app.services.invoice_fee_service import with_invoice_fee
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(subscription_ids: Optional[Sequence[int]] = None): async def process_subscriptions():
""" """
Main job: Process subscriptions due for invoicing. Main job: Process subscriptions due for invoicing.
- Find active subscriptions where next_invoice_date <= today - Find active subscriptions where next_invoice_date <= today
@ -33,13 +26,6 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
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 = """
@ -51,13 +37,8 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
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,
@ -86,43 +67,15 @@ async def process_subscriptions(subscription_ids: Optional[Sequence[int]] = None
), ),
'[]'::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, tuple(params)) subscriptions = execute_query(query)
if not subscriptions: if not subscriptions:
logger.info("✅ No subscriptions due for invoicing") logger.info("✅ No subscriptions due for invoicing")
@ -193,22 +146,6 @@ 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)
@ -218,13 +155,7 @@ 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')
first_full_period_start = sub.get('first_full_period_start') period_end = _calculate_next_period_start(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()
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:
@ -240,53 +171,9 @@ 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({ ordre_lines.append({
"product": { "product": {
"productNumber": product_number, "productNumber": product_number,
"description": f"{item.get('description', '')} – skæv periode {period_start} til {first_full_period_start} (30 dage)"
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": prorated_unit_price,
"totalNetAmount": round(float(item.get('quantity', 1)) * prorated_unit_price, 2),
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"proration_basis": "30_day",
"proration_factor": factor,
"period_from": str(period_start),
"period_to": str(first_full_period_start),
}
})
full_unit_price = float(item.get('unit_price', 0)) * advance_periods
ordre_lines.append({
"product": {
"productNumber": product_number,
"description": item.get('description', '') + (f" – {advance_periods} perioder" if advance_periods > 1 else '')
},
"quantity": float(item.get('quantity', 1)),
"unitNetPrice": full_unit_price,
"totalNetAmount": float(item.get('quantity', 1)) * full_unit_price,
"discountPercentage": 0,
"metadata": {
"subscription_id": subscription_id,
"asset_id": item.get('asset_id'),
"period_from": str(full_period_start),
"period_to": str(item.get('period_to') or period_end),
"advance_periods": advance_periods,
}
})
first_invoice_items = sub.get('first_invoice_items', [])
if isinstance(first_invoice_items, str):
first_invoice_items = json.loads(first_invoice_items)
for item in first_invoice_items:
ordre_lines.append({
"product": {
"productNumber": str(item.get('product_id') or 'ENGANG'),
"description": item.get('description', '') "description": item.get('description', '')
}, },
"quantity": float(item.get('quantity', 1)), "quantity": float(item.get('quantity', 1)),
@ -295,8 +182,9 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
"discountPercentage": 0, "discountPercentage": 0,
"metadata": { "metadata": {
"subscription_id": subscription_id, "subscription_id": subscription_id,
"first_invoice_item_id": item.get('id'), "asset_id": item.get('asset_id'),
"one_time": True, "period_from": str(item.get('period_from') or period_start),
"period_to": str(item.get('period_to') or period_end),
} }
}) })
@ -312,7 +200,6 @@ async def _process_subscription_group(subscriptions: list[dict]) -> int:
f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}" f"Subscription IDs: {', '.join(str(sid) for sid in source_subscription_ids)}"
) )
ordre_lines = with_invoice_fee(ordre_lines, customer_id, cursor=cursor, native=True)
insert_query = """ insert_query = """
INSERT INTO ordre_drafts ( INSERT INTO ordre_drafts (
title, title,
@ -350,22 +237,6 @@ 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,
@ -375,19 +246,8 @@ 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')
first_full_period_start = sub.get('first_full_period_start') new_period_start = _calculate_next_period_start(current_period_start, sub['billing_interval'])
if isinstance(first_full_period_start, str): new_next_invoice_date = _calculate_next_period_start(new_period_start, sub['billing_interval'])
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

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

View File

@ -3,7 +3,7 @@ Pydantic Models and Schemas
""" """
from enum import Enum from enum import Enum
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict
from typing import Optional, List from typing import Optional, List
from datetime import datetime, date from datetime import datetime, date
@ -39,8 +39,7 @@ class CustomerUpdate(BaseModel):
standard_hourly_rate: Optional[float] = None standard_hourly_rate: Optional[float] = None
special_freight_price: Optional[float] = None special_freight_price: Optional[float] = None
supplier_service_enrolled: Optional[bool] = None supplier_service_enrolled: Optional[bool] = None
invoice_fee_amount: Optional[float] = Field(None, ge=0, allow_inf_nan=False) invoice_fee_amount: Optional[float] = None
invoice_fee_exempt: Optional[bool] = None
class Customer(CustomerBase): class Customer(CustomerBase):
@ -90,7 +89,6 @@ class VendorBase(BaseModel):
priority: Optional[int] = 100 priority: Optional[int] = 100
notes: Optional[str] = None notes: Optional[str] = None
is_active: bool = True is_active: bool = True
is_internet_provider: bool = False
class VendorCreate(VendorBase): class VendorCreate(VendorBase):
@ -105,16 +103,10 @@ class VendorUpdate(BaseModel):
domain: Optional[str] = None domain: Optional[str] = None
email: Optional[str] = None email: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
address: Optional[str] = None
postal_code: Optional[str] = None
city: Optional[str] = None
website: Optional[str] = None
economic_supplier_number: Optional[int] = None
contact_person: Optional[str] = None contact_person: Optional[str] = None
category: Optional[str] = None category: Optional[str] = None
notes: Optional[str] = None notes: Optional[str] = None
is_active: Optional[bool] = None is_active: Optional[bool] = None
is_internet_provider: Optional[bool] = None
class Vendor(VendorBase): class Vendor(VendorBase):
@ -167,15 +159,6 @@ class SolutionBase(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None # Support, Drift, Konsulent, etc. solution_type: Optional[str] = None # Support, Drift, Konsulent, etc.
result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst result: Optional[str] = None # Løst, Delvist, Workaround, Ej løst
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: str = "internal"
approval_status: str = "draft"
is_final: bool = True
tags: list[str] = Field(default_factory=list)
products: list[str] = Field(default_factory=list)
class SolutionCreate(SolutionBase): class SolutionCreate(SolutionBase):
"""Schema for creating a solution""" """Schema for creating a solution"""
@ -188,16 +171,6 @@ class SolutionUpdate(BaseModel):
description: Optional[str] = None description: Optional[str] = None
solution_type: Optional[str] = None solution_type: Optional[str] = None
result: Optional[str] = None result: Optional[str] = None
problem: Optional[str] = None
root_cause: Optional[str] = None
investigation: Optional[str] = None
workaround: Optional[str] = None
visibility: Optional[str] = None
approval_status: Optional[str] = None
is_final: Optional[bool] = None
tags: Optional[list[str]] = None
products: Optional[list[str]] = None
change_note: Optional[str] = None
class Solution(SolutionBase): class Solution(SolutionBase):
"""Full solution schema""" """Full solution schema"""
@ -206,9 +179,6 @@ class Solution(SolutionBase):
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
updated_by_user_id: Optional[int] = None
approved_by_user_id: Optional[int] = None
approved_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -269,11 +239,6 @@ class AnyDeskSession(BaseModel):
created_at: str created_at: str
updated_at: Optional[str] = None updated_at: Optional[str] = None
@field_validator('started_at', 'ended_at', 'created_at', 'updated_at', mode='before')
@classmethod
def serialize_database_timestamps(cls, value):
return value.isoformat() if isinstance(value, datetime) else value
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
@ -331,8 +296,6 @@ class TodoStep(TodoStepBase):
is_next: bool = False is_next: bool = False
created_by_user_id: Optional[int] = None created_by_user_id: Optional[int] = None
created_by_name: Optional[str] = None created_by_name: Optional[str] = None
assigned_to_user_id: Optional[int] = None
assigned_to_name: Optional[str] = None
created_at: datetime created_at: datetime
completed_by_user_id: Optional[int] = None completed_by_user_id: Optional[int] = None
completed_by_name: Optional[str] = None completed_by_name: Optional[str] = None

View File

@ -1,96 +0,0 @@
"""Keep ALSO's approved period amount authoritative on order lines."""
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from datetime import datetime, timedelta
import calendar
import re
from fastapi import HTTPException
def _number(value):
try:
result = Decimal(str(value))
if not result.is_finite():
raise InvalidOperation
return result
except (InvalidOperation, TypeError, ValueError):
raise HTTPException(409, 'ALSO-linjen mangler et gyldigt godkendt periodebeløb')
def period_amount_line(line):
"""Return a copy; never rewrite saved history or normalize other sources."""
if line.get('source_type') != 'also_cloud':
return dict(line)
amount = _number(line.get('amount'))
if amount < 0 or amount != amount.quantize(Decimal('.01'), rounding=ROUND_HALF_UP):
raise HTTPException(409, 'ALSO-periodebeløbet kræver afklaring før eksport')
if _number(line.get('discount_percentage', 0)) != 0:
raise HTTPException(409, 'ALSO-periodebeløbet må ikke kombineres med linjerabat')
result = dict(line)
meta = dict(line.get('meta') or {})
if meta.get('also_period_amount_version') != 1:
quantity = _number(line.get('quantity'))
monthly_price = _number(line.get('unit_price'))
if quantity <= 0 or monthly_price < 0:
raise HTTPException(409, 'ALSO-linjen mangler gyldigt licensantal eller månedspris')
meta.update(also_license_quantity=str(quantity), also_monthly_unit_price=str(monthly_price),
also_period_amount_version=1)
quantity_text = format(quantity, 'f').rstrip('0').rstrip('.') if '.' in str(quantity) else str(quantity)
price_text = f'{monthly_price:.2f}'.replace('.', ',')
license_text = 'licens' if quantity == 1 else 'licenser'
description = result.get('description') or 'Cloud abonnement'
title, separator, rest = description.partition('\n')
currency_text = 'kr.' if result.get('currency', 'DKK') == 'DKK' else result['currency']
explanation = f'{quantity_text} {license_text} á {price_text} {currency_text}/måned.'
interval = meta.get('billing_period')
period_text = _period_text(interval)
result['description'] = title + '\n' + explanation + (
' ' + period_text if period_text else ('\n' + rest if separator else ''))
result['description'] = re.sub(r'\s*Reference:\s*[^\n]*', '', result.get('description', ''), flags=re.IGNORECASE).strip()
result.update(quantity=1.0, unit_price=float(amount), amount=float(amount),
discount_percentage=0.0, meta=meta)
return result
def _period_text(interval):
"""ALSO Marketplace intervals have an exclusive end date."""
match = re.fullmatch(r'\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*-\s*(\d{2})[./-](\d{2})[./-](\d{4})\s*', str(interval or ''))
if not match:
return None
try:
day, month, year, end_day, end_month, end_year = map(int, match.groups())
start = datetime(year, month, day).date()
end = datetime(end_year, end_month, end_day).date()
except ValueError:
raise HTTPException(409, 'ALSO-linjen har en ugyldig periode')
if end <= start:
raise HTTPException(409, 'ALSO-periodens slutdato skal være efter startdatoen')
last = end - timedelta(days=1)
start_text = start.strftime('%d.%m.') if start.year == last.year else start.strftime('%d.%m.%Y')
text = f'Periode: {start_text}–{last:%d.%m.%Y}, {(end-start).days}'
if (start.year, start.month) == (last.year, last.month):
text += f' af {calendar.monthrange(start.year, start.month)[1]}'
return text + ' dage.'
def check_period_amounts(lines, saved_lines):
"""Reject stale browsers and altered amounts before either export backend runs."""
saved = {str(line.get('line_key')): line for line in saved_lines}
seen = set()
for line in lines:
old = saved.get(str(line.get('line_key')))
if line.get('source_type') != 'also_cloud' and (not old or old.get('source_type') != 'also_cloud'):
continue
if not old or old.get('source_type') != 'also_cloud' or line.get('source_type') != 'also_cloud':
raise HTTPException(409, 'ALSO-eksport kræver den gemte godkendte ordrekladde')
key = str(line.get('line_key'))
if key in seen or line.get('source_id') != old.get('source_id'):
raise HTTPException(409, 'ALSO-kildelinjen mangler entydig identitet eller er valgt flere gange')
seen.add(key)
expected = period_amount_line(old)
if (line.get('product_id') != old.get('product_id')
or line.get('description') != expected.get('description')
or line.get('currency', 'DKK') != old.get('currency', 'DKK')
or any(_number(line.get(key, 0)) != _number(expected[key])
for key in ('quantity', 'unit_price', 'discount_percentage'))):
raise HTTPException(409, 'ALSO-linjen matcher ikke det godkendte periodebeløb. Genindlæs kladden og kontrollér linjerne før eksport.')

View File

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

View File

@ -1,4 +1,3 @@
from app.services.invoice_fee_service import with_invoice_fee
import hashlib import hashlib
import json import json
import logging import logging
@ -17,7 +16,6 @@ from fastapi import HTTPException
import httpx import httpx
from app.core.config import settings from app.core.config import settings
from app.modules.also.backend.billing import period_amount_line
from app.core.database import execute_query, execute_query_single, execute_update, get_db_connection, release_db_connection, table_has_column from app.core.database import execute_query, execute_query_single, execute_update, get_db_connection, release_db_connection, table_has_column
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
from app.modules.also.models.schemas import ( from app.modules.also.models.schemas import (
@ -277,21 +275,6 @@ def _extract_period_start(value: Any) -> Optional[str]:
return _parse_date_candidate(match.group(1)) return _parse_date_candidate(match.group(1))
def _format_charge_period(value: Any) -> Optional[str]:
"""Return an explicit ALSO service period without inferring a missing end date."""
text = _normalized_text(value)
if not text:
return None
match = re.match(r"\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*-\s*(\d{2}[./-]\d{2}[./-]\d{4})\s*$", text)
if not match:
return None
start = _parse_date_candidate(match.group(1))
end = _parse_date_candidate(match.group(2))
if not start or not end:
return None
return f"{datetime.strptime(start, '%Y-%m-%d').strftime('%d.%m.%Y')} - {datetime.strptime(end, '%Y-%m-%d').strftime('%d.%m.%Y')}"
def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool: def _is_zero_value_tenant_line(line: Dict[str, Any]) -> bool:
product_name = _normalize_match_key(line.get("product_name")) product_name = _normalize_match_key(line.get("product_name"))
if "microsoftorganizationtenant" not in product_name: if "microsoftorganizationtenant" not in product_name:
@ -336,8 +319,7 @@ class AlsoService:
"total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"], "total_price": ["total_price", "amount", "line_total", "net_amount", "subtotal", "extended_price", "total", "sales_price_total", "sales_price", "total_amount", "charge"],
"currency": ["currency", "valuta"], "currency": ["currency", "valuta"],
"billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"], "billing_start": ["billing_start", "period_start", "start_date", "billing_start_date", "invoice_date", "service_period_start", "billing_month", "period_from", "billing_from", "valid_from", "from_date", "start_date", "startdate"],
# The CSV's Actual Charge Interval is the authoritative service period. "charge_interval": ["charge_interval", "actual_charge_interval", "actualchargeinterval", "term", "commitment", "period_type", "contract_term"],
"charge_interval": ["actual_charge_interval", "actualchargeinterval", "charge_interval", "term", "commitment", "period_type", "contract_term"],
"billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"], "billing_interval": ["billing_interval", "interval", "billing_cycle", "frequency", "charge_frequency"],
"billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"], "billable_parameters": ["billable_parameters", "billableparameters", "quantity", "qty", "udrc_value", "licenses", "seats", "users", "units", "antal", "license_count", "unit_count", "count"],
"source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"], "source_line_ref": ["source_line_ref", "line_id", "line_ref", "id", "reference"],
@ -1705,11 +1687,9 @@ class AlsoService:
f""" f"""
SELECT SELECT
l.*, l.*,
c.name AS matched_customer_name,
{ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount, {ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount,
COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount
FROM also_import_lines l FROM also_import_lines l
LEFT JOIN customers c ON c.id = l.matched_customer_id
LEFT JOIN products p ON p.id = l.matched_product_id LEFT JOIN products p ON p.id = l.matched_product_id
WHERE l.queue_status = %s WHERE l.queue_status = %s
ORDER BY l.id DESC ORDER BY l.id DESC
@ -1722,11 +1702,9 @@ class AlsoService:
f""" f"""
SELECT SELECT
l.*, l.*,
c.name AS matched_customer_name,
{ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount, {ALSO_EFFECTIVE_COST_SQL} AS effective_cost_amount,
COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount COALESCE(l.total_price, l.sales_price, 0) - ({ALSO_EFFECTIVE_COST_SQL}) AS effective_margin_amount
FROM also_import_lines l FROM also_import_lines l
LEFT JOIN customers c ON c.id = l.matched_customer_id
LEFT JOIN products p ON p.id = l.matched_product_id LEFT JOIN products p ON p.id = l.matched_product_id
ORDER BY l.id DESC ORDER BY l.id DESC
LIMIT %s LIMIT %s
@ -2025,37 +2003,24 @@ class AlsoService:
draft_lines: List[Dict[str, Any]] = [] draft_lines: List[Dict[str, Any]] = []
for line in group_lines: for line in group_lines:
quantity = line.get("billable_parameters") quantity = _to_decimal(line.get("billable_parameters"), Decimal("1"))
unit_price = line.get("unit_price") if quantity <= 0:
raw_line = line.get('raw_line_json') or {} quantity = Decimal("1")
if isinstance(raw_line, str): unit_price = _to_decimal(line.get("unit_price"), _to_decimal(line.get("sales_price"), Decimal("0")))
raw_line = json.loads(raw_line) amount = _to_decimal(line.get("total_price"), default=(quantity * unit_price))
raw_fields = {_normalize_header(key): value for key, value in raw_line.items()}
if raw_fields.get('charge') not in (None, ''):
# Charge is the period total, not a monthly price per licence.
unit_price = next((
_parse_decimal_candidate(raw_fields[key])
for key in self.HEADER_ALIASES['unit_price']
if key != 'charge' and raw_fields.get(key) not in (None, '')
), None)
amount = line.get("total_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(
period_amount_line({ {
"line_key": f"also:{line['id']}", "line_key": f"also:{line['id']}",
"source_type": "also_cloud", "source_type": "also_cloud",
"source_id": int(line["id"]), "source_id": int(line["id"]),
"reference_id": int(line["import_job_id"]), "reference_id": int(line["import_job_id"]),
"description": description, "description": line.get("product_name") or line.get("matched_product_name") or "Cloud abonnement",
"quantity": quantity, "quantity": float(quantity),
"unit": "stk", "unit": "stk",
"unit_price": unit_price, "unit_price": float(unit_price),
"discount_percentage": 0.0, "discount_percentage": 0.0,
"amount": amount, "amount": float(amount),
"currency": currency, "currency": currency,
"status": "approved", "status": "approved",
"line_date": str(line.get("billing_start")) if line.get("billing_start") else None, "line_date": str(line.get("billing_start")) if line.get("billing_start") else None,
@ -2067,9 +2032,8 @@ 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,
}, },
}) }
) )
aggregate_key = f"also-cloud-{customer_id}-{period_key}" aggregate_key = f"also-cloud-{customer_id}-{period_key}"
@ -2103,13 +2067,12 @@ class AlsoService:
] ]
execute_query( execute_query(
"""UPDATE ordre_drafts """UPDATE ordre_drafts
SET title = 'Abonnementer', lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP SET lines_json = %s::jsonb, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""", WHERE id = %s""",
(_json_dumps(merged_lines), int(draft["id"])), (_json_dumps(merged_lines), int(draft["id"])),
) )
draft_id = int(draft["id"]) draft_id = int(draft["id"])
else: else:
draft_lines = with_invoice_fee(draft_lines, customer_id)
draft = execute_query_single( draft = execute_query_single(
""" """
INSERT INTO ordre_drafts ( INSERT INTO ordre_drafts (
@ -2127,10 +2090,10 @@ class AlsoService:
RETURNING id RETURNING id
""", """,
( (
"Abonnementer", f"ALSO Cloud {customer_name} - {period_key}",
customer_id, customer_id,
_json_dumps(draft_lines), _json_dumps(draft_lines),
None, "Genereret fra ALSO Cloud Billing approval",
1, 1,
approved_by_user_id, approved_by_user_id,
_json_dumps({"source": "also_cloud_billing"}), _json_dumps({"source": "also_cloud_billing"}),

View File

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

View File

@ -1,8 +1,8 @@
from typing import Optional, Literal from typing import Optional
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel
from app.core.auth_service import AuthService from app.core.auth_service import AuthService
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
@ -69,10 +69,6 @@ class BottomBarMessageCreatePayload(BaseModel):
message: str message: str
recipient_user_id: Optional[int] = None recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False requires_manual_ack: bool = False
message_kind: Literal['message', 'phone'] = 'message'
contact_id: Optional[int] = Field(default=None, gt=0)
caller_name: str = Field(default='', max_length=200)
callback_phone: str = Field(default='', max_length=80)
class BottomBarMessageReadPayload(BaseModel): class BottomBarMessageReadPayload(BaseModel):
@ -570,17 +566,13 @@ async def send_bottom_bar_message(
if recipient_user_id == int(current_user_id): if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv") raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
if payload.contact_id is not None:
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
row = execute_query_single( row = execute_query_single(
""" """
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone) INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s)
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""", """,
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()), (int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack)),
) or {} ) or {}
return { return {
@ -590,10 +582,6 @@ async def send_bottom_bar_message(
"from": _resolve_current_user_display_name(current_user), "from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}", "to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text, "text": row.get("message_text") or message_text,
"message_kind": payload.message_kind,
"contact_id": payload.contact_id,
"caller_name": payload.caller_name.strip(),
"callback_phone": payload.callback_phone.strip(),
"requires_manual_ack": bool(row.get("requires_manual_ack")), "requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"), "created_at": row.get("created_at"),
"is_own": True, "is_own": True,
@ -667,7 +655,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") tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support")
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,10 +83,6 @@ def ensure_bottom_bar_messages_schema() -> None:
sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, sender_user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE, recipient_user_id INTEGER NULL REFERENCES users(user_id) ON DELETE CASCADE,
message_text TEXT NOT NULL, message_text TEXT NOT NULL,
message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
caller_name VARCHAR(200) NOT NULL DEFAULT '',
callback_phone VARCHAR(80) NOT NULL DEFAULT '',
requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE, requires_manual_ack BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMP NULL read_at TIMESTAMP NULL
@ -96,12 +92,6 @@ def ensure_bottom_bar_messages_schema() -> None:
logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically") logger.warning("⚠️ bottom_bar_messages table was missing and has been created automatically")
else: else:
columns = set(_table_columns("bottom_bar_messages")) columns = set(_table_columns("bottom_bar_messages"))
if not {'message_kind', 'contact_id', 'caller_name', 'callback_phone'}.issubset(columns):
execute_query("""ALTER TABLE bottom_bar_messages
ADD COLUMN IF NOT EXISTS message_kind VARCHAR(16) NOT NULL DEFAULT 'message',
ADD COLUMN IF NOT EXISTS contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS caller_name VARCHAR(200) NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS callback_phone VARCHAR(80) NOT NULL DEFAULT ''""")
if "requires_manual_ack" not in columns: if "requires_manual_ack" not in columns:
execute_query( execute_query(
""" """
@ -153,21 +143,14 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
m.sender_user_id, m.sender_user_id,
m.recipient_user_id, m.recipient_user_id,
m.message_text, m.message_text,
m.message_kind, m.contact_id, m.caller_name, m.callback_phone,
NULLIF(CONCAT_WS(' ', contact.first_name, contact.last_name), '') AS contact_name,
m.requires_manual_ack, m.requires_manual_ack,
m.created_at, m.created_at,
receipt.read_at, m.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 (
@ -179,24 +162,21 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
ORDER BY m.created_at DESC, m.id DESC ORDER BY m.created_at DESC, m.id DESC
LIMIT %s LIMIT %s
""", """,
(int(user_id), int(user_id), int(user_id), int(user_id), int(user_id), safe_limit), (int(user_id), int(user_id), int(user_id), 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 m FROM bottom_bar_messages
LEFT JOIN bottom_bar_message_receipts receipt WHERE read_at IS NULL
ON receipt.message_id = m.id AND sender_user_id <> %s
AND receipt.user_id = %s
WHERE receipt.read_at IS NULL
AND m.sender_user_id <> %s
AND ( AND (
m.recipient_user_id = %s recipient_user_id = %s
OR m.recipient_user_id IS NULL OR 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)
@ -213,17 +193,11 @@ def get_user_messages_summary(user_id: Optional[int], limit: int = 20) -> Dict[s
"from": row.get("sender_name") or "Ukendt", "from": row.get("sender_name") or "Ukendt",
"to": recipient_name, "to": recipient_name,
"text": row.get("message_text") or "", "text": row.get("message_text") or "",
"message_kind": row.get("message_kind") or "message",
"contact_id": row.get("contact_id"),
"contact_name": row.get("contact_name"),
"caller_name": row.get("caller_name") or "",
"callback_phone": row.get("callback_phone") or "",
"requires_manual_ack": bool(row.get("requires_manual_ack")), "requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at").isoformat() if row.get("created_at") else None, "created_at": row.get("created_at").isoformat() if row.get("created_at") else None,
"is_own": int(row.get("sender_user_id") or 0) == int(user_id), "is_own": int(row.get("sender_user_id") or 0) == int(user_id),
"is_read": row.get("read_at") is not None,
"is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id), "is_unread": row.get("read_at") is None and int(row.get("sender_user_id") or 0) != int(user_id),
"is_acknowledged": row.get("acknowledged_at") is not None, "is_acknowledged": row.get("read_at") is not None,
} }
) )
@ -241,40 +215,31 @@ 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 = """
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL) recipient_user_id = %s
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE AND read_at IS NULL
AND m.sender_user_id <> %s AND COALESCE(requires_manual_ack, FALSE) = FALSE
AND sender_user_id <> %s
""" """
params.append(int(user_id)) params.append(int(user_id))
where_clause += " AND m.sender_user_id = %s" where_clause += " AND sender_user_id = %s"
params.append(int(partner_user_id)) params.append(int(partner_user_id))
else: else:
where_clause = """ where_clause = """
(m.recipient_user_id = %s OR m.recipient_user_id IS NULL) (recipient_user_id = %s OR recipient_user_id IS NULL)
AND COALESCE(m.requires_manual_ack, FALSE) = FALSE AND read_at IS NULL
AND m.sender_user_id <> %s AND COALESCE(requires_manual_ack, FALSE) = FALSE
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"""
WITH eligible AS ( UPDATE bottom_bar_messages
SELECT m.id SET read_at = CURRENT_TIMESTAMP
FROM bottom_bar_messages m
LEFT JOIN bottom_bar_message_receipts r
ON r.message_id = m.id AND r.user_id = %s
WHERE {where_clause} WHERE {where_clause}
AND r.read_at IS NULL RETURNING COUNT(*) OVER() AS affected_count
), 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([int(user_id)] + params + [int(user_id)]), tuple(params),
) or {} ) or {}
return int(row.get("affected_count") or 0) return int(row.get("affected_count") or 0)
@ -287,18 +252,14 @@ 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(
""" """
INSERT INTO bottom_bar_message_receipts (message_id, user_id, read_at, acknowledged_at) UPDATE bottom_bar_messages
SELECT m.id, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP SET read_at = CURRENT_TIMESTAMP
FROM bottom_bar_messages m WHERE id = %s
WHERE m.id = %s AND recipient_user_id = %s
AND (m.recipient_user_id = %s OR m.recipient_user_id IS NULL) AND read_at IS NULL
AND m.sender_user_id <> %s RETURNING id
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(user_id), int(message_id), int(user_id), int(user_id)), (int(message_id), int(user_id)),
) or {} ) or {}
return bool(row.get("id")) return bool(row.get("id"))
@ -318,7 +279,7 @@ def get_drift_status(limit: int = 5) -> Dict[str, Any]:
rows = execute_query( rows = execute_query(
f""" f"""
SELECT device_name, message, source_id, source_event_id, raw_json SELECT device_name, message, source_event_id, raw_json
FROM {rel_name} FROM {rel_name}
WHERE ( WHERE (
COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new') COALESCE(NULLIF(LOWER(BTRIM(status)), ''), 'new') IN ('active', 'warning', 'critical', 'new')
@ -333,39 +294,11 @@ def get_drift_status(limit: int = 5) -> Dict[str, Any]:
blacklist = _get_drift_device_blacklist() blacklist = _get_drift_device_blacklist()
blacklist_set = set(blacklist) blacklist_set = set(blacklist)
ignore_rules: set[tuple[str, str]] = set()
ignore_rel = execute_query_single(
"SELECT to_regclass('public.drift_ignore_rules')::text AS rel"
) or {}
if ignore_rel.get("rel"):
rule_rows = execute_query(
"""
SELECT source_id, external_id
FROM drift_ignore_rules
WHERE is_active = TRUE
"""
) or []
ignore_rules = {
(
str(rule.get("source_id") or "").strip().lower(),
str(rule.get("external_id") or "").strip().lower(),
)
for rule in rule_rows
if rule.get("source_id") and rule.get("external_id")
}
filtered_rows: List[Dict[str, Any]] = [] filtered_rows: List[Dict[str, Any]] = []
for row in rows: for row in rows:
candidates = set(_event_blacklist_candidates(row)) candidates = set(_event_blacklist_candidates(row))
if any(item in candidates for item in blacklist_set): if any(item in candidates for item in blacklist_set):
continue continue
source = str(row.get("source_id") or "").strip().lower()
external_id = str(row.get("source_event_id") or "").strip().lower()
for prefix in ("uisp-", "kuma-"):
if external_id.startswith(prefix):
external_id = external_id[len(prefix):]
break
if (source, external_id) in ignore_rules:
continue
filtered_rows.append(row) filtered_rows.append(row)
top_items = filtered_rows[: max(1, int(limit or 5))] top_items = filtered_rows[: max(1, int(limit or 5))]
@ -400,13 +333,19 @@ 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:
return False # Fail-open for authenticated users if group mapping is missing.
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)
@ -534,7 +473,7 @@ def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
t.sag_id, t.sag_id,
s.titel AS sag_navn, s.titel AS sag_navn,
t.start_tid, t.start_tid,
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int - COALESCE(t.pause_total_seconds, 0)::int, 0) AS elapsed GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
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
WHERE t.medarbejder_id = %s WHERE t.medarbejder_id = %s
@ -588,7 +527,7 @@ def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Di
t.start_tid, t.start_tid,
t.slut_tid, t.slut_tid,
GREATEST( GREATEST(
EXTRACT(EPOCH FROM (COALESCE(t.paused_at, NOW()) - COALESCE(t.start_tid, NOW())))::int, EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
0 0
) AS elapsed_seconds, ) AS elapsed_seconds,
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
@ -1018,10 +957,6 @@ def build_bottom_bar_state(
unassigned_open_cases = get_unassigned_open_cases(limit=8) unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10) recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10) notes_summary = get_user_notes_summary(user_id, limit=10)
procurement_attention = execute_query_single(
"""SELECT COUNT(*)::int AS count FROM sag_salgsvarer
WHERE type = 'purchase' AND status = 'draft'"""
) or {"count": 0}
urgent_cases = execute_query( urgent_cases = execute_query(
""" """
@ -1192,7 +1127,6 @@ def build_bottom_bar_state(
"mail": { "mail": {
"unread": status.get("mails_unread", 0), "unread": status.get("mails_unread", 0),
"customer_reply_needed": status.get("mails_unread", 0), "customer_reply_needed": status.get("mails_unread", 0),
"route": "/emails?filter=unread",
}, },
"cases": { "cases": {
"open": status.get("sager_open", 0), "open": status.get("sager_open", 0),
@ -1200,19 +1134,13 @@ def build_bottom_bar_state(
}, },
"urgent": { "urgent": {
"count": status.get("sager_urgent", 0), "count": status.get("sager_urgent", 0),
"route": "/sag?priority=urgent",
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases], "list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
}, },
"unassigned": { "unassigned": {
"count": status.get("sager_unassigned", 0), "count": status.get("sager_unassigned", 0),
"route": "/sag?unassigned=1",
"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?status=to_order",
},
"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,
@ -1313,8 +1241,7 @@ def build_bottom_bar_state(
"context_actions": context_actions, "context_actions": context_actions,
} }
from app.core.case_visibility import hidden_case_ids,filter_data return {
return filter_data({
"enabled": True, "enabled": True,
"sections": sections, "sections": sections,
"status": status, "status": status,
@ -1322,4 +1249,4 @@ def build_bottom_bar_state(
"own_timers": own_timers, "own_timers": own_timers,
"recent_cases": recent_cases, "recent_cases": recent_cases,
"notifications": notifications, "notifications": notifications,
},hidden_case_ids({'id':user_id,'is_superadmin':force_boss_access})) }

View File

@ -1,13 +1,13 @@
import logging import logging
import hmac import hmac
import hashlib import hashlib
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response from fastapi.responses import Response
from app.core.auth_dependencies import get_current_user from app.core.auth_dependencies import get_current_user
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
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -76,7 +76,6 @@ def _build_ical_response(events: list[dict], now: datetime) -> Response:
f"UID:{uid}", f"UID:{uid}",
f"DTSTAMP:{_format_ical_dt(now)}", f"DTSTAMP:{_format_ical_dt(now)}",
f"DTSTART:{_format_ical_dt(start_value)}", f"DTSTART:{_format_ical_dt(start_value)}",
*([f"DTEND:{_format_ical_dt(datetime.fromisoformat(event['end']))}"] if event.get("end") else []),
f"SUMMARY:{summary}", f"SUMMARY:{summary}",
f"DESCRIPTION:{description}", f"DESCRIPTION:{description}",
"END:VEVENT", "END:VEVENT",
@ -136,8 +135,6 @@ def _escape_ical(value: str) -> str:
def _format_ical_dt(value: datetime) -> str: def _format_ical_dt(value: datetime) -> str:
if value.tzinfo is not None:
return value.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
return value.strftime("%Y%m%dT%H%M%S") return value.strftime("%Y%m%dT%H%M%S")
@ -160,10 +157,6 @@ def _get_calendar_events(
"meeting", "meeting",
"technician_visit", "technician_visit",
"obs", "obs",
"quick_planned",
"quick_registered",
"planner_booking",
"absence",
} }
requested_types = { requested_types = {
t.strip() for t in (types.split(",") if types else []) if t.strip() t.strip() for t in (types.split(",") if types else []) if t.strip()
@ -329,88 +322,11 @@ def _get_calendar_events(
) )
) )
quick_states = [state for state in ("planned", "registered")
if not normalized_types or f"quick_{state}" in normalized_types]
if quick_states:
query = """SELECT q.*, c.name AS customer_name, COALESCE(u.full_name,u.username) AS user_name
FROM quick_time_items q LEFT JOIN customers c ON c.id=q.customer_id
JOIN users u ON u.user_id=q.user_id
WHERE q.state=ANY(%s) AND q.starts_at<%s AND q.ends_at>%s"""
params = [quick_states, end_dt, start_dt]
if only_mine and resolved_user_id is not None:
query += " AND q.user_id=%s"
params.append(resolved_user_id)
if customer_id:
query += " AND q.customer_id=%s"
params.append(customer_id)
for row in execute_query(query, tuple(params)) or []:
kind = f"quick_{row['state']}"
label = "Planlagt tid" if row['state'] == 'planned' else "Brugt tid"
event = _build_event(kind, f"{label}: {row['description']} · {row['user_name']}",
row['starts_at'],
f"/sag/new?quick_plan={row['id']}" if row['state'] == 'planned' else '/timetracking',
{"reference_id": row['id'], "reference_type": "quick_time",
"event_kind": kind, "user_id": row['user_id'],
"customer_id": row.get('customer_id'), "customer_name": row.get('customer_name')})
event['end'] = row['ends_at'].isoformat()
event['backgroundColor'] = '#336699' if row['state'] == 'planned' else '#7c3aed'
event['borderColor'] = event['backgroundColor']
event['textColor'] = '#ffffff'
events.append(event)
if not normalized_types or normalized_types & {'planner_booking','absence'}:
events.extend(_planning_feed_events(request,start_dt,end_dt,resolved_user_id,only_mine,customer_id,normalized_types))
events.sort(key=lambda item: item.get("start") or "") events.sort(key=lambda item: item.get("start") or "")
return events return events
def _planning_feed_events(request,start,end,target,only_mine,customer_id,kinds):
"""Read existing reservations into the legacy feed, without copying them."""
present=execute_query_single("SELECT to_regclass('planner_events') IS NOT NULL AS ready")
if not present or not present['ready']:
return []
from app.core.auth_service import AuthService
from app.core.case_visibility import filter_data,hidden_case_ids
from app.modules.planner.backend.router import _can_view,_can_view_target,_aware
from app.modules.planner.backend.calendar import visible_absence
viewer=getattr(request.state,'user_id',None) or target
row=execute_query_single('SELECT user_id,is_superadmin FROM users WHERE user_id=%s AND is_active',(viewer,))
if not row:
return []
actor={'id':row['user_id'],'is_superadmin':row['is_superadmin'],'permissions':AuthService.get_user_permissions(row['user_id'])}
if not _can_view(actor):
return []
if only_mine:
ids=[target or actor['id']]
if not _can_view_target(actor,ids[0]):
raise HTTPException(403,'Du har ikke adgang til denne medarbejders plan')
else:
ids=[u['user_id'] for u in execute_query('SELECT user_id FROM users WHERE is_active') or [] if _can_view_target(actor,u['user_id'])]
result=[]
if not kinds or 'planner_booking' in kinds:
rows=execute_query('''SELECT a.id,a.event_id,a.sag_id,a.user_id,a.starts_at,a.ends_at,e.title
FROM planner_allocations a JOIN planner_events e ON e.id=a.event_id LEFT JOIN sag_sager s ON s.id=a.sag_id
WHERE a.deleted_at IS NULL AND a.user_id=ANY(%s) AND a.starts_at<%s AND a.ends_at>%s
AND (%s IS NULL OR s.customer_id=%s) ORDER BY a.id''',(ids,_aware(end),_aware(start),customer_id,customer_id)) or []
seen=set()
for item in rows:
if item['event_id'] in seen:
continue
seen.add(item['event_id'])
result.append({'id':f'planner:{item["event_id"]}','title':item['title'],'start':item['starts_at'].isoformat(),
'end':item['ends_at'].isoformat(),'sag_id':item['sag_id'],'event_type':'planner_booking',
'url':f'/planning?view=calendar&event_id={item["event_id"]}'})
if (not kinds or 'absence' in kinds) and not customer_id:
labels={r['code']:r['label'] for r in execute_query('SELECT code,label FROM planner_activity_types') or []}
for item in execute_query('SELECT * FROM planner_absences WHERE deleted_at IS NULL AND user_id=ANY(%s) AND starts_at<%s AND ends_at>%s',(ids,_aware(end),_aware(start))) or []:
safe=visible_absence(item,actor)
result.append({'id':f'absence:{item["id"]}','title':'Fravær' if safe['kind']=='unavailable' else
labels.get(safe['kind'],'Fravær'),
'start':item['starts_at'].isoformat(),'end':item['ends_at'].isoformat(),'event_type':'absence','url':'/planning?view=calendar'})
return filter_data(result,hidden_case_ids(actor))
@router.get("/calendar/events") @router.get("/calendar/events")
async def get_calendar_events( async def get_calendar_events(
request: Request, request: Request,
@ -470,9 +386,6 @@ async def get_calendar_ical(
types=types, types=types,
) )
from app.core.case_visibility import hidden_case_ids,filter_data
events=filter_data(events,hidden_case_ids({'id':getattr(request.state,'user_id',None),
'is_superadmin':getattr(request.state,'case_superadmin',False)}))
return _build_ical_response(events, now) return _build_ical_response(events, now)
@ -522,6 +435,4 @@ async def get_calendar_ical_feed(
customer_id=customer_id, customer_id=customer_id,
types=types, types=types,
) )
from app.core.case_visibility import hidden_case_ids,filter_data
events=filter_data(events,hidden_case_ids({'id':user_id}))
return _build_ical_response(events, now) return _build_ical_response(events, now)

View File

@ -161,7 +161,7 @@
border: 1px solid var(--calendar-border); border: 1px solid var(--calendar-border);
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
width: 100%; width: 100%;
background: var(--theme-surface, #ffffff); background: #ffffff;
color: var(--calendar-ink); color: var(--calendar-ink);
} }
@ -175,7 +175,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
background: var(--theme-surface, #ffffff); background: #ffffff;
border: 1px solid var(--calendar-border); border: 1px solid var(--calendar-border);
border-radius: 8px; border-radius: 8px;
padding: 0.35rem 0.6rem; padding: 0.35rem 0.6rem;
@ -233,7 +233,7 @@
top: calc(100% + 6px); top: calc(100% + 6px);
left: 0; left: 0;
right: 0; right: 0;
background: var(--theme-surface, #ffffff); background: #ffffff;
border: 1px solid var(--calendar-border); border: 1px solid var(--calendar-border);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12); box-shadow: 0 12px 24px rgba(15, 23, 42, 0.12);
@ -378,7 +378,7 @@
.event-meeting { .event-meeting {
background: rgba(15, 76, 117, 0.18); background: rgba(15, 76, 117, 0.18);
color: var(--theme-primary-text, #0f4c75); color: #0f4c75;
} }
.event-technician_visit { .event-technician_visit {
@ -459,8 +459,6 @@
<div class="filter-block"> <div class="filter-block">
<label>Event typer</label> <label>Event typer</label>
<div class="type-tags"> <div class="type-tags">
<label class="type-tag"><input type="checkbox" class="type-filter" value="quick_planned" checked><span class="type-dot" style="background:#336699"></span>Planlagt tid</label>
<label class="type-tag"><input type="checkbox" class="type-filter" value="quick_registered" checked><span class="type-dot" style="background:#7c3aed"></span>Brugt tid</label>
<label class="type-tag" title="Vis deadlines"> <label class="type-tag" title="Vis deadlines">
<input type="checkbox" class="type-filter" value="deadline" checked> <input type="checkbox" class="type-filter" value="deadline" checked>
<span class="type-dot" style="background: var(--calendar-ember);"></span> <span class="type-dot" style="background: var(--calendar-ember);"></span>
@ -512,7 +510,7 @@
<div class="status-bar" id="rangeLabel">Indlæser periode...</div> <div class="status-bar" id="rangeLabel">Indlæser periode...</div>
</div> </div>
<div id="calendar"></div> <div id="calendar"></div>
<div class="calendar-legend"><div class="legend-item"><span class="legend-dot" style="background:#336699"></span>Planlagt tid</div><div class="legend-item"><span class="legend-dot" style="background:#7c3aed"></span>Brugt tid</div> <div class="calendar-legend">
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div> <div class="legend-item"><span class="legend-dot" style="background: var(--calendar-ember);"></span>Deadline</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div> <div class="legend-item"><span class="legend-dot" style="background: var(--calendar-violet);"></span>Deferred</div>
<div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div> <div class="legend-item"><span class="legend-dot" style="background: var(--calendar-sea);"></span>Møde</div>

View File

@ -38,7 +38,6 @@ class DriftCustomerMappingPayload(BaseModel):
class DriftBlacklistPayload(BaseModel): class DriftBlacklistPayload(BaseModel):
value: Optional[str] = None value: Optional[str] = None
reason: Optional[str] = None
def run_uptime_kuma_sync() -> Dict[str, Any]: def run_uptime_kuma_sync() -> Dict[str, Any]:
@ -187,12 +186,12 @@ def _row_to_event(row: Dict[str, Any]) -> Dict[str, Any]:
or nested_overview.get("last_seen") or nested_overview.get("last_seen")
or updated or updated
) )
duration_minutes = int(row.get("duration_minutes") or 0) duration_minutes = row.get("duration_minutes")
if duration_minutes is None:
started_dt = _coerce_dt(started) started_dt = _coerce_dt(started)
end_dt = _coerce_dt(resolved) or datetime.now(timezone.utc) end_dt = _coerce_dt(resolved) or _coerce_dt(updated) or datetime.now(timezone.utc)
if started_dt: if started_dt:
calculated_duration = max(int((end_dt - started_dt).total_seconds() // 60), 0) duration_minutes = max(int((end_dt - started_dt).total_seconds() // 60), 0)
duration_minutes = max(duration_minutes, calculated_duration)
source_event_id = row.get("source_event_id") or "" source_event_id = row.get("source_event_id") or ""
monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id monitor_key = source_event_id[5:] if source_event_id.startswith("kuma-") else source_event_id
@ -401,22 +400,6 @@ def _ensure_schema() -> None:
) )
""" """
) )
execute_query(
"""
CREATE TABLE IF NOT EXISTS drift_ignore_rules (
id SERIAL PRIMARY KEY,
source_id INTEGER REFERENCES drift_sources(id) ON DELETE CASCADE,
external_id VARCHAR(255) NOT NULL,
device_name VARCHAR(255),
reason VARCHAR(100),
ignored_by INTEGER,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (source_id, external_id)
)
"""
)
def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]: def _get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]:
@ -563,52 +546,19 @@ def _event_is_blacklisted(payload: Dict[str, Any], blacklist: List[str]) -> bool
def _filter_out_blacklisted_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: def _filter_out_blacklisted_rows(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
blacklist = _get_drift_device_blacklist() blacklist = _get_drift_device_blacklist()
ignore_rows = execute_query( if not blacklist:
"""SELECT source_id, LOWER(external_id) AS external_id
FROM drift_ignore_rules WHERE is_active = TRUE"""
) or []
ignore_keys = {
(int(rule.get("source_id") or 0), str(rule.get("external_id") or "").strip().lower())
for rule in ignore_rows
if rule.get("external_id")
}
if not blacklist and not ignore_keys:
return rows return rows
filtered: List[Dict[str, Any]] = [] filtered: List[Dict[str, Any]] = []
blacklist_set = set(blacklist) blacklist_set = set(blacklist)
for row in rows: for row in rows:
candidates = set(_event_blacklist_candidates(row)) candidates = set(_event_blacklist_candidates(row))
source_id = int(row.get("source_id") or 0)
if any((source_id, item) in ignore_keys for item in candidates):
continue
if any(item in candidates for item in blacklist_set): if any(item in candidates for item in blacklist_set):
continue continue
filtered.append(row) filtered.append(row)
return filtered return filtered
def _event_matches_ignore_rule(payload: Dict[str, Any], source_id: int, rules: Set[tuple[int, str]]) -> bool:
candidates = set(_event_blacklist_candidates({
"source_event_id": payload.get("source_event_id"),
"device_name": payload.get("device"),
"raw_json": payload.get("raw_json") or {},
}))
return any((int(source_id or 0), candidate) in rules for candidate in candidates)
def _active_ignore_rule_keys() -> Set[tuple[int, str]]:
rows = execute_query(
"""SELECT source_id, LOWER(external_id) AS external_id
FROM drift_ignore_rules WHERE is_active = TRUE"""
) or []
return {
(int(row.get("source_id") or 0), str(row.get("external_id") or "").strip().lower())
for row in rows
if row.get("external_id")
}
def _is_uptime_kuma_configured() -> bool: def _is_uptime_kuma_configured() -> bool:
cfg = _get_drift_connector_config() cfg = _get_drift_connector_config()
return bool(cfg.get("base_url") and cfg.get("api_key")) return bool(cfg.get("base_url") and cfg.get("api_key"))
@ -1407,12 +1357,7 @@ def _run_uptime_kuma_sync_internal() -> Dict[str, Any]:
} }
blacklist = _get_drift_device_blacklist() blacklist = _get_drift_device_blacklist()
ignore_rules = _active_ignore_rule_keys() filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
filtered_events = [
event for event in events
if not _event_is_blacklisted(event, blacklist)
and not _event_matches_ignore_rule(event, int(source.get("id") or 0), ignore_rules)
]
skipped = len(events) - len(filtered_events) skipped = len(events) - len(filtered_events)
for item in filtered_events: for item in filtered_events:
@ -1491,12 +1436,7 @@ def _run_uisp_sync_internal() -> Dict[str, Any]:
} }
blacklist = _get_drift_device_blacklist() blacklist = _get_drift_device_blacklist()
ignore_rules = _active_ignore_rule_keys() filtered_events = [event for event in events if not _event_is_blacklisted(event, blacklist)]
filtered_events = [
event for event in events
if not _event_is_blacklisted(event, blacklist)
and not _event_matches_ignore_rule(event, int(source.get("id") or 0), ignore_rules)
]
skipped = len(events) - len(filtered_events) skipped = len(events) - len(filtered_events)
for item in filtered_events: for item in filtered_events:
@ -1581,6 +1521,7 @@ async def get_drift_summary():
active_alerts += 1 active_alerts += 1
if row.get("ticket_id") is None: if row.get("ticket_id") is None:
unassigned += 1 unassigned += 1
if severity_value == "critical": if severity_value == "critical":
critical += 1 critical += 1
if severity_value == "warning": if severity_value == "warning":
@ -1646,9 +1587,6 @@ async def list_events(
params: List[Any] = [] params: List[Any] = []
filters = [] filters = []
if status: if status:
if str(status).strip().lower() == "active":
filters.append("LOWER(COALESCE(e.status, '')) IN ('active', 'warning', 'critical', 'new')")
else:
filters.append("e.status = %s") filters.append("e.status = %s")
params.append(status) params.append(status)
if severity: if severity:
@ -1993,80 +1931,23 @@ async def acknowledge_event(event_id: int):
return {"id": event_id, "status": "acknowledged"} return {"id": event_id, "status": "acknowledged"}
@router.get("/drift/ignore-rules")
async def list_ignore_rules():
_ensure_schema()
return execute_query(
"""SELECT r.id, r.source_id, s.name AS source_name, r.external_id,
r.device_name, r.reason, r.ignored_by,
COALESCE(NULLIF(u.full_name, ''), u.username) AS ignored_by_name,
r.created_at, r.updated_at
FROM drift_ignore_rules r
LEFT JOIN drift_sources s ON s.id = r.source_id
LEFT JOIN users u ON u.user_id = r.ignored_by
WHERE r.is_active = TRUE
ORDER BY r.created_at DESC, r.id DESC"""
) or []
@router.delete("/drift/ignore-rules/{rule_id:int}")
async def reactivate_ignored_device(rule_id: int):
_ensure_schema()
rule = execute_query_single(
"SELECT id FROM drift_ignore_rules WHERE id = %s AND is_active = TRUE",
(rule_id,),
)
if not rule:
raise HTTPException(status_code=404, detail="Ignore rule not found")
execute_query(
"""UPDATE drift_ignore_rules
SET is_active = FALSE, updated_at = CURRENT_TIMESTAMP
WHERE id = %s""",
(rule_id,),
)
return {"id": rule_id, "is_active": False}
@router.post("/drift/events/{event_id:int}/blacklist") @router.post("/drift/events/{event_id:int}/blacklist")
async def blacklist_event(event_id: int, request: Request, payload: Optional[DriftBlacklistPayload] = None): async def blacklist_event(event_id: int, payload: Optional[DriftBlacklistPayload] = None):
_ensure_schema() _ensure_schema()
event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,)) event = execute_query_single("SELECT * FROM drift_events WHERE id = %s", (event_id,))
if not event: if not event:
raise HTTPException(status_code=404, detail="Event not found") raise HTTPException(status_code=404, detail="Event not found")
current = _get_drift_device_blacklist()
explicit = str((payload.value if payload else "") or "").strip().lower() explicit = str((payload.value if payload else "") or "").strip().lower()
source_event_id = str(event.get("source_event_id") or "").strip().lower() to_add = [explicit] if explicit else _event_blacklist_candidates(event)
external_id = explicit or source_event_id
if external_id.startswith("uisp-"):
external_id = external_id[5:]
elif external_id.startswith("kuma-"):
external_id = external_id[5:]
if not external_id:
raise HTTPException(status_code=400, detail="Enheden mangler et stabilt kilde-ID")
reason = str((payload.reason if payload else "") or "").strip().lower() or "other" merged = list(current)
allowed_reasons = {"test", "retired", "unmanaged", "noisy", "other"} for item in to_add:
if reason not in allowed_reasons: if item and item not in merged:
raise HTTPException(status_code=400, detail="Ukendt årsag til ignorering") merged.append(item)
ignored_by = getattr(request.state, "user_id", None)
execute_query( _save_drift_device_blacklist(merged)
"""INSERT INTO drift_ignore_rules
(source_id, external_id, device_name, reason, ignored_by, is_active)
VALUES (%s, %s, %s, %s, %s, TRUE)
ON CONFLICT (source_id, external_id)
DO UPDATE SET device_name = EXCLUDED.device_name,
reason = EXCLUDED.reason,
ignored_by = EXCLUDED.ignored_by,
is_active = TRUE,
updated_at = CURRENT_TIMESTAMP""",
(
event.get("source_id"),
external_id,
event.get("device_name"),
reason,
ignored_by,
),
)
current_status = str(event.get("status") or "").strip().lower() current_status = str(event.get("status") or "").strip().lower()
auto_acknowledged = False auto_acknowledged = False
@ -2091,8 +1972,8 @@ async def blacklist_event(event_id: int, request: Request, payload: Optional[Dri
return { return {
"id": event_id, "id": event_id,
"blacklisted": [external_id], "blacklisted": to_add,
"reason": reason, "blacklist_size": len(merged),
"status": "acknowledged" if auto_acknowledged else current_status, "status": "acknowledged" if auto_acknowledged else current_status,
"auto_acknowledged": auto_acknowledged, "auto_acknowledged": auto_acknowledged,
} }

View File

@ -49,11 +49,8 @@
</div> </div>
</div> </div>
<details class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<summary class="card-header bg-body border-0 py-3 px-4 fw-semibold" style="cursor:pointer"> <div class="card-body">
<i class="bi bi-gear me-2"></i>Opsætning, kundemapping og ignorerede enheder
</summary>
<div class="card-body border-top">
<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">Bulk Mapping</h5> <h5 class="mb-1">Bulk Mapping</h5>
@ -79,22 +76,8 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<h5 class="mb-1">Ignorerede enheder</h5>
<div class="small text-muted">Enhederne ignoreres ved alle fremtidige sync. De kan altid sættes under overvågning igen.</div>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="loadIgnoreRules()"><i class="bi bi-arrow-clockwise me-1"></i>Opdater</button>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>Enhed</th><th>Kilde</th><th>Årsag</th><th>Ignoreret af</th><th>Dato</th><th></th></tr></thead>
<tbody id="drift-ignore-rules-body"><tr><td colspan="6" class="text-muted">Indlæser...</td></tr></tbody>
</table>
</div> </div>
</div> </div>
</details>
<div class="card shadow-sm border-0 mb-4"> <div class="card shadow-sm border-0 mb-4">
<div class="card-body d-flex flex-wrap align-items-center gap-3"> <div class="card-body d-flex flex-wrap align-items-center gap-3">
@ -114,13 +97,13 @@
<div class="d-flex flex-wrap gap-2 mb-3"> <div class="d-flex flex-wrap gap-2 mb-3">
<select class="form-select form-select-sm w-auto" id="drift-status-filter"> <select class="form-select form-select-sm w-auto" id="drift-status-filter">
<option value="">Status: Alle</option> <option value="">Status: Alle</option>
<option value="active" selected>Kræver handling</option> <option value="active">Aktiv</option>
<option value="acknowledged">Godkendt</option> <option value="acknowledged">Godkendt</option>
<option value="resolved">Løst</option> <option value="resolved">Løst</option>
</select> </select>
<select class="form-select form-select-sm w-auto" id="drift-severity-filter"> <select class="form-select form-select-sm w-auto" id="drift-severity-filter">
<option value="">Severity: Alle</option> <option value="">Severity: Alle</option>
<option value="critical">Critical</option> <option value="critical" selected>Critical</option>
<option value="warning">Warning</option> <option value="warning">Warning</option>
<option value="info">Info</option> <option value="info">Info</option>
</select> </select>
@ -129,17 +112,9 @@
<option value="uptime kuma">Uptime Kuma</option> <option value="uptime kuma">Uptime Kuma</option>
<option value="uisp">UISP</option> <option value="uisp">UISP</option>
</select> </select>
<select class="form-select form-select-sm w-auto" id="drift-attention-filter">
<option value="">Alle alarmer</option>
<option value="no_case">Uden sag</option>
<option value="unknown_customer">Ukendt kunde</option>
<option value="missing_ip">Mangler IP</option>
</select>
<input class="form-control form-control-sm" style="max-width:240px" id="drift-search-filter" placeholder="Søg kunde, device, IP…">
<button class="btn btn-sm btn-outline-primary" onclick="loadDriftEvents()"> <button class="btn btn-sm btn-outline-primary" onclick="loadDriftEvents()">
<i class="bi bi-funnel me-1"></i>Opdater filtre <i class="bi bi-funnel me-1"></i>Opdater filtre
</button> </button>
<span class="small text-muted align-self-center ms-auto" id="drift-result-count"></span>
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
@ -174,34 +149,6 @@
</div> </div>
</div> </div>
<div class="modal fade" id="driftIgnoreModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Ignorér enhed fremover</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<p class="mb-3" id="driftIgnoreDeviceText"></p>
<input type="hidden" id="driftIgnoreEventId">
<label class="form-label" for="driftIgnoreReason">Årsag</label>
<select class="form-select" id="driftIgnoreReason">
<option value="test">Testudstyr</option>
<option value="retired">Nedlagt udstyr</option>
<option value="unmanaged">Ikke administreret af BMC</option>
<option value="noisy">Støjende/irrelevant alarm</option>
<option value="other">Andet</option>
</select>
<div class="form-text">Reglen bindes til enhedens stabile ID hos kilden, også hvis navnet senere ændres.</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-danger" onclick="confirmIgnoreDevice()">Ignorér enhed fremover</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="driftMapModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="driftMapModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
@ -229,24 +176,6 @@
let driftCustomersCache = []; let driftCustomersCache = [];
let driftBlacklistCache = []; let driftBlacklistCache = [];
function escapeDriftHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
.replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
function driftReasonLabel(value) {
return ({ test: 'Testudstyr', retired: 'Nedlagt udstyr', unmanaged: 'Ikke administreret', noisy: 'Støjende alarm', other: 'Andet' })[value] || 'Andet';
}
function formatDriftDuration(totalMinutes) {
const minutes = Math.max(0, Number(totalMinutes || 0));
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
if (hours < 48) return `${hours} t ${minutes % 60} min`;
return `${Math.floor(hours / 24)} d ${hours % 24} t`;
}
function parseDriftBlacklistValue(rawValue) { function parseDriftBlacklistValue(rawValue) {
const raw = String(rawValue || '').trim(); const raw = String(rawValue || '').trim();
if (!raw) return []; if (!raw) return [];
@ -315,7 +244,7 @@ function isDriftEventBlacklisted(event, blacklist) {
function syncBottomDriftCountFromVisibleEvents(events) { function syncBottomDriftCountFromVisibleEvents(events) {
const activeStatuses = new Set(['active', 'warning', 'critical', 'new']); const activeStatuses = new Set(['active', 'warning', 'critical', 'new']);
const activeCount = typeof events === 'number' ? Math.max(0, events) : (Array.isArray(events) ? events : []).filter(event => { const activeCount = (Array.isArray(events) ? events : []).filter(event => {
const status = String((event && event.status) || '').trim().toLowerCase(); const status = String((event && event.status) || '').trim().toLowerCase();
return activeStatuses.has(status); return activeStatuses.has(status);
}).length; }).length;
@ -362,21 +291,41 @@ async function loadDriftSummary() {
const res = await fetch('/api/v1/drift/summary', { credentials: 'include' }); const res = await fetch('/api/v1/drift/summary', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json(); const data = await res.json();
document.getElementById('drift-active-alerts').textContent = data.active_alerts ?? 0; const blacklist = await loadDriftBlacklist();
document.getElementById('drift-critical').textContent = data.critical ?? 0;
document.getElementById('drift-warnings').textContent = data.warnings ?? 0; const fetchVisibleEvents = async (params) => {
const query = new URLSearchParams(params || {});
query.set('limit', '200');
const eventsRes = await fetch(`/api/v1/drift/events?${query.toString()}`, { credentials: 'include' });
if (!eventsRes.ok) return [];
const payload = await eventsRes.json();
const events = Array.isArray(payload) ? payload : [];
return events.filter(event => !isDriftEventBlacklisted(event, blacklist));
};
const [activeEvents, uispEvents] = await Promise.all([
fetchVisibleEvents({ status: 'active' }),
fetchVisibleEvents({ source: 'uisp' }),
]);
const criticalCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'critical').length;
const warningCount = activeEvents.filter(event => String(event?.severity || '').toLowerCase() === 'warning').length;
const uispActiveCount = uispEvents.filter(event => String(event?.status || '').toLowerCase() === 'active').length;
document.getElementById('drift-active-alerts').textContent = activeEvents.length;
document.getElementById('drift-critical').textContent = criticalCount;
document.getElementById('drift-warnings').textContent = warningCount;
document.getElementById('drift-resolved-today').textContent = data.resolved_today ?? 0; document.getElementById('drift-resolved-today').textContent = data.resolved_today ?? 0;
const uisp = data.uisp || {}; const uisp = data.uisp || {};
const configured = !!uisp.configured; const configured = !!uisp.configured;
const configuredEl = document.getElementById('drift-uisp-configured'); const configuredEl = document.getElementById('drift-uisp-configured');
configuredEl.textContent = configured ? 'Konfigureret' : 'Ikke konfigureret'; configuredEl.textContent = configured ? 'Konfigureret' : 'Ikke konfigureret';
configuredEl.className = configured ? 'badge text-bg-success' : 'badge text-bg-secondary'; configuredEl.className = configured ? 'badge text-bg-success' : 'badge text-bg-secondary';
document.getElementById('drift-uisp-active').textContent = uisp.active_events ?? 0; document.getElementById('drift-uisp-active').textContent = uispActiveCount;
document.getElementById('drift-uisp-total').textContent = uisp.total_events ?? 0; document.getElementById('drift-uisp-total').textContent = uispEvents.length;
document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync document.getElementById('drift-uisp-last-sync').textContent = uisp.last_sync
? new Date(uisp.last_sync).toLocaleString('da-DK') ? new Date(uisp.last_sync).toLocaleString('da-DK')
: '-'; : '-';
syncBottomDriftCountFromVisibleEvents(Number(data.active_alerts || 0));
} catch (e) { } catch (e) {
console.error(e); console.error(e);
document.getElementById('drift-active-alerts').textContent = '—'; document.getElementById('drift-active-alerts').textContent = '—';
@ -404,88 +353,54 @@ async function loadDriftEvents() {
const res = await fetch(`/api/v1/drift/events?${params.toString()}`, { credentials: 'include' }); const res = await fetch(`/api/v1/drift/events?${params.toString()}`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const events = await res.json(); const events = await res.json();
const attention = document.getElementById('drift-attention-filter').value; const blacklist = await loadDriftBlacklist();
const search = document.getElementById('drift-search-filter').value.trim().toLowerCase(); const visibleEvents = (Array.isArray(events) ? events : []).filter(event => !isDriftEventBlacklisted(event, blacklist));
const visibleEvents = (Array.isArray(events) ? events : []) syncBottomDriftCountFromVisibleEvents(visibleEvents);
.filter(event => {
if (attention === 'no_case' && event.ticket_id) return false;
if (attention === 'unknown_customer' && event.customer && event.customer !== 'UISP' && event.customer !== 'Uptime Kuma') return false;
if (attention === 'missing_ip' && event.ip) return false;
if (!search) return true;
return [event.customer, event.device, event.ip, event.site_client_name, event.source]
.some(value => String(value || '').toLowerCase().includes(search));
})
.sort((a, b) => {
const severity = { critical: 0, warning: 1, info: 2 };
const customerA = (!a.customer || a.customer === 'UISP' || a.customer === 'Uptime Kuma') ? '' : a.customer;
const customerB = (!b.customer || b.customer === 'UISP' || b.customer === 'Uptime Kuma') ? '' : b.customer;
const groupA = String(customerA || a.site_client_name || 'Ukendt kunde');
const groupB = String(customerB || b.site_client_name || 'Ukendt kunde');
return groupA.localeCompare(groupB, 'da')
|| (severity[String(a.severity || '').toLowerCase()] ?? 3) - (severity[String(b.severity || '').toLowerCase()] ?? 3)
|| Number(b.duration_minutes || 0) - Number(a.duration_minutes || 0);
});
document.getElementById('drift-result-count').textContent = `${visibleEvents.length} viste alarmer`;
if (!visibleEvents.length) { if (!visibleEvents.length) {
body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>'; body.innerHTML = '<tr><td colspan="11" class="text-muted">Ingen hændelser fundet.</td></tr>';
return; return;
} }
let previousGroup = ''; body.innerHTML = visibleEvents.map(event => `
body.innerHTML = visibleEvents.map(event => {
const realCustomer = (!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '' : event.customer;
const group = realCustomer || event.site_client_name || 'Ukendt kunde';
const groupHeader = group !== previousGroup
? `<tr class="table-light"><td colspan="11" class="fw-semibold"><i class="bi bi-building me-2"></i>${escapeDriftHtml(group)}</td></tr>`
: '';
previousGroup = group;
const quality = [
(!event.customer || event.customer === 'UISP' || event.customer === 'Uptime Kuma') ? '<span class="badge text-bg-warning">Ukendt kunde</span>' : '',
!event.ip ? '<span class="badge text-bg-secondary">Mangler IP</span>' : '',
].filter(Boolean).join(' ');
return groupHeader + `
<tr> <tr>
<td><span class="badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}">${event.status === 'active' ? 'Kræver handling' : event.status === 'acknowledged' ? 'Set' : event.status === 'resolved' ? 'Løst' : escapeDriftHtml(event.status || 'Ny')}</span></td> <td><span class="badge bg-${event.status === 'active' ? 'danger' : event.status === 'acknowledged' ? 'warning' : 'success'}">${event.status || 'new'}</span></td>
<td><span class="badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}">${escapeDriftHtml(event.severity || 'info')}</span></td> <td><span class="badge bg-${event.severity === 'critical' ? 'danger' : event.severity === 'warning' ? 'warning' : 'secondary'}">${event.severity || 'info'}</span></td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span>${escapeDriftHtml(event.source || 'Ukendt')}</span> <span>${event.source || 'Ukendt'}</span>
${event.source_link ? `<a class="btn btn-sm btn-outline-secondary" href="${event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn kilde"><i class="bi bi-box-arrow-up-right"></i></a>` : ''} ${event.source_link ? `<a class="btn btn-sm btn-outline-secondary" href="${event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn kilde"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div> </div>
</td> </td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span>${escapeDriftHtml(event.customer || '-')}</span> <span>${event.customer || '-'}</span>
${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})' title="Knyt monitor til kunde">Knyt</button>` : ''} ${event.monitor_key ? `<button class="btn btn-sm btn-outline-primary" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})' title="Knyt monitor til kunde">Knyt</button>` : ''}
</div> </div>
</td> </td>
<td> <td>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<span>${escapeDriftHtml(event.device || '-')}</span> <span>${event.device || '-'}</span>
${(event.device_link || event.source_link) ? `<a class="btn btn-sm btn-outline-secondary" href="${event.device_link || event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn enhed"><i class="bi bi-box-arrow-up-right"></i></a>` : ''} ${(event.device_link || event.source_link) ? `<a class="btn btn-sm btn-outline-secondary" href="${event.device_link || event.source_link}" target="_blank" rel="noopener noreferrer" title="Åbn enhed"><i class="bi bi-box-arrow-up-right"></i></a>` : ''}
</div> </div>
</td> </td>
<td>${escapeDriftHtml(event.ip || '-')}<div class="mt-1">${quality}</div></td> <td>${event.ip || '-'}</td>
<td>${escapeDriftHtml(event.site_client_name || event.site || event.customer || '-')}</td> <td>${event.site_client_name || event.site || event.customer || '-'}</td>
<td>${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td> <td>${event.started ? new Date(event.started).toLocaleString('da-DK') : '-'}</td>
<td>${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}</td> <td>${event.last_seen ? new Date(event.last_seen).toLocaleString('da-DK') : '-'}</td>
<td>${formatDriftDuration(event.duration_minutes)}</td> <td>${event.duration_minutes !== null && event.duration_minutes !== undefined ? `${event.duration_minutes} min` : '-'}</td>
<td> <td>
<div class="d-flex align-items-center gap-1 flex-nowrap"> <div class="d-flex align-items-center gap-2">
${event.ticket_id ? `<a href="/sag/${Number(event.ticket_id)}/v3" class="btn btn-sm btn-primary">Åbn sag</a>` : `<button class="btn btn-sm btn-primary" onclick="createDriftCase(${Number(event.id)})">Opret sag</button>`} ${event.status === 'active' ? `<button class="btn btn-sm btn-outline-success" onclick="acknowledgeDriftEvent(${event.id})">Godkend</button>` : ''}
<div class="dropdown"> <button class="btn btn-sm btn-outline-danger" onclick="blacklistDriftEvent(${event.id})" title="Blacklist device fremadrettet">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-label="Flere handlinger">Mere</button> Blacklist
<ul class="dropdown-menu dropdown-menu-end"> </button>
${event.status === 'active' ? `<li><button class="dropdown-item" onclick="acknowledgeDriftEvent(${Number(event.id)})"><i class="bi bi-check2 me-2"></i>Markér som set</button></li>` : ''} ${event.ticket_id ? `<a href="/sag/${event.ticket_id}/v3" class="btn btn-sm btn-outline-secondary">Sag #${event.ticket_id}</a>` : '<button class="btn btn-sm btn-outline-primary" onclick="createDriftCase(' + event.id + ')">Opret sag</button>'}
${event.monitor_key ? `<li><button class="dropdown-item" onclick='openMapCustomerModal(${JSON.stringify(event.monitor_key || '')}, ${JSON.stringify(event.device || '')}, ${JSON.stringify(String(event.source_event_id || '').startsWith('uisp-') ? 'uisp' : 'uptime-kuma')})'><i class="bi bi-link-45deg me-2"></i>Knyt kunde</button></li>` : ''}
<li><button class="dropdown-item text-danger" onclick='openIgnoreDeviceModal(${Number(event.id)}, ${JSON.stringify(event.device || 'Ukendt enhed')}, ${JSON.stringify(event.source || 'Ukendt kilde')})'><i class="bi bi-eye-slash me-2"></i>Ignorér enhed fremover</button></li>
</ul>
</div>
</div> </div>
</td> </td>
</tr> </tr>
`}).join(''); `).join('');
} catch (e) { } catch (e) {
console.error(e); console.error(e);
syncBottomDriftCountFromVisibleEvents([]);
body.innerHTML = '<tr><td colspan="11" class="text-muted">Kunne ikke hente drift-data.</td></tr>'; body.innerHTML = '<tr><td colspan="11" class="text-muted">Kunne ikke hente drift-data.</td></tr>';
} }
} }
@ -522,67 +437,27 @@ async function acknowledgeDriftEvent(eventId) {
await loadDriftEvents(); await loadDriftEvents();
} }
function openIgnoreDeviceModal(eventId, deviceName, sourceName) { async function blacklistDriftEvent(eventId, deviceName = '') {
document.getElementById('driftIgnoreEventId').value = Number(eventId || 0); const label = (deviceName || '').trim() || `event #${eventId}`;
document.getElementById('driftIgnoreDeviceText').textContent = `${deviceName || 'Ukendt enhed'} fra ${sourceName || 'ukendt kilde'} fjernes fra nuværende og fremtidige Drift-alarmer.`; if (!confirm(`Blacklist ${label} for fremtidige Drift sync?`)) {
document.getElementById('driftIgnoreReason').value = 'noisy'; return;
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).show(); }
}
async function confirmIgnoreDevice() {
const eventId = Number(document.getElementById('driftIgnoreEventId').value || 0);
const reason = document.getElementById('driftIgnoreReason').value || 'other';
if (!eventId) return;
const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, { const res = await fetch(`/api/v1/drift/events/${eventId}/blacklist`, {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason }) body: JSON.stringify({ value: String(deviceName || '').trim().toLowerCase() })
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
alert(data.detail || 'Kunne ikke ignorere enheden'); alert(data.detail || 'Kunne ikke blacklist device');
return; return;
} }
bootstrap.Modal.getOrCreateInstance(document.getElementById('driftIgnoreModal')).hide();
await loadDriftSummary(); await loadDriftSummary();
await loadDriftEvents(); await loadDriftEvents();
await loadIgnoreRules(); showNotification('Device blacklistet for fremtidige sync', 'success');
showNotification('Enheden ignoreres ved fremtidige sync', 'success');
}
async function loadIgnoreRules() {
const body = document.getElementById('drift-ignore-rules-body');
if (!body) return;
try {
const res = await fetch('/api/v1/drift/ignore-rules', { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const rules = await res.json();
body.innerHTML = (Array.isArray(rules) ? rules : []).map(rule => `
<tr>
<td><strong>${escapeDriftHtml(rule.device_name || rule.external_id)}</strong><div class="small text-muted">${escapeDriftHtml(rule.external_id)}</div></td>
<td>${escapeDriftHtml(rule.source_name || '-')}</td>
<td>${escapeDriftHtml(driftReasonLabel(rule.reason))}</td>
<td>${escapeDriftHtml(rule.ignored_by_name || 'Ukendt')}</td>
<td>${rule.created_at ? new Date(rule.created_at).toLocaleString('da-DK') : '-'}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-success" onclick="reactivateIgnoredDevice(${Number(rule.id)})">Overvåg igen</button></td>
</tr>`).join('') || '<tr><td colspan="6" class="text-success">Ingen enheder ignoreres.</td></tr>';
} catch (e) {
body.innerHTML = '<tr><td colspan="6" class="text-danger">Kunne ikke hente ignorerede enheder.</td></tr>';
}
}
async function reactivateIgnoredDevice(ruleId) {
const res = await fetch(`/api/v1/drift/ignore-rules/${Number(ruleId)}`, { method: 'DELETE', credentials: 'include' });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
alert(data.detail || 'Kunne ikke sætte enheden under overvågning igen');
return;
}
await loadIgnoreRules();
await loadDriftSummary();
await loadDriftEvents();
showNotification('Enheden overvåges igen fra næste sync', 'success');
} }
async function mapDriftCustomer(monitorKey, monitorName) { async function mapDriftCustomer(monitorKey, monitorName) {
@ -741,10 +616,6 @@ window.addEventListener('DOMContentLoaded', () => {
loadDriftSummary(); loadDriftSummary();
loadDriftEvents(); loadDriftEvents();
loadDriftHistory(); loadDriftHistory();
loadIgnoreRules();
document.getElementById('drift-search-filter')?.addEventListener('keydown', event => {
if (event.key === 'Enter') loadDriftEvents();
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@ -89,7 +89,7 @@
.fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; } .fedex-status.draft { background: rgba(108, 117, 125, 0.15); border-color: rgba(108, 117, 125, 0.3); color: #5f6b76; }
.fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; } .fedex-status.submitted, .fedex-status.booked { background: rgba(13, 110, 253, 0.12); border-color: rgba(13, 110, 253, 0.3); color: #0a58ca; }
.fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; } .fedex-status.in_transit { background: rgba(255, 193, 7, 0.15); border-color: rgba(255, 193, 7, 0.35); color: #996f00; }
.fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: var(--theme-success-text, #146c43); } .fedex-status.delivered { background: rgba(25, 135, 84, 0.14); border-color: rgba(25, 135, 84, 0.3); color: #146c43; }
.fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; } .fedex-status.cancelled, .fedex-status.failed { background: rgba(220, 53, 69, 0.14); border-color: rgba(220, 53, 69, 0.3); color: #b02a37; }
.fedex-row-title { .fedex-row-title {

View File

@ -1,284 +0,0 @@
"""Reviewed hardware intake from invoice lines, product codes, or manual entry."""
import copy
import hashlib
import json
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import Literal, Optional
from uuid import UUID, uuid4
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import atomic_transaction, execute_query
router = APIRouter(prefix='/hardware-intake')
def _one(sql, params=()):
rows = execute_query(sql, params)
return rows[0] if rows else None
def _decimal(value):
try:
number = Decimal(str(value))
if number.is_finite():
return number
except (InvalidOperation, ValueError, TypeError):
pass
raise HTTPException(422, 'Antal og beløb skal være gyldige tal')
def _lines(raw_lines, use_line_numbers=False):
result = []
seen = set()
for index, line in enumerate(raw_lines or [], 1):
key = str((line.get('line_number') or index) if use_line_numbers else (line.get('id') or line.get('line_number') or index))
if key in seen:
raise HTTPException(422, 'Fakturaen har tvetydige linjenumre; kontrollér fakturagrundlaget')
seen.add(key)
qty = line.get('quantity')
price = line.get('unit_price')
result.append(dict(key=key, source_line_id=line.get('id'), description=str(line.get('description') or line.get('name') or ''),
sku=line.get('sku') or line.get('item_number'), quantity=str(qty) if qty is not None else None,
unit_price=str(price) if price is not None else None))
return result
def _store_preview(kind, source_id, header, lines):
payload = dict(header=header, lines=_lines(lines, use_line_numbers=kind == 'file' or str(header.get('allocation_source') or '').startswith('file:')))
preview_id = str(uuid4())
execute_query('''INSERT INTO hardware_intake_previews(id,source_kind,source_id,payload)
VALUES(%s,%s,%s,%s)''', (preview_id, kind, source_id, Json(payload, dumps=lambda value: json.dumps(value, default=str))))
taken = execute_query("SELECT id,hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (header.get('allocation_source', f'{kind}:{source_id}') + ':',)) or []
created_units = {}
for row in taken:
key = row['key'].rsplit(':', 2)[-2]
created_units.setdefault(key, []).append(row['id'])
return dict(preview_id=preview_id, source_kind=kind, source_id=source_id, created_units=created_units, **payload)
def _invoice_preview(invoice_id):
invoice = _one('''SELECT id,invoice_number,vendor_name,invoice_date,currency,file_path,status,extraction_id
FROM supplier_invoices WHERE id=%s''', (invoice_id,))
if not invoice:
raise HTTPException(404, 'Leverandørfakturaen findes ikke')
if invoice['status'] in ('cancelled', 'rejected', 'credited'):
raise HTTPException(409, 'Afviste, annullerede eller krediterede fakturaer kan ikke bruges til ny hardware')
file_path = invoice.pop('file_path', None)
invoice['document_available'] = bool(file_path)
extraction_id = invoice.pop('extraction_id', None)
linked_file = _one('SELECT file_id FROM extractions WHERE extraction_id=%s', (extraction_id,)) if extraction_id else None
if not linked_file and file_path:
linked_file = _one('SELECT file_id FROM incoming_files WHERE file_path=%s ORDER BY file_id LIMIT 1', (file_path,))
if linked_file:
invoice['allocation_source'] = f"file:{linked_file['file_id']}"
invoice['document_available'] = True
lines = execute_query('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s ORDER BY line_number,id', (invoice_id,))
return _store_preview('invoice', invoice_id, invoice, lines)
async def _file_preview(file_id):
from app.services.ollama_service import ollama_service
from app.services.invoice2data_service import get_invoice2data_service
file = _one('SELECT file_id,filename,file_path FROM incoming_files WHERE file_id=%s', (file_id,))
if not file:
raise HTTPException(404, 'Fakturafilen findes ikke')
invoice = _one('''SELECT id FROM supplier_invoices WHERE file_path=%s
OR id IN (SELECT supplier_invoice_id FROM supplier_invoice_attachments WHERE file_path=%s)
OR extraction_id IN (SELECT extraction_id FROM extractions WHERE file_id=%s)
ORDER BY id LIMIT 1''', (file['file_path'], file['file_path'], file_id))
if invoice:
return _invoice_preview(invoice['id'])
extraction = _one('SELECT * FROM extractions WHERE file_id=%s ORDER BY created_at DESC LIMIT 1', (file_id,))
payload = {}
lines = []
if extraction:
raw = extraction.get('llm_response_json') or {}
payload = json.loads(raw) if isinstance(raw, str) else raw
if not isinstance(payload, dict):
payload = {}
lines = execute_query('SELECT * FROM extraction_lines WHERE extraction_id=%s ORDER BY line_number', (extraction['extraction_id'],)) or payload.get('lines') or []
if not lines:
path = Path(file['file_path'])
if not path.is_file():
raise HTTPException(404, 'Fakturafilen mangler på disken')
text = await ollama_service._extract_text_from_file(path)
if not text.strip():
raise HTTPException(422, 'Kunne ikke læse fakturaen. Prøv en tydeligere fil eller manuel oprettelse.')
payload = get_invoice2data_service().extract(text) or {}
if not payload.get('lines'):
payload = await ollama_service.extract_from_text(text)
lines = payload.get('lines') or []
header = {key: payload.get(key) or (extraction or {}).get(key) for key in
('invoice_number', 'vendor_name', 'invoice_date', 'currency')}
header['filename'] = file['filename']
header['document_available'] = True
return _store_preview('file', file_id, header, lines)
@router.get('/sources')
async def list_sources(q: str = Query('', max_length=200)):
term = '%' + q.strip() + '%'
invoices = execute_query('''SELECT id,invoice_number,vendor_name,invoice_date FROM supplier_invoices
WHERE status NOT IN ('cancelled','rejected','credited')
AND (invoice_number ILIKE %s OR COALESCE(vendor_name,'') ILIKE %s)
ORDER BY created_at DESC LIMIT 20''', (term, term)) or []
files = execute_query('''SELECT file_id,filename FROM incoming_files
WHERE filename ILIKE %s ORDER BY uploaded_at DESC LIMIT 20''', (term,)) or []
return {'sources': [dict(kind='invoice', id=row['id'], label=f"{row.get('vendor_name') or 'Leverandør'} · {row['invoice_number']}") for row in invoices]
+ [dict(kind='file', id=row['file_id'], label=row['filename']) for row in files]}
class SourceInput(BaseModel):
kind: Literal['invoice', 'file']
id: int = Field(gt=0)
@router.post('/preview')
async def preview_source(source: SourceInput):
return _invoice_preview(source.id) if source.kind == 'invoice' else await _file_preview(source.id)
@router.post('/upload')
async def upload_preview(file: UploadFile = File(...)):
from app.billing.backend.supplier_invoices import upload_supplier_invoice
suffix = Path(file.filename or '').suffix.lower()
if suffix not in ('.pdf', '.png', '.jpg', '.jpeg'):
raise HTTPException(422, 'Vælg en PDF, PNG eller JPG')
uploaded = await upload_supplier_invoice(file)
file_id = uploaded.get('file_id')
if not file_id:
raise HTTPException(422, 'Upload kunne ikke knyttes til en fakturafil')
return await _file_preview(file_id)
class IntakeInput(BaseModel):
request_id: UUID
mode: Literal['manual', 'barcode', 'invoice'] = 'manual'
hardware: dict
count: int = Field(default=1, ge=1, le=100)
serial_numbers: list[str] = Field(default_factory=list, max_length=100)
preview_id: Optional[UUID] = None
line_key: Optional[str] = None
code: Optional[str] = Field(default=None, max_length=200)
product_id: Optional[int] = Field(default=None, gt=0)
approved_unit_cost: Optional[Decimal] = Field(default=None, ge=0, allow_inf_nan=False)
currency: str = Field(default='DKK', pattern='^[A-Z]{3}$')
@router.post('/create')
async def create_from_intake(request: IntakeInput):
from app.modules.hardware.backend.router import create_hardware
if not request.hardware.get('asset_type'):
raise HTTPException(422, 'Vælg hardwaretype før oprettelse')
if request.mode == 'barcode' and not request.code:
raise HTTPException(422, 'Slå en stregkode op, og vælg en vare før oprettelse')
if request.count > 1 and any(request.hardware.get(key) for key in ('internal_asset_id', 'customer_asset_id', 'anydesk_id', 'anydesk_link')):
raise HTTPException(422, 'Ved flere enheder skal individuelle asset-ID og AnyDesk-ID udfyldes på hver hardwarepost efter oprettelsen')
serials = [value.strip() for value in request.serial_numbers]
if not serials:
serials = [str(request.hardware.get('serial_number') or '').strip()] if request.count == 1 else [''] * request.count
if len(serials) != request.count:
raise HTTPException(422, 'Angiv ét serienummer pr. enhed, eller lad serienumrene være tomme')
normalized_serials = [value.replace(' ', '').replace('-', '').upper() for value in serials if value]
if len(set(normalized_serials)) != len(normalized_serials):
raise HTTPException(409, 'Samme serienummer må ikke bruges på flere enheder')
request_json = request.model_dump(mode='json')
fingerprint = hashlib.sha256(json.dumps(request_json, sort_keys=True).encode()).hexdigest()
with atomic_transaction():
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-request:' + str(request.request_id),))
previous = execute_query('''SELECT id,hardware_specs FROM hardware_assets
WHERE hardware_specs->>'intake_request_id'=%s ORDER BY id''', (str(request.request_id),)) or []
if previous:
if any(row['hardware_specs'].get('intake_request_hash') != fingerprint for row in previous):
raise HTTPException(409, 'Oprettelsen er allerede gennemført med andre data. Genindlæs formularen for en ny oprettelse.')
return {'hardware_ids': [row['id'] for row in previous], 'already_created': True}
source = dict(mode=request.mode, code=request.code)
unit_keys = [f"manual:{request.request_id}:{index}" for index in range(request.count)]
invoice_id = None
if request.mode == 'invoice':
preview = _one('''SELECT * FROM hardware_intake_previews WHERE id=%s
AND created_at > NOW()-INTERVAL '24 hours' ''', (str(request.preview_id),)) if request.preview_id else None
if not preview:
raise HTTPException(409, 'Vælg og gennemgå fakturalinjen igen; forhåndsvisningen mangler eller er udløbet')
line = next((line for line in preview['payload']['lines'] if line['key'] == request.line_key), None)
if not line:
raise HTTPException(422, 'Vælg en fakturalinje')
quantity = _decimal(line['quantity'])
if quantity <= 0 or quantity != int(quantity) or quantity > 10000:
raise HTTPException(422, 'Fakturalinjen skal have et positivt helt antal fysiske enheder')
if line['unit_price'] is not None and _decimal(line['unit_price']) < 0:
raise HTTPException(422, 'Kreditlinjer kan ikke oprette ny hardware')
key_prefix = preview['payload']['header'].get('allocation_source', f"{preview['source_kind']}:{preview['source_id']}") + f":{line['key']}:"
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-source:' + key_prefix,))
taken = execute_query("SELECT hardware_specs->>'intake_unit_key' AS key FROM hardware_assets WHERE starts_with(hardware_specs->>'intake_unit_key',%s)", (key_prefix,)) or []
occupied = {row['key'] for row in taken}
unit_keys = [key_prefix + str(index) for index in range(1, int(quantity)+1) if key_prefix + str(index) not in occupied][:request.count]
if len(unit_keys) != request.count:
raise HTTPException(409, 'Der er allerede oprettet hardware for disse enheder. Vælg færre enheder eller gennemgå eksisterende hardware.')
source = dict(mode='invoice', source_kind=preview['source_kind'], source_id=preview['source_id'],
line=copy.deepcopy(line), header=copy.deepcopy(preview['payload']['header']))
if preview['source_kind'] == 'invoice':
invoice_id = preview['source_id']
invoice = _one('SELECT id,status FROM supplier_invoices WHERE id=%s', (invoice_id,))
current_line = _one('SELECT * FROM supplier_invoice_lines WHERE supplier_invoice_id=%s AND id=%s', (invoice_id, line['source_line_id']))
current_snapshot = _lines([current_line], use_line_numbers=str(preview['payload']['header'].get('allocation_source') or '').startswith('file:'))[0] if current_line else None
if current_snapshot and current_line.get('line_number') is None:
current_snapshot['key'] = line['key']
if current_snapshot != line:
raise HTTPException(409, 'Fakturalinjen er ændret. Gennemgå fakturaen igen før oprettelse.')
if not invoice or invoice['status'] in ('cancelled', 'rejected', 'credited'):
raise HTTPException(409, 'Fakturaen er ikke længere tilgængelig som oprettelsesgrundlag')
elif not _one('SELECT file_id FROM incoming_files WHERE file_id=%s', (preview['source_id'],)):
raise HTTPException(409, 'Fakturafilen findes ikke længere')
source.update(approved_unit_cost=str(request.approved_unit_cost) if request.approved_unit_cost is not None else None,
approved_currency=request.currency)
if request.product_id:
product = _one('SELECT id,name,ean,sku_internal FROM products WHERE id=%s AND deleted_at IS NULL', (request.product_id,))
if not product:
raise HTTPException(409, 'Den valgte vare findes ikke længere')
source['product'] = dict(product)
for normalized_serial in sorted(normalized_serials):
execute_query("SELECT pg_advisory_xact_lock(hashtextextended(%s,0))", ('hardware-intake-serial:' + normalized_serial,))
created = []
for index, serial in enumerate(serials):
if serial:
duplicate = _one("""SELECT id FROM hardware_assets WHERE deleted_at IS NULL
AND UPPER(REPLACE(REPLACE(COALESCE(serial_number,''),' ',''),'-',''))=%s LIMIT 1""", (serial.replace(' ', '').replace('-', '').upper(),))
if duplicate:
raise HTTPException(409, f"Serienummeret findes allerede på hardware #{duplicate['id']}")
hardware = copy.deepcopy(request.hardware)
if hardware.get('hardware_specs') is not None and not isinstance(hardware['hardware_specs'], dict):
raise HTTPException(422, 'Hardwarespecifikationer skal være et objekt')
specs = dict(hardware.get('hardware_specs') or {})
specs.update(intake_request_id=str(request.request_id), intake_request_hash=fingerprint,
intake_unit_key=unit_keys[index], acquisition=source)
hardware.update(serial_number=serial or None, hardware_specs=specs)
asset = await create_hardware(hardware)
if invoice_id:
execute_query('''INSERT INTO supplier_invoice_relations(supplier_invoice_id,relation_type,relation_id,note)
VALUES(%s,'asset',%s,'Oprettet fra godkendt fakturalinje') ON CONFLICT DO NOTHING''', (invoice_id, asset['id']))
created.append(asset['id'])
return {'hardware_ids': created, 'already_created': False}
@router.get('/{hardware_id}/document')
async def acquisition_document(hardware_id: int):
asset = _one('SELECT hardware_specs FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,))
source = ((asset or {}).get('hardware_specs') or {}).get('acquisition') or {}
if source.get('source_kind') == 'invoice':
document = _one('''SELECT COALESCE(NULLIF(si.file_path,''),f.file_path) AS file_path
FROM supplier_invoices si LEFT JOIN extractions e ON e.extraction_id=si.extraction_id
LEFT JOIN incoming_files f ON f.file_id=e.file_id WHERE si.id=%s''', (source['source_id'],))
elif source.get('source_kind') == 'file':
document = _one('SELECT file_path FROM incoming_files WHERE file_id=%s', (source['source_id'],))
else:
document = None
path = Path(document['file_path']) if document and document.get('file_path') else None
if not path or not path.is_file():
raise HTTPException(404, 'Det oprindelige fakturabilag findes ikke')
return FileResponse(path, filename=path.name)

View File

@ -1,127 +0,0 @@
"""Read-only Gateway inventory lookup for reviewed asset creation."""
import asyncio
import json
from typing import Literal
from urllib.parse import quote
import aiohttp
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel, Field
from psycopg2.extras import Json
from app.core.database import execute_query
from app.core.config import settings
from app.products.backend.router import _apigw_base_url, _apigw_headers
router = APIRouter(prefix='/hardware-intake/inventory')
def inventory_item(record):
raw = record.get('json_data', record.get('hardware_data')) or {}
if isinstance(raw, str):
raw = json.loads(raw)
hardware = raw.get('hardware') or {}
return dict(id=record['id'], name=record.get('computer_name') or '',
brand=record.get('manufacturer') or hardware.get('manufacturer') or '',
model=record.get('model') or hardware.get('model') or '',
serial_number=record.get('serial_number') or '',
os=record.get('os_caption') or (raw.get('os') or {}).get('caption') or '',
cpu=record.get('cpu_name') or (hardware.get('cpu') or {}).get('name') or '',
ram_gb=record.get('ram_gb', hardware.get('ram_gb')),
last_seen=record.get('last_seen') or record.get('collected_at'),
hardware_specs={'gateway_inventory': {'id':record['id'], 'collected_at':record.get('last_seen') or record.get('collected_at'), 'data':raw, 'name':record.get('computer_name'), 'os':record.get('os_caption') or (raw.get('os') or {}).get('caption'), 'cpu':record.get('cpu_name') or (hardware.get('cpu') or {}).get('name'), 'ram_gb':record.get('ram_gb', hardware.get('ram_gb'))}})
@router.get('')
async def search_inventory(q: str = Query('', max_length=160), by: Literal['name', 'serial'] = 'name', page: int = Query(1, ge=1)):
path = '/api/inventory'
params = {'per_page': 20, 'page': page}
if by == 'serial':
if not q.strip():
raise HTTPException(422, 'Indtast et serienummer')
path += '/serial/' + quote(q.strip(), safe='')
params = {}
elif q.strip():
params['computer_name'] = q.strip()
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(_apigw_base_url() + path, headers=headers, params=params, allow_redirects=False) as response:
if response.status == 404 and by == 'serial':
record = await find_inventory_in_list(session, headers, serial=q.strip())
return {'items': [inventory_item(record)] if record else [], 'pagination': {}}
if response.status != 200:
raise HTTPException(502, 'API Gateway kunne ikke levere inventory. Kontrollér adgang og tilgængelighed.')
payload = await response.json()
records = [payload] if by == 'serial' else payload['data']
return {'items': [inventory_item(record) for record in records], 'pagination': payload.get('pagination', {})}
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
class InventoryPairInput(BaseModel):
inventory_id: int = Field(gt=0)
async def find_inventory_in_list(session, headers, *, inventory_id=None, serial=None):
"""Some deployed Gateways expose the list but lack working detail routes."""
page = 1
while page <= 1000:
async with session.get(f'{_apigw_base_url()}/api/inventory', headers=headers,
params={'per_page': 100, 'page': page}, allow_redirects=False) as response:
if response.status != 200:
raise HTTPException(502, 'Inventory-listen kunne ikke hentes fra API Gateway')
payload = await response.json()
for record in payload['data']:
if inventory_id is not None and str(record['id']) == str(inventory_id):
return record
if serial is not None and str(record.get('serial_number') or '').strip().casefold() == serial.casefold():
return record
total_pages = int(payload['pagination']['total_pages'])
if page >= total_pages:
return None
page += 1
raise HTTPException(502, 'Inventory-listen er for stor til at gennemføre opslaget')
async def fetch_inventory_record(inventory_id: int):
headers = _apigw_headers()
try:
async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=settings.APIGW_TIMEOUT_SECONDS)) as session:
async with session.get(f'{_apigw_base_url()}/api/inventory/{inventory_id}', headers=headers, allow_redirects=False) as response:
if response.status == 404:
record = await find_inventory_in_list(session, headers, inventory_id=inventory_id)
if record is None:
raise HTTPException(404, 'Inventory-maskinen findes ikke længere. Søg igen for at opdatere listen.')
return inventory_item(record)
if response.status != 200:
raise HTTPException(502, 'Inventory-maskinen kunne ikke hentes fra API Gateway')
return inventory_item(await response.json())
except (aiohttp.ClientError, asyncio.TimeoutError, ValueError, KeyError, TypeError, AttributeError):
raise HTTPException(502, 'Inventory kunne ikke læses fra API Gateway') from None
@router.post('/{hardware_id}/pair')
async def pair_inventory(hardware_id: int, payload: InventoryPairInput):
if not execute_query('SELECT id FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,)):
raise HTTPException(404, 'Hardware findes ikke')
item = await fetch_inventory_record(payload.inventory_id)
# Merge in PostgreSQL so concurrent updates and acquisition/ESET/UISP data survive.
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""",
(Json(item['hardware_specs']), hardware_id))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'inventory': item}
@router.delete('/{hardware_id}/pair')
async def unpair_inventory(hardware_id: int):
rows = execute_query("""UPDATE hardware_assets
SET hardware_specs = COALESCE(hardware_specs, '{}'::jsonb) - 'gateway_inventory',
updated_at = NOW()
WHERE id=%s AND deleted_at IS NULL RETURNING id""", (hardware_id,))
if not rows:
raise HTTPException(404, 'Hardware findes ikke')
return {'hardware_id': hardware_id, 'unpaired': True}

View File

@ -8,164 +8,11 @@ from psycopg2.extras import Json
from datetime import datetime, date from datetime import datetime, date
import os import os
import uuid import uuid
import secrets
from fastapi import Header, status
from pydantic import BaseModel, Field
from app.core.config import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
class MobileRecorderProvisionRequest(BaseModel):
"""Payload posted by the Apple Configurator cfgutil provisioning script."""
name: str = Field(min_length=1, max_length=120)
asset_type: str = "mobile_recorder"
manufacturer: str = "Apple"
recorder_number: Optional[int] = Field(default=None, ge=1, le=99999)
model: Optional[str] = Field(default=None, max_length=100)
device_type: Optional[str] = Field(default=None, max_length=100)
serial_number: str = Field(min_length=1, max_length=100)
udid: Optional[str] = Field(default=None, max_length=160)
ecid: Optional[str] = Field(default=None, max_length=160)
imei: Optional[str] = Field(default=None, max_length=40)
wifi_mac: Optional[str] = Field(default=None, max_length=40)
os: str = "iOS"
os_version: Optional[str] = Field(default=None, max_length=40)
supervised: bool = False
status: str = "ready"
def _provisioning_token_or_401(
authorization: Optional[str], x_provisioning_token: Optional[str],
) -> None:
"""Authenticate a headless provisioning client without accepting user JWTs."""
expected = (settings.MOBILE_RECORDER_PROVISIONING_TOKEN or "").strip()
if not expected:
logger.error("Mobile Recorder provisioning was called but no service token is configured")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Provisioning endpoint is disabled: service token is not configured",
)
bearer = (authorization or "").strip()
supplied = (x_provisioning_token or "").strip()
if not supplied and bearer.lower().startswith("bearer "):
supplied = bearer[7:].strip()
if not supplied or not secrets.compare_digest(supplied, expected):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid provisioning token",
headers={"WWW-Authenticate": "Bearer"},
)
def _clean_provisioning_value(value: Optional[str]) -> Optional[str]:
return str(value).strip() if value is not None and str(value).strip() else None
@router.post("/assets/provision", status_code=status.HTTP_200_OK)
async def provision_mobile_recorder(
payload: MobileRecorderProvisionRequest,
authorization: Optional[str] = Header(default=None),
x_provisioning_token: Optional[str] = Header(default=None),
):
"""Create or update an Apple BMC Mobile Recorder by its physical serial number."""
_provisioning_token_or_401(authorization, x_provisioning_token)
if payload.asset_type != "mobile_recorder":
raise HTTPException(status_code=422, detail="asset_type must be mobile_recorder")
if payload.status != "ready":
raise HTTPException(status_code=422, detail="Provisioned Mobile Recorders must use status ready")
serial_number = _clean_provisioning_value(payload.serial_number)
if not serial_number:
raise HTTPException(status_code=422, detail="serial_number is required")
manufacturer = _clean_provisioning_value(payload.manufacturer) or "Apple"
model = _clean_provisioning_value(payload.model) or _clean_provisioning_value(payload.device_type)
recorder_name = _clean_provisioning_value(payload.name)
mobile_specs = {
"recorder_number": payload.recorder_number,
"name": recorder_name,
"udid": _clean_provisioning_value(payload.udid),
"ecid": _clean_provisioning_value(payload.ecid),
"imei": _clean_provisioning_value(payload.imei),
"wifi_mac": _clean_provisioning_value(payload.wifi_mac),
"os": _clean_provisioning_value(payload.os) or "iOS",
"os_version": _clean_provisioning_value(payload.os_version),
"supervised": bool(payload.supervised),
"provisioning_status": "ready",
"source": "apple_configurator",
}
existing = execute_query(
"""SELECT id, hardware_specs FROM hardware_assets
WHERE LOWER(TRIM(serial_number)) = LOWER(TRIM(%s)) AND deleted_at IS NULL
ORDER BY id LIMIT 2""",
(serial_number,),
) or []
if len(existing) > 1:
raise HTTPException(
status_code=409,
detail="More than one active Asset has this serial number; merge the duplicate Assets before provisioning",
)
prior_specs = (existing[0].get("hardware_specs") if existing else {}) or {}
if isinstance(prior_specs, str):
try:
prior_specs = json.loads(prior_specs)
except (TypeError, ValueError):
prior_specs = {}
if not isinstance(prior_specs, dict):
prior_specs = {}
prior_specs["mobile_recorder"] = mobile_specs
if existing:
asset_id = int(existing[0]["id"])
rows = execute_query(
"""UPDATE hardware_assets
SET asset_type = 'mobile_recorder', brand = %s,
model = COALESCE(%s, model), internal_asset_id = %s,
recorder_number = %s, status = 'ready', hardware_specs = %s,
last_provisioned_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP
WHERE id = %s AND deleted_at IS NULL
RETURNING id""",
(manufacturer, model, recorder_name, payload.recorder_number, Json(prior_specs), asset_id),
)
action = "updated"
else:
rows = execute_query(
"""INSERT INTO hardware_assets
(asset_type, brand, model, serial_number, internal_asset_id, recorder_number,
current_owner_type, status, hardware_specs, provisioned_at, last_provisioned_at)
VALUES ('mobile_recorder', %s, %s, %s, %s, %s, 'bmc', 'ready', %s,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id""",
(manufacturer, model, serial_number, recorder_name, payload.recorder_number, Json(prior_specs)),
)
action = "created"
asset_id = int(rows[0]["id"])
execute_query(
"""INSERT INTO hardware_ownership_history
(hardware_id, owner_type, start_date, notes)
VALUES (%s, 'bmc', CURRENT_DATE, 'Created by Apple Configurator provisioning')""",
(asset_id,),
fetch=False,
)
if not rows:
raise HTTPException(status_code=500, detail="Could not persist provisioned Asset")
execute_query(
"""INSERT INTO hardware_provisioning_history (hardware_id, action, payload)
VALUES (%s, %s, %s)""",
(asset_id, action, Json({"serial_number": serial_number, "mobile_recorder": mobile_specs})),
fetch=False,
)
logger.info("Mobile Recorder %s Asset #%s via serial %s", action, asset_id, serial_number)
return {"success": True, "action": action, "asset_id": asset_id, "recorder_number": payload.recorder_number}
def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]: def _eset_extract_first_str(payload: dict, keys: List[str]) -> Optional[str]:
if payload is None: if payload is None:
return None return None
@ -401,10 +248,10 @@ async def list_hardware_by_customer(customer_id: int):
@router.get("/hardware/by-contact/{contact_id}", response_model=List[dict]) @router.get("/hardware/by-contact/{contact_id}", response_model=List[dict])
async def list_hardware_by_contact(contact_id: int, direct_only: bool = False): async def list_hardware_by_contact(contact_id: int):
""" """
List contact hardware, including company hardware by default for legacy callers. List hardware assets linked directly to a contact.
Use direct_only=true for the contact's own hardware tab. Supports both hardware_assets (new) and hardware (legacy) tables.
""" """
# Try new hardware_assets table via hardware_contacts # Try new hardware_assets table via hardware_contacts
query_new = """ query_new = """
@ -426,8 +273,6 @@ async def list_hardware_by_contact(contact_id: int, direct_only: bool = False):
ORDER BY h.created_at DESC ORDER BY h.created_at DESC
""" """
result_new = execute_query(query_new, (contact_id,)) result_new = execute_query(query_new, (contact_id,))
if direct_only:
return result_new or []
# Also look up hardware_assets by the contact's company (customer link) # Also look up hardware_assets by the contact's company (customer link)
query_by_customer = """ query_by_customer = """
@ -520,18 +365,10 @@ async def assign_hardware_to_contact(hardware_id: int, payload: dict):
return {"status": "ok"} return {"status": "ok"}
def _validate_bmc_asset_category(category, owner_type):
if category is not None and category not in ('rental', 'internal'):
raise HTTPException(422, 'Vælg Udlejningsvare eller Internt udstyr som asset-type')
if category is not None and owner_type != 'bmc':
raise HTTPException(422, 'Asset-type gælder kun BMC-ejet udstyr')
@router.post("/hardware", response_model=dict) @router.post("/hardware", response_model=dict)
async def create_hardware(data: dict): async def create_hardware(data: dict):
"""Create a new hardware asset.""" """Create a new hardware asset."""
try: try:
_validate_bmc_asset_category(data.get('bmc_asset_category'), data.get('current_owner_type', 'bmc'))
query = """ query = """
INSERT INTO hardware_assets ( INSERT INTO hardware_assets (
asset_type, brand, model, serial_number, customer_asset_id, current_location_id, asset_type, brand, model, serial_number, customer_asset_id, current_location_id,
@ -540,9 +377,9 @@ async def create_hardware(data: dict):
anydesk_id, anydesk_link, anydesk_id, anydesk_link,
eset_uuid, hardware_specs, eset_group, eset_uuid, hardware_specs, eset_group,
rental_default_start_price, rental_default_freight_price, rental_default_start_price, rental_default_freight_price,
rental_default_preparation_price, rental_default_operations_monthly_price, bmc_asset_category rental_default_preparation_price, rental_default_operations_monthly_price
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %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, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING * RETURNING *
""" """
@ -574,7 +411,6 @@ async def create_hardware(data: dict):
data.get("rental_default_freight_price"), data.get("rental_default_freight_price"),
data.get("rental_default_preparation_price"), data.get("rental_default_preparation_price"),
data.get("rental_default_operations_monthly_price"), data.get("rental_default_operations_monthly_price"),
data.get('bmc_asset_category'),
) )
result = execute_query(query, params) result = execute_query(query, params)
if not result: if not result:
@ -601,8 +437,6 @@ async def create_hardware(data: dict):
execute_query(ownership_query, ownership_params) execute_query(ownership_query, ownership_params)
return hardware return hardware
except HTTPException:
raise
except Exception as e: except Exception as e:
logger.error(f"❌ Failed to create hardware: {str(e)}") logger.error(f"❌ Failed to create hardware: {str(e)}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -864,11 +698,6 @@ async def delete_hardware_network_link(hardware_id: int, link_id: int):
async def update_hardware(hardware_id: int, data: dict): async def update_hardware(hardware_id: int, data: dict):
"""Update hardware asset.""" """Update hardware asset."""
try: try:
if 'bmc_asset_category' in data:
current = execute_query('SELECT current_owner_type FROM hardware_assets WHERE id=%s AND deleted_at IS NULL', (hardware_id,))
if not current:
raise HTTPException(404, 'Hardware not found')
_validate_bmc_asset_category(data['bmc_asset_category'], data.get('current_owner_type', current[0]['current_owner_type']))
# Build dynamic update query # Build dynamic update query
update_fields = [] update_fields = []
params = [] params = []
@ -881,7 +710,7 @@ async def update_hardware(hardware_id: int, data: dict):
"eset_uuid", "hardware_specs", "eset_group", "eset_uuid", "hardware_specs", "eset_group",
"rental_default_start_price", "rental_default_freight_price", "rental_default_start_price", "rental_default_freight_price",
"rental_default_preparation_price", "rental_default_operations_monthly_price", "rental_default_preparation_price", "rental_default_operations_monthly_price",
"location_display_order", "bmc_asset_category" "location_display_order"
] ]
for field in allowed_fields: for field in allowed_fields:
@ -1542,10 +1371,3 @@ async def list_eset_incidents(
""" """
result = execute_query(query, (severity_list, limit)) result = execute_query(query, (severity_list, limit))
return result or [] return result or []
# The intake endpoints use their own prefix and preserve existing hardware CRUD.
from app.modules.hardware.backend.intake import router as hardware_intake_router
router.include_router(hardware_intake_router)
from app.modules.hardware.backend.inventory import router as hardware_inventory_router
router.include_router(hardware_inventory_router)

View File

@ -225,8 +225,7 @@ async def hardware_list(
asset_type: str = Query(None), asset_type: str = Query(None),
rental_scope: str = Query(None), rental_scope: str = Query(None),
customer_id: int = Query(None), customer_id: int = Query(None),
q: str = Query(None), q: str = Query(None)
bmc_asset_category: Optional[str] = Query(None, pattern='^(rental|internal|unassigned)$')
): ):
"""Display list of BMC-owned assets only.""" """Display list of BMC-owned assets only."""
query = """ query = """
@ -251,11 +250,6 @@ async def hardware_list(
""" """
params = [] params = []
if bmc_asset_category == 'unassigned':
query += ' AND ha.bmc_asset_category IS NULL'
elif bmc_asset_category:
query += ' AND ha.bmc_asset_category = %s'
params.append(bmc_asset_category)
if status: if status:
query += " AND ha.status = %s" query += " AND ha.status = %s"
params.append(status) params.append(status)
@ -302,7 +296,6 @@ async def hardware_list(
"current_status": status, "current_status": status,
"current_asset_type": asset_type, "current_asset_type": asset_type,
"current_rental_scope": rental_scope, "current_rental_scope": rental_scope,
"current_bmc_asset_category": bmc_asset_category,
"search_query": q "search_query": q
}) })
@ -445,27 +438,6 @@ async def hardware_eset_import(request: Request):
}) })
@router.get("/hardware/{hardware_id:int}/label", response_class=HTMLResponse)
async def hardware_label(request: Request, hardware_id: int,
size: str = Query("62x40"), base_url: Optional[str] = Query(None, max_length=300)):
from app.core.config import settings
from app.modules.hardware.labels import LABEL_SIZES, hardware_label_url, hardware_label_qr
if size not in LABEL_SIZES:
raise HTTPException(400, detail="Vælg en af de understøttede labelstørrelser")
rows = execute_query("SELECT id, brand, model, serial_number FROM hardware_assets WHERE id = %s AND deleted_at IS NULL", (hardware_id,))
if not rows:
raise HTTPException(404, detail="Hardware not found")
label_base = (base_url or settings.HUB_BASE_URL).strip().rstrip('/')
target_url = hardware_label_url(label_base, hardware_id)
width, height = LABEL_SIZES[size]
return templates.TemplateResponse("modules/hardware/templates/label.html", {
"request": request, "hardware": rows[0], "size": size,
"width": width, "height": height, "base_url": label_base,
"target_url": target_url, "qr_svg": hardware_label_qr(target_url),
}, headers={"Cache-Control": "no-store"})
@router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse) @router.get("/hardware/{hardware_id:int}", response_class=HTMLResponse)
async def hardware_detail(request: Request, hardware_id: int): async def hardware_detail(request: Request, hardware_id: int):
"""Display hardware details.""" """Display hardware details."""
@ -476,8 +448,6 @@ async def hardware_detail(request: Request, hardware_id: int):
raise HTTPException(status_code=404, detail="Hardware not found") raise HTTPException(status_code=404, detail="Hardware not found")
hardware = result[0] hardware = result[0]
specs = hardware.get('hardware_specs') or {}
hardware['acquisition'] = specs.get('acquisition') if isinstance(specs, dict) else None
# Network switches expose their wall-outlet connections as a port map. # Network switches expose their wall-outlet connections as a port map.
switch_ports = [] switch_ports = []
@ -654,11 +624,6 @@ async def hardware_detail(request: Request, hardware_id: int):
ORDER BY hcr.created_at DESC ORDER BY hcr.created_at DESC
""" """
cases = execute_query(case_query, (hardware_id,)) cases = execute_query(case_query, (hardware_id,))
if getattr(request.state,'user_id',None) is not None:
from app.core.case_visibility import hidden_case_ids
hidden_cases=hidden_case_ids({'id':request.state.user_id,
'is_superadmin':getattr(request.state,'case_superadmin',False)})
cases=[row for row in (cases or []) if row['case_id'] not in hidden_cases]
# Get tags # Get tags
tag_query = """ tag_query = """

View File

@ -1,26 +0,0 @@
"""Local QR generation for printable inventory labels. No asset writes."""
from urllib.parse import urlsplit, urlunsplit
import qrcode
from qrcode.image.svg import SvgPathImage
from fastapi import HTTPException
LABEL_SIZES = {'62x40': (62, 40), '62x29': (62, 29), '89x36': (89, 36)}
def hardware_label_url(base_url: str, hardware_id: int) -> str:
try:
parts = urlsplit(base_url.strip())
except ValueError as error:
raise HTTPException(400, "Hub-adressen er ugyldig") from error
if (parts.scheme not in {'http', 'https'} or not parts.hostname
or parts.username or parts.password or parts.query or parts.fragment):
raise HTTPException(400, 'Brug en http- eller https-adresse uden login, søgeparametre eller fragment.')
return urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip('/') + f'/hardware/{hardware_id}', '', ''))
def hardware_label_qr(url: str) -> str:
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
qr.add_data(url)
qr.make(fit=True)
return qr.make_image(image_factory=SvgPathImage).to_string().decode('utf-8')

View File

@ -155,47 +155,6 @@ js{% extends "shared/frontend/base.html" %}
<p>Tilføj et nyt hardware asset til systemet</p> <p>Tilføj et nyt hardware asset til systemet</p>
</div> </div>
<div class="form-card mb-3">
<div class="p-3">
<h2 class="h5 mb-3">Hvordan vil du oprette hardware?</h2>
<div class="d-flex flex-wrap gap-2 mb-3">
<button type="button" class="btn btn-outline-primary" id="intake-mode-invoice" onclick="setHardwareIntakeMode('invoice')">Fra leverandørfaktura</button>
<button type="button" class="btn btn-outline-primary" id="intake-mode-barcode" onclick="setHardwareIntakeMode('barcode')">Stregkode / varenummer</button>
<button type="button" class="btn btn-outline-primary" onclick="openHardwareInventory()">Fra PC / Mac inventory</button>
<button type="button" class="btn btn-primary" id="intake-mode-manual" onclick="setHardwareIntakeMode('manual')">100 % manuelt</button>
</div>
<div id="intake-invoice-panel" hidden>
<label for="intake-invoice-search" class="form-label">Find en eksisterende faktura eller upload en ny</label>
<div class="d-flex gap-2 mb-2">
<input id="intake-invoice-search" class="form-control" placeholder="Leverandør, fakturanummer eller filnavn" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInvoiceSources();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInvoiceSources()">Søg</button>
</div>
<label class="form-label mt-2" for="intake-upload">Ny faktura · PDF eller billede</label>
<input id="intake-upload" class="form-control mb-3" type="file" accept=".pdf,.png,.jpg,.jpeg" onchange="uploadHardwareInvoice()">
<div id="intake-invoice-results" class="list-group mb-3"></div>
<div id="intake-invoice-lines"></div>
</div>
<div id="intake-barcode-panel" hidden>
<label for="intake-code" class="form-label">Scan eller indtast EAN/stregkode eller varenummer</label>
<div class="d-flex gap-2 mb-2">
<input id="intake-code" class="form-control" autocomplete="off" placeholder="Scan koden og tryk Enter" oninput="hardwareIntake.code=null;hardwareIntake.productId=null;" onkeydown="if(event.key==='Enter'){event.preventDefault();lookupHardwareCode();}">
<button type="button" class="btn btn-outline-primary" onclick="lookupHardwareCode()">Slå op</button>
</div>
<div id="intake-product-results" class="list-group"></div>
</div>
<div id="intake-inventory-panel" hidden>
<label for="intake-inventory-search">Find PC / Mac i API Gateway</label>
<div class="d-flex gap-2 mb-2">
<select id="intake-inventory-by" aria-label="Søg efter"><option value="name">Maskinnavn</option><option value="serial">Serienummer (præcist)</option></select>
<input id="intake-inventory-search" class="form-control" placeholder="Maskinnavn eller serienummer" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInventory();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInventory()">Søg</button>
</div>
<div id="intake-inventory-results" class="list-group mb-2"></div>
</div>
<div id="intake-message" class="small text-muted" role="status" aria-live="polite">Udfyld formularen manuelt. Intet oprettes, før du gemmer.</div>
</div>
</div>
<div class="form-card"> <div class="form-card">
<form id="hardwareForm" onsubmit="submitForm(event)"> <form id="hardwareForm" onsubmit="submitForm(event)">
<!-- Basic Information --> <!-- Basic Information -->
@ -203,7 +162,7 @@ js{% extends "shared/frontend/base.html" %}
<h3 class="form-section-title">📋 Grundlæggende Information</h3> <h3 class="form-section-title">📋 Grundlæggende Information</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="asset_type">Hardwaretype <span class="required">*</span></label> <label for="asset_type">Type <span class="required">*</span></label>
<select id="asset_type" name="asset_type" required> <select id="asset_type" name="asset_type" required>
<option value="">Vælg type...</option> <option value="">Vælg type...</option>
<option value="pc">🖥️ PC</option> <option value="pc">🖥️ PC</option>
@ -217,16 +176,6 @@ js{% extends "shared/frontend/base.html" %}
</select> </select>
</div> </div>
<div class="form-group" id="bmcAssetCategoryGroup" >
<label for="bmc_asset_category">Asset-type</label>
<select id="bmc_asset_category" name="bmc_asset_category" >
<option value="">Ikke valgt</option>
<option value="rental">Udlejningsvare</option>
<option value="internal">Internt udstyr</option>
</select>
<small class="text-muted">Kun BMC-ejet udstyr: udlejning eller eget udstyr til intern brug.</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="brand">Mærke</label> <label for="brand">Mærke</label>
<input type="text" id="brand" name="brand" placeholder="fx Dell, HP, Lenovo..."> <input type="text" id="brand" name="brand" placeholder="fx Dell, HP, Lenovo...">
@ -242,23 +191,6 @@ js{% extends "shared/frontend/base.html" %}
<input type="text" id="serial_number" name="serial_number" placeholder="Unik serienummer"> <input type="text" id="serial_number" name="serial_number" placeholder="Unik serienummer">
</div> </div>
<div class="form-group">
<label for="intake-count">Antal fysiske enheder</label>
<input id="intake-count" type="number" min="1" max="100" step="1" value="1" required onchange="updateHardwareIntakeQuantity()">
<small>Én hardwarepost pr. enhed. Serienumre må ikke genbruges.</small>
</div>
<div class="form-group full-width" id="intake-serials-group" hidden>
<label for="intake-serials">Serienumre · ét pr. linje, valgfrit</label>
<textarea id="intake-serials" rows="3" placeholder="Ét serienummer pr. fysisk enhed. Lad feltet være tomt, hvis de ikke kendes."></textarea>
</div>
<div class="form-group">
<label for="intake-unit-cost">Indkøbspris pr. enhed ekskl. moms · valgfri</label>
<input id="intake-unit-cost" type="number" min="0" step="0.01" placeholder="Ukendt">
</div>
<div class="form-group">
<label for="intake-currency">Valuta for indkøbspris</label>
<input id="intake-currency" value="DKK" maxlength="3" pattern="[A-Z]{3}" required>
</div>
<div class="form-group"> <div class="form-group">
<label for="internal_asset_id">Internt Asset ID</label> <label for="internal_asset_id">Internt Asset ID</label>
<input type="text" id="internal_asset_id" name="internal_asset_id" placeholder="BMC-PC-001"> <input type="text" id="internal_asset_id" name="internal_asset_id" placeholder="BMC-PC-001">
@ -389,7 +321,7 @@ js{% extends "shared/frontend/base.html" %}
<!-- Actions --> <!-- Actions -->
<div class="form-actions"> <div class="form-actions">
<a href="/hardware" class="btn btn-secondary">Annuller</a> <a href="/hardware" class="btn btn-secondary">Annuller</a>
<button type="submit" id="intake-save" class="btn btn-primary">💾 Gem hardware</button> <button type="submit" class="btn btn-primary">💾 Gem Hardware</button>
</div> </div>
</form> </form>
</div> </div>
@ -397,13 +329,10 @@ js{% extends "shared/frontend/base.html" %}
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/hardware-intake.js"></script>
<script> <script>
function toggleCustomerSelect() { function toggleCustomerSelect() {
const ownerType = document.getElementById('current_owner_type').value; const ownerType = document.getElementById('current_owner_type').value;
const customerGroup = document.getElementById('customerSelectGroup'); const customerGroup = document.getElementById('customerSelectGroup');
document.getElementById('bmcAssetCategoryGroup').style.display = ownerType === 'bmc' ? '' : 'none';
document.getElementById('bmc_asset_category').disabled = ownerType !== 'bmc';
if (ownerType === 'customer') { if (ownerType === 'customer') {
customerGroup.style.display = 'block'; customerGroup.style.display = 'block';
@ -464,24 +393,19 @@ js{% extends "shared/frontend/base.html" %}
} }
} }
data.bmc_asset_category = data.current_owner_type === 'bmc'
? (document.getElementById('bmc_asset_category').value || null) : null;
try { try {
const response = await fetch('/api/v1/hardware-intake/create', { const response = await fetch('/api/v1/hardware', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(hardwareIntakePayload(data)) body: JSON.stringify(data)
}); });
if (response.ok) { if (response.ok) {
const result = await response.json(); const result = await response.json();
const ids = result.hardware_ids || []; alert('Hardware oprettet!');
alert(`${result.already_created ? 'Allerede oprettet' : 'Oprettet'}: ${ids.length} hardwareenhed${ids.length === 1 ? '' : 'er'}`); window.location.href = `/hardware/${result.id}`;
window.location.href = ids.length === 1 ? `/hardware/${ids[0]}`
: (data.current_owner_type === 'customer' ? '/hardware/customers' : '/hardware');
} else { } else {
const error = await response.json(); const error = await response.json();
alert('Fejl: ' + (error.detail || 'Kunne ikke oprette hardware')); alert('Fejl: ' + (error.detail || 'Kunne ikke oprette hardware'));

View File

@ -27,7 +27,7 @@
} }
.action-card { .action-card {
background: var(--theme-surface, white); background: white;
padding: 1rem; padding: 1rem;
border-radius: 8px; border-radius: 8px;
text-align: center; text-align: center;
@ -56,7 +56,7 @@
.switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; } .switch-port-panel { background: #202a35; border: 5px solid #10161d; border-radius: .7rem; padding: .85rem; }
.switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; } .switch-port-grid { display: grid; grid-template-columns: repeat(24, minmax(44px, 1fr)); gap: .35rem; }
.switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: var(--theme-surface-soft, #f4f6f8); color: var(--theme-text, #263645); font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; } .switch-port-button { min-height: 58px; border-radius: .35rem; border: 2px solid #aeb7c1; background: #f4f6f8; color: #263645; font-size: .72rem; font-weight: 700; display:flex; flex-direction:column; align-items:center; justify-content:center; line-height:1.1; width:100%; }
.switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); } .switch-port-button:hover { transform: translateY(-1px); border-color:#0d6efd; box-shadow:0 0 0 2px rgba(13,110,253,.18); }
.switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; } .switch-port-button.connected { background:#198754; border-color:#146c43; color:#fff; }
.switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; } .switch-port-button.hardware-linked { background:#6f42c1; border-color:#59359f; color:#fff; }
@ -79,7 +79,7 @@
top: 0; top: 0;
bottom: 0; bottom: 0;
width: 2px; width: 2px;
background: var(--theme-surface-soft, #e9ecef); background: #e9ecef;
} }
.timeline-item { .timeline-item {
position: relative; position: relative;
@ -92,7 +92,7 @@
width: 0.8rem; width: 0.8rem;
height: 0.8rem; height: 0.8rem;
border-radius: 50%; border-radius: 50%;
background: var(--theme-surface, white); background: white;
border: 2px solid var(--accent); border: 2px solid var(--accent);
} }
.timeline-item.active .timeline-marker { .timeline-item.active .timeline-marker {
@ -168,25 +168,11 @@
font-size: 0.82rem; font-size: 0.82rem;
color: var(--text-secondary); color: var(--text-secondary);
} }
#hwTabs { gap: .45rem; border-bottom: 0; }
#hwTabs .nav-link { border: 1px solid rgba(0,0,0,.1); border-radius: 999px; color: var(--text-secondary); font-weight: 650; padding: .45rem .8rem; }
#hwTabs .nav-link.active { color: var(--accent); background: var(--accent-light); border-color: rgba(15,76,117,.35); }
.hardware-detail-page .card { border: 1px solid rgba(0,0,0,.08) !important; border-radius: .8rem; box-shadow: 0 5px 16px rgba(15,76,117,.06); }
.inventory-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2rem; }
.inventory-specs h6 { font-size: .9rem; margin-bottom: .8rem; }
.inventory-fields > div { display: grid; grid-template-columns: 135px minmax(0, 1fr); gap: .75rem; padding: .4rem 0; border-bottom: 1px solid var(--bs-border-color, #e5e7eb); font-size: .875rem; }
.inventory-fields dt { color: var(--text-secondary); font-weight: 400; }
.inventory-fields dd { margin: 0; overflow-wrap: anywhere; }
.inventory-specs td { font-size: .85rem; overflow-wrap: anywhere; }
.inventory-specs th { font-size: .8rem; color: var(--text-secondary); font-weight: 500; }
.inventory-software summary { font-size: .9rem; font-weight: 500; cursor: pointer; }
@media (max-width: 767px) { .inventory-grid { grid-template-columns: 1fr; gap: 1.5rem; } .inventory-fields > div { grid-template-columns: 110px minmax(0, 1fr); } }
</style> </style>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="container-fluid hardware-detail-page" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;"> <div class="container-fluid" style="margin-top: 2rem; margin-bottom: 2rem; max-width: 1400px;">
<!-- Top Bar: Back Link + Global Tags --> <!-- Top Bar: Back Link + Global Tags -->
<div class="d-flex justify-content-between align-items-start mb-2"> <div class="d-flex justify-content-between align-items-start mb-2">
@ -268,7 +254,6 @@
<!-- Link to Edit --> <!-- Link to Edit -->
<div class="ms-auto d-flex gap-2"> <div class="ms-auto d-flex gap-2">
<a href="/hardware/{{ hardware.id }}/label" class="btn btn-sm btn-outline-primary" target="_blank" rel="noopener" title="Print label med QR-kode"><i class="bi bi-qr-code me-1"></i>Label</a>
<button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET"> <button onclick="syncEsetData()" class="btn btn-sm btn-outline-secondary" title="Opdater fra ESET">
<i class="bi bi-arrow-repeat"></i> <i class="bi bi-arrow-repeat"></i>
</button> </button>
@ -283,20 +268,6 @@
</div> </div>
</div> </div>
{% set paired_inventory = (hardware.hardware_specs or {}).get('gateway_inventory') %}
<details class="card mb-3 p-3" id="inventoryPairPanel">
<summary>PC / Mac inventory · {{ ('Parret med ' ~ paired_inventory.name) if paired_inventory else 'Par maskine' }}</summary>
<p class="small text-muted mt-2">Vælg en maskine for at gemme koblingen og dens specifikationer. Asset-felter, ejer og øvrige integrationer bevares. Ved ny parring erstattes den tidligere inventory-kobling.</p>
<div class="d-flex gap-2 mb-2">
<select id="intake-inventory-by" class="form-select" aria-label="Søg efter"><option value="name">Maskinnavn</option><option value="serial">Serienummer (præcist)</option></select>
<input id="intake-inventory-search" class="form-control" value="{{ hardware.serial_number or '' }}" placeholder="Maskinnavn eller serienummer" onkeydown="if(event.key==='Enter'){event.preventDefault();findHardwareInventory();}">
<button type="button" class="btn btn-outline-primary" onclick="findHardwareInventory()">Søg</button>
{% if paired_inventory %}<button type="button" class="btn btn-outline-secondary" onclick="unpairHardwareInventory()">Fjern parring</button>{% endif %}
</div>
<div id="intake-message" class="small text-muted" role="status" aria-live="polite"></div>
<div id="intake-inventory-results" class="list-group mt-2"></div>
</details>
<!-- Tabs Navigation --> <!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist"> <ul class="nav nav-tabs mb-4 px-2" id="hwTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -304,48 +275,36 @@
<i class="bi bi-card-text me-2"></i>Hardware Detaljer <i class="bi bi-card-text me-2"></i>Hardware Detaljer
</button> </button>
</li> </li>
{% if hardware.eset_uuid or hardware.hardware_specs or uisp_device or switch_ports %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab"> <button class="nav-link" id="eset-tab" data-bs-toggle="tab" data-bs-target="#eset-specs" type="button" role="tab">
<i class="bi bi-cpu me-2"></i>Specifikationer <i class="bi bi-cpu me-2"></i>Specifikationer
</button> </button>
</li> </li>
{% endif %}
{% if locations or ownership %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab"> <button class="nav-link" id="history-tab" data-bs-toggle="tab" data-bs-target="#history" type="button" role="tab">
<i class="bi bi-clock-history me-2"></i>Historik <i class="bi bi-clock-history me-2"></i>Historik
</button> </button>
</li> </li>
{% endif %}
{% if rental_stats.total_bindings or rental_stats.order_count %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab"> <button class="nav-link" id="prices-tab" data-bs-toggle="tab" data-bs-target="#prices" type="button" role="tab">
<i class="bi bi-cash-coin me-2"></i>Priser <i class="bi bi-cash-coin me-2"></i>Priser
</button> </button>
</li> </li>
{% endif %}
{% if rental_stats.total_bindings or rental_stats.order_count %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab"> <button class="nav-link" id="statistics-tab" data-bs-toggle="tab" data-bs-target="#statistics" type="button" role="tab">
<i class="bi bi-bar-chart-line me-2"></i>Statistik <i class="bi bi-bar-chart-line me-2"></i>Statistik
</button> </button>
</li> </li>
{% endif %}
{% if attachments %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab"> <button class="nav-link" id="files-tab" data-bs-toggle="tab" data-bs-target="#files" type="button" role="tab">
<i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }}) <i class="bi bi-paperclip me-2"></i>Filer ({{ attachments|length }})
</button> </button>
</li> </li>
{% endif %}
{% if hardware.notes %}
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab"> <button class="nav-link" id="notes-tab" data-bs-toggle="tab" data-bs-target="#notes" type="button" role="tab">
<i class="bi bi-sticky me-2"></i>Noter <i class="bi bi-sticky me-2"></i>Noter
</button> </button>
</li> </li>
{% endif %}
</ul> </ul>
<div class="tab-content" id="hwTabsContent"> <div class="tab-content" id="hwTabsContent">
@ -375,29 +334,6 @@
{% endif %} {% endif %}
</span> </span>
</div> </div>
{% if hardware.current_owner_type == 'bmc' %}
<div class="info-row">
<span class="info-label">Asset-type</span>
<span class="info-value">{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(hardware.bmc_asset_category, 'Ikke valgt') }}</span>
</div>
{% endif %}
{% if hardware.acquisition %}
<div class="info-row">
<span class="info-label">Oprettelsesgrundlag</span>
<span class="info-value">
{% if hardware.acquisition.mode == 'invoice' %}
Leverandørfaktura {{ hardware.acquisition.header.invoice_number or hardware.acquisition.header.filename or '' }}
<div class="small text-muted">{{ hardware.acquisition.header.vendor_name or '' }} · {{ hardware.acquisition.line.description }}</div>
{% if hardware.acquisition.header.document_available %}<a href="/api/v1/hardware-intake/{{ hardware.id }}/document" target="_blank" rel="noopener">Åbn fakturabilag</a>{% endif %}
{% elif hardware.acquisition.mode == 'barcode' %}
Stregkode / varenummer: {{ hardware.acquisition.code }}
{% else %}Manuelt oprettet{% endif %}
{% if hardware.acquisition.approved_unit_cost is not none %}
<div class="small text-muted">Indkøb ekskl. moms: {{ hardware.acquisition.approved_unit_cost }} {{ hardware.acquisition.approved_currency }}</div>
{% endif %}
</span>
</div>
{% endif %}
<div class="info-row"> <div class="info-row">
<span class="info-label">Mærke/Model</span> <span class="info-label">Mærke/Model</span>
<span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span> <span class="info-value">{{ hardware.brand or '-' }} / {{ hardware.model or '-' }}</span>
@ -450,7 +386,6 @@
<!-- AnyDesk Card --> <!-- AnyDesk Card -->
{% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %} {% set anydesk_url = hardware.anydesk_id and ('anydesk://' ~ hardware.anydesk_id) %}
{% if anydesk_url %}
<div class="card mb-4 shadow-sm border-0"> <div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
<h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6> <h6 class="text-danger mb-0"><i class="bi bi-display me-2"></i>Remote Access</h6>
@ -483,7 +418,6 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
<!-- Location & Owner Grid --> <!-- Location & Owner Grid -->
<div class="row"> <div class="row">
@ -655,12 +589,6 @@
<!-- Tab: Specs --> <!-- Tab: Specs -->
<div class="tab-pane fade" id="eset-specs" role="tabpanel"> <div class="tab-pane fade" id="eset-specs" role="tabpanel">
{% set inventory = (hardware.hardware_specs or {}).get('gateway_inventory') %}
{% if inventory %}
{% include "modules/hardware/templates/inventory_specs.html" %}
{% endif %}
{% if not inventory or (eset_specs and (eset_specs.os_name or eset_specs.primary_local_ip or eset_specs.cpu_models or eset_specs.deployed_components)) %}
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6> <h6 class="text-primary mb-0"><i class="bi bi-cpu me-2"></i>Specifikationer</h6>
@ -761,12 +689,11 @@
</table> </table>
</div> </div>
{% else %} {% else %}
{% if not inventory %}<div class="text-muted">Ingen specifikationer fundet endnu.</div>{% endif %} <div class="text-muted">Ingen ESET specifikationer fundet endnu.</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% endif %}
{% if switch_ports %} {% if switch_ports %}
<div class="card mt-4 shadow-sm border-0"> <div class="card mt-4 shadow-sm border-0">
<div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center"> <div class="card-header bg-white border-bottom-0 pt-3 ps-3 d-flex justify-content-between align-items-center">
@ -826,16 +753,18 @@
</div> </div>
{% endif %} {% endif %}
{% if hardware.hardware_specs and not inventory %} {% if hardware.hardware_specs %}
<details class="card mt-4 shadow-sm border-0"> <div class="card mt-4 shadow-sm border-0">
<summary class="p-3 text-muted small">Tekniske diagnosedata</summary> <div class="card-header bg-white border-bottom-0 pt-3 ps-3">
<h6 class="text-primary mb-0"><i class="bi bi-shield-check me-2"></i>ESET Data</h6>
</div>
<div class="card-body"> <div class="card-body">
<div class="mb-2 text-muted" style="font-size: 0.85rem;"> <div class="mb-2 text-muted" style="font-size: 0.85rem;">
Specifikationsdata til diagnose Rå data fra ESET (til match/diagnose)
</div> </div>
<pre class="p-3 rounded" style="background: var(--bg-body); border: 1px solid rgba(0,0,0,0.1); max-height: 420px; overflow: auto; font-size: 0.85rem;">{{ hardware.hardware_specs | tojson(indent=2) }}</pre> <pre class="p-3 rounded" style="background: var(--bg-body); border: 1px solid rgba(0,0,0,0.1); max-height: 420px; overflow: auto; font-size: 0.85rem;">{{ hardware.hardware_specs | tojson(indent=2) }}</pre>
</div> </div>
</details> </div>
{% endif %} {% endif %}
</div> </div>
@ -922,13 +851,13 @@
</div> </div>
<div class="col-md-6 col-lg-3"> <div class="col-md-6 col-lg-3">
<div class="price-tile"> <div class="price-tile">
<div class="price-label">Klargøring</div> <div class="price-label">Klargoring</div>
<div class="price-value">{{ "{:,.2f}".format(preparation_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div> <div class="price-value">{{ "{:,.2f}".format(preparation_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div> </div>
</div> </div>
<div class="col-md-6 col-lg-3"> <div class="col-md-6 col-lg-3">
<div class="price-tile"> <div class="price-tile">
<div class="price-label">Drift pr. måned</div> <div class="price-label">Drift pr. maned</div>
<div class="price-value">{{ "{:,.2f}".format(operations_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div> <div class="price-value">{{ "{:,.2f}".format(operations_price).replace(",", "X").replace(".", ",").replace("X", ".") }} kr.</div>
</div> </div>
</div> </div>
@ -1138,22 +1067,11 @@
</select> </select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<label class="form-label" for="quickRentCaseSearch">Kundens åbne sager</label> <label class="form-label">Sag ID</label>
<input type="search" class="form-control mb-2" id="quickRentCaseSearch" placeholder="Søg på titel eller sagsnummer" disabled> <input type="number" class="form-control" id="quickRentSagId" placeholder="fx 123" min="1" required>
<select class="form-select" id="quickRentSagId" required disabled><option value="">Vælg kunde først</option></select>
<div class="small text-muted mt-1" id="quickRentCaseHelp" role="status"></div>
<button type="button" class="btn btn-sm btn-link px-0" id="quickRentNewCaseToggle" disabled onclick="document.getElementById('quickRentNewCasePanel').hidden=!document.getElementById('quickRentNewCasePanel').hidden">+ Opret ny sag til kunden</button>
</div>
<div class="col-12" id="quickRentNewCasePanel" hidden>
<div class="border rounded p-3">
<label class="form-label" for="quickRentNewCaseTitle">Titel på ny sag</label>
<input class="form-control mb-2" id="quickRentNewCaseTitle" placeholder="Fx Udlejning af arbejdscomputer">
<div class="small text-muted mb-2">Sagen knyttes til den valgte kunde og dette asset. Den gemmes med det samme og vælges til udlejningen.</div>
<button type="button" class="btn btn-sm btn-outline-primary" id="quickRentCreateCaseBtn" onclick="createQuickRentCase()">Opret og vælg sag</button>
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Startdato</label> <label class="form-label">Start dato</label>
<input type="date" class="form-control" id="quickRentStartDate" required> <input type="date" class="form-control" id="quickRentStartDate" required>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
@ -1179,7 +1097,7 @@
> >
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Klargøring</label> <label class="form-label">Klargoring</label>
<input <input
type="number" type="number"
class="form-control" class="form-control"
@ -1190,7 +1108,7 @@
> >
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label">Drift pr. måned</label> <label class="form-label">Drift pr. maned</label>
<input <input
type="number" type="number"
class="form-control" class="form-control"
@ -1201,9 +1119,12 @@
required required
> >
</div> </div>
<div class="col-md-4">
<label class="form-label">Drift i ordre (maneder)</label>
<input type="number" class="form-control" id="quickRentInitialMonths" min="1" max="12" value="2">
</div> </div>
<div class="form-text mt-2">Drift oprettes som et løbende månedsabonnement. Opstartsordren indeholder kun startpris, fragt og klargøring.</div> </div>
<div class="form-text mt-2">Standarden er 2 mdr. drift i opstartsordren (1+1).</div>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
@ -1290,7 +1211,7 @@
<label class="form-label fw-bold">Vælg ny lokation</label> <label class="form-label fw-bold">Vælg ny lokation</label>
<input type="text" class="form-control mb-2" id="locationSearchInput" placeholder="🔍 Søg efter lokation..." autocomplete="off"> <input type="text" class="form-control mb-2" id="locationSearchInput" placeholder="🔍 Søg efter lokation..." autocomplete="off">
<div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: var(--theme-surface, #fff);"> <div class="list-group border rounded" id="locationList" style="max-height: 350px; overflow-y: auto; background: #fff;">
{% macro render_location_option(node, depth) %} {% macro render_location_option(node, depth) %}
<div class="location-item-container" data-location-name="{{ node.name | lower }}"> <div class="location-item-container" data-location-name="{{ node.name | lower }}">
<label class="list-group-item list-group-item-action cursor-pointer border-0 py-1 px-2" style="padding-left: {{ depth * 20 + 10 }}px !important;"> <label class="list-group-item list-group-item-action cursor-pointer border-0 py-1 px-2" style="padding-left: {{ depth * 20 + 10 }}px !important;">
@ -1409,14 +1330,6 @@
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="/static/js/hardware-intake.js?v=inventory-pair-1"></script>
<script>
hardwareIntake.pairHardwareId = {{ hardware.id | tojson }};
document.getElementById('intake-inventory-by').value = {{ ('serial' if hardware.serial_number else 'name') | tojson }};
document.getElementById('inventoryPairPanel').addEventListener('toggle', function () {
if (this.open && !hardwareIntake.inventoryItems) findHardwareInventory();
});
</script>
<script> <script>
async function syncEsetData() { async function syncEsetData() {
try { try {
@ -1556,53 +1469,6 @@
})); }));
}); });
let quickRentCaseGeneration = 0;
async function loadQuickRentCases() {
const version = ++quickRentCaseGeneration;
const customer = document.getElementById('quickRentCustomerId').value;
const select = document.getElementById('quickRentSagId');
const help = document.getElementById('quickRentCaseHelp');
select.innerHTML = '<option value="">Vælg sag</option>';
select.disabled = true;
document.getElementById('quickRentCaseSearch').disabled = !customer;
document.getElementById('quickRentNewCaseToggle').disabled = !customer;
refreshQuickRentPlan();
help.textContent = customer ? 'Henter kundens åbne sager…' : '';
if (!customer) return;
try {
const query = document.getElementById('quickRentCaseSearch').value;
const data = await intakeFetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/cases?customer_id=${encodeURIComponent(customer)}&q=${encodeURIComponent(query)}`);
if (version !== quickRentCaseGeneration) return;
for (const item of data.cases) select.add(new Option(`#${item.id} · ${item.titel} · ${item.status}`, item.id));
select.disabled = !data.cases.length;
help.textContent = data.has_more ? 'Viser de første 100 sager. Søg for at indsnævre listen.' : (data.cases.length ? 'Kun kundens åbne sager vises.' : 'Ingen åbne sager fundet. Du kan oprette en ny sag nedenfor.');
} catch (error) { if (version === quickRentCaseGeneration) help.textContent = error.message; }
}
async function createQuickRentCase() {
const customer = document.getElementById('quickRentCustomerId').value;
const title = document.getElementById('quickRentNewCaseTitle').value.trim();
if (!customer || !title) { document.getElementById('quickRentCaseHelp').textContent = 'Vælg kunde og angiv en titel.'; return; }
const button = document.getElementById('quickRentCreateCaseBtn');
button.disabled = true;
try {
const item = await intakeFetch('/api/v1/sag', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({titel:title,customer_id:Number(customer),type:'ticket',status:'Ny',hardware_ids:[{{ hardware.id }}]})});
if (customer !== document.getElementById('quickRentCustomerId').value) {
document.getElementById('quickRentCaseHelp').textContent = `Sag #${item.id} blev oprettet til den tidligere valgte kunde.`;
return;
}
++quickRentCaseGeneration;
const select = document.getElementById('quickRentSagId');
select.add(new Option(`#${item.id} · ${item.titel} · Ny`,item.id));
select.disabled = false;
select.value = String(item.id);
document.getElementById('quickRentNewCasePanel').hidden = true;
document.getElementById('quickRentNewCaseTitle').value = '';
document.getElementById('quickRentCaseHelp').textContent = `Sag #${item.id} er oprettet og knyttet til dette asset.`;
refreshQuickRentPlan();
} catch (error) { document.getElementById('quickRentCaseHelp').textContent = error.message; }
finally { button.disabled = false; }
}
async function submitQuickRent() { async function submitQuickRent() {
const customerId = Number(document.getElementById('quickRentCustomerId').value || 0); const customerId = Number(document.getElementById('quickRentCustomerId').value || 0);
const sagId = Number(document.getElementById('quickRentSagId').value || 0); const sagId = Number(document.getElementById('quickRentSagId').value || 0);
@ -1611,6 +1477,7 @@
const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0); const freightPrice = Number(document.getElementById('quickRentFreightPrice').value || 0);
const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0); const preparationPrice = Number(document.getElementById('quickRentPreparationPrice').value || 0);
const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0); const operationsMonthlyPrice = Number(document.getElementById('quickRentOperationsMonthlyPrice').value || 0);
const initialOperationsMonths = Number(document.getElementById('quickRentInitialMonths').value || 2);
if (!customerId || !sagId || !startDate) { if (!customerId || !sagId || !startDate) {
alert('Kunde, sag og startdato er påkrævet.'); alert('Kunde, sag og startdato er påkrævet.');
@ -1637,7 +1504,7 @@
freight_price: freightPrice, freight_price: freightPrice,
preparation_price: preparationPrice, preparation_price: preparationPrice,
operations_monthly_price: operationsMonthlyPrice, operations_monthly_price: operationsMonthlyPrice,
initial_operations_months: 0, initial_operations_months: initialOperationsMonths,
notice_period_days: 30 notice_period_days: 30
}) })
}); });
@ -1682,14 +1549,13 @@
if (!customerId || !sagId) { if (!customerId || !sagId) {
infoEl.className = 'alert alert-secondary py-2 small mb-3'; infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.'; infoEl.textContent = 'Vælg kunde og sag for at se, hvad der bliver oprettet.';
submitBtn.disabled = true; submitBtn.disabled = false;
return; return;
} }
try { try {
infoEl.className = 'alert alert-secondary py-2 small mb-3'; infoEl.className = 'alert alert-secondary py-2 small mb-3';
infoEl.textContent = 'Tjekker abonnement på sagen...'; infoEl.textContent = 'Tjekker abonnement på sagen...';
submitBtn.disabled = true;
const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`); const response = await fetch(`/api/v1/hardware/{{ hardware.id }}/quick-rent/preview?customer_id=${customerId}&sag_id=${sagId}`);
const data = await response.json(); const data = await response.json();
@ -1698,7 +1564,6 @@
throw new Error((data && data.detail) || 'Preview fejlede'); throw new Error((data && data.detail) || 'Preview fejlede');
} }
if (customerId !== Number(document.getElementById('quickRentCustomerId').value) || sagId !== Number(document.getElementById('quickRentSagId').value)) return;
submitBtn.disabled = !data.can_submit; submitBtn.disabled = !data.can_submit;
if (data.action === 'reuse') { if (data.action === 'reuse') {
@ -1711,7 +1576,6 @@
infoEl.textContent = data.message || 'Klar.'; infoEl.textContent = data.message || 'Klar.';
} catch (error) { } catch (error) {
if (customerId !== Number(document.getElementById('quickRentCustomerId').value) || sagId !== Number(document.getElementById('quickRentSagId').value)) return;
submitBtn.disabled = false; submitBtn.disabled = false;
infoEl.className = 'alert alert-danger py-2 small mb-3'; infoEl.className = 'alert alert-danger py-2 small mb-3';
infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`; infoEl.textContent = `Fejl i forhåndsvisning: ${error.message}. Du kan stadig prøve at oprette.`;
@ -1843,12 +1707,10 @@
quickRentStartDate.value = new Date().toISOString().slice(0, 10); quickRentStartDate.value = new Date().toISOString().slice(0, 10);
} }
if (quickRentCustomerId) { if (quickRentCustomerId) {
quickRentCustomerId.addEventListener('change', () => { document.getElementById('quickRentCaseSearch').value=''; document.getElementById('quickRentNewCasePanel').hidden=true; document.getElementById('quickRentNewCaseTitle').value=''; loadQuickRentCases(); }); quickRentCustomerId.addEventListener('change', refreshQuickRentPlan);
let caseSearchTimer;
document.getElementById('quickRentCaseSearch').addEventListener('input', () => { clearTimeout(caseSearchTimer); caseSearchTimer=setTimeout(loadQuickRentCases,200); });
} }
if (quickRentSagId) { if (quickRentSagId) {
quickRentSagId.addEventListener('change', refreshQuickRentPlan); quickRentSagId.addEventListener('input', refreshQuickRentPlan);
} }
if (quickRentModal) { if (quickRentModal) {
quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan); quickRentModal.addEventListener('shown.bs.modal', refreshQuickRentPlan);

View File

@ -162,7 +162,7 @@
<h3 class="form-section-title">📋 Grundlæggende Information</h3> <h3 class="form-section-title">📋 Grundlæggende Information</h3>
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="asset_type">Hardwaretype <span class="required">*</span></label> <label for="asset_type">Type <span class="required">*</span></label>
<select id="asset_type" name="asset_type" required> <select id="asset_type" name="asset_type" required>
<option value="">Vælg type...</option> <option value="">Vælg type...</option>
<option value="pc" {% if hardware.asset_type == 'pc' %}selected{% endif %}>🖥️ PC</option> <option value="pc" {% if hardware.asset_type == 'pc' %}selected{% endif %}>🖥️ PC</option>
@ -176,16 +176,6 @@
</select> </select>
</div> </div>
<div class="form-group" id="bmcAssetCategoryGroup" {% if hardware.current_owner_type != 'bmc' %}style="display:none"{% endif %}>
<label for="bmc_asset_category">Asset-type</label>
<select id="bmc_asset_category" name="bmc_asset_category" {% if hardware.current_owner_type != 'bmc' %}disabled{% endif %}>
<option value="">Ikke valgt</option>
<option value="rental" {% if hardware.bmc_asset_category == 'rental' %}selected{% endif %}>Udlejningsvare</option>
<option value="internal" {% if hardware.bmc_asset_category == 'internal' %}selected{% endif %}>Internt udstyr</option>
</select>
<small class="text-muted">Kun BMC-ejet udstyr: udlejning eller eget udstyr til intern brug.</small>
</div>
<div class="form-group"> <div class="form-group">
<label for="brand">Mærke</label> <label for="brand">Mærke</label>
<input type="text" id="brand" name="brand" value="{{ hardware.brand or '' }}" placeholder="fx Dell, HP, Lenovo..."> <input type="text" id="brand" name="brand" value="{{ hardware.brand or '' }}" placeholder="fx Dell, HP, Lenovo...">
@ -371,8 +361,6 @@
function toggleCustomerSelect() { function toggleCustomerSelect() {
const ownerType = document.getElementById('current_owner_type').value; const ownerType = document.getElementById('current_owner_type').value;
const customerGroup = document.getElementById('customerSelectGroup'); const customerGroup = document.getElementById('customerSelectGroup');
document.getElementById('bmcAssetCategoryGroup').style.display = ownerType === 'bmc' ? '' : 'none';
document.getElementById('bmc_asset_category').disabled = ownerType !== 'bmc';
if (ownerType === 'customer') { if (ownerType === 'customer') {
customerGroup.style.display = 'block'; customerGroup.style.display = 'block';
@ -406,9 +394,6 @@
} }
} }
data.bmc_asset_category = data.current_owner_type === 'bmc'
? (document.getElementById('bmc_asset_category').value || null) : null;
try { try {
const response = await fetch('/api/v1/hardware/{{ hardware.id }}', { const response = await fetch('/api/v1/hardware/{{ hardware.id }}', {
method: 'PATCH', method: 'PATCH',

View File

@ -289,16 +289,6 @@
</select> </select>
</div> </div>
<div class="filter-group">
<label for="bmc_asset_category">Asset-type</label>
<select name="bmc_asset_category" id="bmc_asset_category">
<option value="">Alle asset-typer</option>
<option value="rental" {% if current_bmc_asset_category == 'rental' %}selected{% endif %}>Udlejningsvare</option>
<option value="internal" {% if current_bmc_asset_category == 'internal' %}selected{% endif %}>Internt udstyr</option>
<option value="unassigned" {% if current_bmc_asset_category == 'unassigned' %}selected{% endif %}>Ikke valgt</option>
</select>
</div>
<div class="filter-group"> <div class="filter-group">
<label for="rental_scope">Udlejning</label> <label for="rental_scope">Udlejning</label>
<select name="rental_scope" id="rental_scope"> <select name="rental_scope" id="rental_scope">
@ -356,10 +346,6 @@
<span class="hardware-detail-label">Type:</span> <span class="hardware-detail-label">Type:</span>
<span class="hardware-detail-value">{{ item.asset_type|title }}</span> <span class="hardware-detail-value">{{ item.asset_type|title }}</span>
</div> </div>
<div class="hardware-detail-row">
<span class="hardware-detail-label">Asset-type:</span>
<span class="hardware-detail-value">{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(item.bmc_asset_category, 'Ikke valgt') }}</span>
</div>
{% if item.anydesk_id or item.anydesk_link %} {% if item.anydesk_id or item.anydesk_link %}
<div class="hardware-detail-row"> <div class="hardware-detail-row">
<span class="hardware-detail-label">AnyDesk:</span> <span class="hardware-detail-label">AnyDesk:</span>
@ -395,7 +381,7 @@
{% if item.is_currently_rented %} {% if item.is_currently_rented %}
Udlejet Udlejet
{% else %} {% else %}
Ikke udlejet Ledig/Intern
{% endif %} {% endif %}
</span> </span>
</div> </div>
@ -408,7 +394,6 @@
<div class="hardware-actions" onclick="event.stopPropagation()"> <div class="hardware-actions" onclick="event.stopPropagation()">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a> <a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a> <a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
</div> </div>
</div> </div>
</div> </div>
@ -422,8 +407,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Hardware</th> <th>Hardware</th>
<th>Hardwaretype</th> <th>Type</th>
<th>Asset-type</th>
<th>Serienr.</th> <th>Serienr.</th>
<th>Ejer</th> <th>Ejer</th>
<th>Udlejning</th> <th>Udlejning</th>
@ -437,14 +421,13 @@
<tr> <tr>
<td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td> <td class="fw-semibold">{{ item.brand or 'Unknown' }} {{ item.model or '' }}</td>
<td>{{ item.asset_type|title }}</td> <td>{{ item.asset_type|title }}</td>
<td>{{ {'rental': 'Udlejningsvare', 'internal': 'Internt udstyr'}.get(item.bmc_asset_category, 'Ikke valgt') }}</td>
<td>{{ item.serial_number or 'Ingen serienummer' }}</td> <td>{{ item.serial_number or 'Ingen serienummer' }}</td>
<td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td> <td>{{ item.customer_name or (item.current_owner_type|title if item.current_owner_type else '—') }}</td>
<td> <td>
{% if item.is_currently_rented %} {% if item.is_currently_rented %}
<span class="status-badge" style="background-color:#0f4c75;color:#fff;">Udlejet</span> <span class="status-badge" style="background-color:#0f4c75;color:#fff;">Udlejet</span>
{% else %} {% else %}
<span class="status-badge" style="background-color:#6c757d;color:#fff;">Ikke udlejet</span> <span class="status-badge" style="background-color:#6c757d;color:#fff;">Ledig/Intern</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -464,7 +447,6 @@
<td class="text-end"> <td class="text-end">
<a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a> <a href="/hardware/{{ item.id }}" class="btn-action">👁️ Se</a>
<a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a> <a href="/hardware/{{ item.id }}/edit" class="btn-action">✏️ Rediger</a>
<a href="/hardware/{{ item.id }}/label" class="btn-action" target="_blank" rel="noopener"><i class="bi bi-qr-code"></i> Label</a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -486,7 +468,7 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
// Auto-submit filter form on change // Auto-submit filter form on change
document.querySelectorAll('#asset_type, #status, #rental_scope, #bmc_asset_category').forEach(select => { document.querySelectorAll('#asset_type, #status, #rental_scope').forEach(select => {
select.addEventListener('change', () => { select.addEventListener('change', () => {
select.form.submit(); select.form.submit();
}); });

View File

@ -1,53 +0,0 @@
{% set data = inventory.get('data') or {} %}
{% set machine = data.get('hardware') or {} %}
{% set identity = data.get('identity') or {} %}
{% set os = data.get('os') or {} %}
{% set network = data.get('network') or {} %}
{% set cpu = machine.get('cpu') or {} %}
{% macro fields(items) %}
<dl class="inventory-fields mb-0">
{% for label, value in items %}{% if value is not none and value != '' %}
<div><dt>{{ label }}</dt><dd>{{ value }}</dd></div>
{% endif %}{% endfor %}
</dl>
{% endmacro %}
<div class="card border-0 shadow-sm mb-3 inventory-specs">
<div class="card-body">
<div class="d-flex flex-wrap justify-content-between gap-2 mb-3">
<div><div class="text-muted small">PC / Mac inventory</div><h5 class="mb-1">{{ inventory.get('name') or identity.get('computer_name') or 'Parret maskine' }}</h5><span class="text-muted">{{ machine.get('manufacturer') or '' }} {{ machine.get('model') or '' }}</span></div>
<div class="small text-muted">Indsamlet<br><strong>{{ (inventory.get('collected_at') or data.get('collected_at') or 'Ukendt') | replace('T', ' ') }}</strong></div>
</div>
<div class="inventory-grid">
<section><h6><i class="bi bi-cpu me-2"></i>Hardware</h6>
{{ fields([('Serienummer', machine.get('serial') or machine.get('serial_number')), ('Processor', inventory.get('cpu') or cpu.get('name')), ('Kerner / tråde', (cpu.get('cores', '–') ~ ' / ' ~ cpu.get('threads', '–')) if cpu else none), ('Hukommelse', ((inventory.get('ram_gb') or machine.get('ram_gb')) ~ ' GB') if (inventory.get('ram_gb') or machine.get('ram_gb')) else none), ('Grafikkort', machine.get('gpu')), ('BIOS-version', machine.get('bios_version')), ('BIOS-dato', machine.get('bios_date'))]) }}
</section>
<section><h6><i class="bi bi-window me-2"></i>System</h6>
{{ fields([('Operativsystem', inventory.get('os') or os.get('caption')), ('Version', os.get('version')), ('Build', os.get('build')), ('Installeret', os.get('install_date')), ('Seneste opstart', os.get('last_boot')), ('Oppetid', (os.get('uptime_hours') ~ ' timer') if os.get('uptime_hours') is not none else none), ('Domæne', identity.get('domain')), ('Bruger', identity.get('user')), ('Offentlig IP', network.get('wan_ip'))]) }}
</section>
</div>
{% if machine.get('disks') %}
<section class="mt-4"><h6><i class="bi bi-device-hdd me-2"></i>Diske <span class="text-muted">({{ machine.disks | length }})</span></h6>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Model</th><th>Forbindelse</th><th class="text-end">Kapacitet</th></tr></thead><tbody>
{% for disk in machine.disks %}<tr><td>{{ disk.get('model') or '–' }}</td><td>{{ disk.get('interface') or '–' }}</td><td class="text-end text-nowrap">{{ (disk.size_gb ~ ' GB') if disk.get('size_gb') is not none else '–' }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
{% if network.get('adapters') %}
<section class="mt-4"><h6><i class="bi bi-ethernet me-2"></i>Netværk</h6>
<div class="table-responsive"><table class="table table-sm mb-0"><thead><tr><th>Netværkskort</th><th>IP-adresser</th><th>MAC-adresse</th><th>DHCP</th></tr></thead><tbody>
{% for adapter in network.adapters %}<tr><td>{{ adapter.get('description') or '–' }}</td><td>{% set ips = adapter.get('ip') or [] %}{% if ips is string %}{{ ips }}{% else %}{% for ip in ips %}<div>{{ ip }}</div>{% else %}–{% endfor %}{% endif %}</td><td class="text-nowrap">{{ adapter.get('mac') or '–' }}</td><td>{{ 'Ja' if adapter.get('dhcp_enabled') == true else ('Nej' if adapter.get('dhcp_enabled') == false else '–') }}</td></tr>{% endfor %}
</tbody></table></div>
</section>
{% endif %}
{% set defender = (data.get('security') or {}).get('windows_defender') %}
{% if defender %}<section class="mt-4"><h6><i class="bi bi-shield-check me-2"></i>Windows Defender</h6>
{{ fields([('Aktiveret', 'Ja' if defender.get('enabled') == true else ('Nej' if defender.get('enabled') == false else 'Ukendt')), ('Realtidsbeskyttelse', 'Ja' if defender.get('real_time_protection') == true else ('Nej' if defender.get('real_time_protection') == false else 'Ukendt')), ('Opdateret', defender.get('updated'))]) }}</section>{% endif %}
{% if data.get('software') %}
<details class="inventory-software mt-4"><summary>Installerede programmer <span class="text-muted">({{ data.software | length }})</span></summary>
<div class="table-responsive mt-2" style="max-height: 420px; overflow: auto;"><table class="table table-sm mb-0"><thead><tr><th>Program</th><th>Version</th><th>Udgiver</th></tr></thead><tbody>
{% for program in data.software %}<tr><td>{{ program.get('name') or '–' }}</td><td>{{ program.get('version') or '–' }}</td><td>{{ program.get('publisher') or '–' }}</td></tr>{% endfor %}
</tbody></table></div>
</details>
{% endif %}
</div>
</div>

View File

@ -1,34 +0,0 @@
<!doctype html>
<html lang="da">
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Label · BMC-{{ '%05d'|format(hardware.id) }}</title>
<style>
*{box-sizing:border-box}body{margin:0;background:#e8eef4;color:#243449;font-family:Arial,Helvetica,sans-serif}
.controls{max-width:760px;margin:32px auto 22px;padding:22px;background:#fff;border:1px solid #bdcbd5;border-radius:12px}
h1{font-size:21px;margin:0 0 7px}p{font-size:13px;color:#65758a;line-height:1.5}form{display:grid;grid-template-columns:150px 1fr;gap:12px}label{font-size:12px;font-weight:600;display:block;margin-bottom:5px}input,select{width:100%;padding:9px;border:1px solid #bdcbd5;border-radius:7px;background:#fff;color:#243449}button,.back{display:inline-block;padding:10px 14px;border-radius:7px;border:1px solid #336699;font-size:13px;cursor:pointer;text-decoration:none}button{background:#336699;color:#fff}.back{background:#fff;color:#336699}.buttons{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap}.target{overflow-wrap:anywhere}.preview{display:flex;justify-content:center;padding:10px 12px 40px}
.asset-label{width:{{ width }}mm;height:{{ height }}mm;display:grid;grid-template-columns:minmax(0,1fr) {{ 21 if height == 29 else 27 }}mm;gap:1.5mm;padding:2.5mm;background:#fff;color:#111;box-shadow:0 8px 30px #23374d26;outline:1px solid #bdcbd5;overflow:hidden}
.label-copy{display:flex;flex-direction:column;min-width:0;justify-content:space-between}.brand{font-size:7pt;font-weight:800;letter-spacing:.3pt;border-bottom:.35mm solid #111;padding-bottom:1mm}.name{font-size:{{ 8 if height == 29 else 10 }}pt;font-weight:700;line-height:1.15;overflow-wrap:anywhere;margin:1.2mm 0;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.asset-id{font-size:9pt;font-weight:800;letter-spacing:.4pt}.serial{font-size:6pt;line-height:1.2;overflow-wrap:anywhere;margin-top:.7mm}.qr{align-self:center;display:flex;flex-direction:column;align-items:center}.qr svg{display:block;width:100%;height:auto}.qr small{font-size:5.5pt;font-weight:600;margin-top:.3mm;text-align:center}
@page{size:{{ width }}mm {{ height }}mm;margin:0}
@media print{html,body{width:{{ width }}mm;height:{{ height }}mm;background:#fff;margin:0}.controls{display:none}.preview{display:block;padding:0}.asset-label{box-shadow:none;outline:0;break-inside:avoid;print-color-adjust:exact;-webkit-print-color-adjust:exact}}
@media(max-width:600px){.controls{margin:12px;padding:16px}form{grid-template-columns:1fr}.buttons{grid-column:1}}
</style>
</head>
<body>
<section class="controls">
<h1>Label til jeres hardware</h1><p>Print i 100 % størrelse uden sidehoved og sidefod. QR-koden åbner hardwarekortet i Hub.</p>
<form method="get">
<div><label for="size">Labelstørrelse</label><select id="size" name="size">{% for value, text in [('62x40','62 × 40 mm'),('62x29','62 × 29 mm'),('89x36','89 × 36 mm')] %}<option value="{{ value }}" {% if size == value %}selected{% endif %}>{{ text }}</option>{% endfor %}</select></div>
<div><label for="base_url">Hub-adresse til QR-koden</label><input type="url" id="base_url" name="base_url" value="{{ base_url }}" required><small>Brug en adresse, telefonen kan åbne.</small></div>
<div class="buttons"><button type="submit">Opdater label</button><button type="button" onclick="window.print()">Print label</button><a class="back" href="/hardware/{{ hardware.id }}">Tilbage til delen</a></div>
</form>
<p class="target">QR-link: <a href="{{ target_url }}">{{ target_url }}</a></p>
</section>
<div class="preview">
<article class="asset-label" aria-label="Hardwarelabel">
<div class="label-copy"><div class="brand">BMC NETWORKS · UDSTYR</div><div class="name">{{ hardware.brand or '' }} {{ hardware.model or 'Hardware' }}</div><div><div class="asset-id">BMC-{{ '%05d'|format(hardware.id) }}</div>{% if hardware.serial_number %}<div class="serial">S/N {{ hardware.serial_number }}</div>{% endif %}</div></div>
<div class="qr">{{ qr_svg|safe }}<small>Scan for hardwarekort</small></div>
</article>
</div>
</body>
</html>

View File

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

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