Compare commits
No commits in common. "main" and "checkpoint-before-bottom-bar-review-20260817" have entirely different histories.
main
...
checkpoint
34
.env.example
34
.env.example
@ -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)
|
||||||
# =====================================================
|
# =====================================================
|
||||||
|
|||||||
@ -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
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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`.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -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.
|
|
||||||
@ -1 +0,0 @@
|
|||||||
"""Restricted administrative reporting modules."""
|
|
||||||
@ -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)
|
|
||||||
@ -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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 %}
|
|
||||||
@ -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),
|
|
||||||
))
|
|
||||||
@ -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"'},
|
|
||||||
)
|
|
||||||
@ -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})
|
|
||||||
@ -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}
|
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAiLineItems(lines, reportedCount) {
|
|
||||||
const container = document.getElementById('aiLineItemsResult');
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const count = Array.isArray(lines) ? lines.length : Number(reportedCount || 0);
|
|
||||||
if (!count) {
|
|
||||||
container.className = 'alert alert-warning mb-3';
|
|
||||||
container.innerHTML = '<i class="bi bi-exclamation-triangle me-2"></i><strong>Ingen varelinjer fundet.</strong> Kontrollér teksten eller angiv et linjemønster manuelt.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = {
|
|
||||||
line_number: 'Linje', position: 'Position', item_number: 'Varenummer', sku: 'SKU',
|
|
||||||
description: 'Beskrivelse / model', quantity: 'Antal', unit_price: 'Stykpris',
|
|
||||||
line_total: 'Linjebeløb', total_price: 'Samlet beløb', ean: 'EAN', kn8: 'KN8',
|
|
||||||
serial_numbers: 'Serienummer', reverse_charge: 'Omvendt betalingspligt',
|
|
||||||
vat_rate: 'Momssats', raw_text: 'Rå tekst'
|
|
||||||
};
|
|
||||||
const itemCards = (lines || []).map((line, index) => {
|
|
||||||
const fields = Object.entries(line)
|
|
||||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
|
||||||
.map(([key, value]) => {
|
|
||||||
let displayValue = Array.isArray(value) ? value.join(', ') : value;
|
|
||||||
if (typeof value === 'boolean') displayValue = value ? 'Ja' : 'Nej';
|
|
||||||
return `<div class="col-md-6 col-xl-4 mb-2">
|
|
||||||
<div class="small text-muted">${templateEscapeHtml(labels[key] || key)}</div>
|
|
||||||
<div class="text-break">${templateEscapeHtml(displayValue)}</div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
const heading = line.item_number || line.sku || `Linje ${index + 1}`;
|
|
||||||
return `<div class="card mb-2">
|
|
||||||
<div class="card-header py-2 fw-semibold">Vare ${index + 1}: ${templateEscapeHtml(heading)}</div>
|
|
||||||
<div class="card-body py-2"><div class="row">${fields}</div></div>
|
|
||||||
</div>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
container.className = 'alert alert-success mb-3 p-3';
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="fw-bold mb-2"><i class="bi bi-check-circle me-2"></i>AI fandt ${count} varelinjer</div>
|
|
||||||
<div>${itemCards}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveTemplate() {
|
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;
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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
|
|
||||||
@ -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"""
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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',
|
|
||||||
)
|
|
||||||
@ -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)
|
|
||||||
@ -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
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"):
|
||||||
|
|||||||
@ -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), []),
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
@ -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
@ -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
|
||||||
|
|||||||
@ -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
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -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")
|
|
||||||
@ -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
|
||||||
|
|||||||
@ -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.')
|
|
||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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"}),
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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}))
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>')
|
|
||||||
.replaceAll('"', '"').replaceAll("'", ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
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 %}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
|
||||||
@ -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}
|
|
||||||
@ -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)
|
|
||||||
|
|||||||
@ -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 = """
|
||||||
|
|||||||
@ -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')
|
|
||||||
@ -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'));
|
||||||
|
|||||||
@ -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,19 +1067,8 @@
|
|||||||
</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">Start dato</label>
|
<label class="form-label">Start dato</label>
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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>
|
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user