Compare commits

..

No commits in common. "c9f04c77b4e95aa0a243582ea532ae513c3cd34f" and "5f603bdd2edaa4df48ebf43d6ef62000b449f214" have entirely different histories.

99 changed files with 1071 additions and 18659 deletions

View File

@ -10,7 +10,6 @@ RUN apt-get update && apt-get install -y \
gcc \ gcc \
g++ \ g++ \
python3-dev \ python3-dev \
postgresql-client \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Build arguments for GitHub release deployment # Build arguments for GitHub release deployment

Binary file not shown.

View File

@ -1,248 +0,0 @@
# Migration Guide - Supplier Invoice Enhancements (v2.0.0)
## 🎯 Hvad migreres:
### Database Changes:
- ✅ `supplier_invoice_lines`: Nye kolonner (contra_account, line_purpose, resale_customer_id, resale_order_number)
- ✅ `economic_accounts`: Ny tabel til e-conomic kontoplan cache
### Backend Changes:
- ✅ e-conomic accounts API integration
- ✅ Line item update endpoint med modkonto support
### Frontend Changes:
- ✅ 3 nye faneblade (Til Betaling, Klar til Bogføring, Varelinjer)
- ✅ Inline redigering af modkonto og formål
- ✅ Backup version på /billing/supplier-invoices2
---
## 📋 Pre-Migration Checklist:
- [ ] Commit alle ændringer til git
- [ ] Test på lokal udvikling fungerer
- [ ] Backup af production database
- [ ] Tag ny version (v2.0.0)
- [ ] Push til Gitea
---
## 🚀 Migration Steps:
### Step 1: Commit og Tag Release
```bash
cd /Users/christianthomas/DEV/bmc_hub_dev
# Commit ændringer
git add .
git commit -m "Supplier invoice enhancements v2.0.0
- Added modkonto (contra_account) support per line
- Added line_purpose tracking (resale, internal, project, stock)
- Added e-conomic accounts API integration
- Redesigned frontend with 3 tabs: Payment, Ready for Booking, Line Items
- Database migration 1000 included
- Backup version available at /billing/supplier-invoices2"
# Opdater VERSION fil
echo "2.0.0" > VERSION
git add VERSION
git commit -m "Bump version to 2.0.0"
# Tag release
git tag v2.0.0
git push origin main
git push origin v2.0.0
```
### Step 2: Backup Production Database
```bash
# SSH til production
ssh bmcadmin@172.16.31.183
# Backup database
cd /srv/podman/bmc_hub_v1.0
podman exec bmc-hub-postgres-prod pg_dump -U bmc_hub bmc_hub > backup_pre_v2.0.0_$(date +%Y%m%d_%H%M%S).sql
# Verificer backup
ls -lh backup_pre_v2.0.0_*.sql
```
### Step 3: Deploy ny Version
Fra lokal Mac:
```bash
cd /Users/christianthomas/DEV/bmc_hub_dev
# Kør deployment script
./deploy_to_prod.sh v2.0.0
```
Dette script:
1. Opdaterer RELEASE_VERSION i .env
2. Stopper containers
3. Bygger nyt image fra Gitea tag v2.0.0
4. Starter containers igen
### Step 4: Kør Migration på Production
```bash
# SSH til production
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Kør migration SQL
podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub < migrations/1000_supplier_invoice_enhancements.sql
# ELLER hvis migrationen ikke er mounted:
# Kopier migration til container først:
podman cp migrations/1000_supplier_invoice_enhancements.sql bmc-hub-postgres-prod:/tmp/migration.sql
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -f /tmp/migration.sql
```
### Step 5: Sync e-conomic Accounts
```bash
# Trigger initial sync af kontoplan
curl -X GET "http://172.16.31.183:8001/api/v1/supplier-invoices/economic/accounts?refresh=true"
# Verificer at konti er cached
curl -s "http://172.16.31.183:8001/api/v1/supplier-invoices/economic/accounts" | jq '.accounts | length'
# Skal returnere antal konti (fx 20)
```
### Step 6: Verificer Migration
```bash
# Tjek database kolonner
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "\d supplier_invoice_lines"
# Skal vise: contra_account, line_purpose, resale_customer_id, resale_order_number
# Tjek economic_accounts tabel
podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "SELECT COUNT(*) FROM economic_accounts;"
# Skal returnere antal accounts (fx 20)
# Test frontend
# Åbn: http://172.16.31.183:8001/billing/supplier-invoices
# Skal vise: Til Betaling, Klar til Bogføring, Varelinjer tabs
# Test backup version
# Åbn: http://172.16.31.183:8001/billing/supplier-invoices2
# Skal vise: Original version med Fakturaer, Mangler Behandling tabs
```
---
## 🔄 Rollback Plan (hvis noget går galt):
### Option 1: Rollback til forrige version
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Opdater til forrige version (fx v1.3.123)
sed -i 's/^RELEASE_VERSION=.*/RELEASE_VERSION=v1.3.123/' .env
# Rebuild og restart
podman-compose down
podman-compose build --no-cache
podman-compose up -d
```
### Option 2: Restore database backup
```bash
ssh bmcadmin@172.16.31.183
cd /srv/podman/bmc_hub_v1.0
# Stop API for at undgå data ændringer
podman stop bmc-hub-api-prod
# Restore database
podman exec -i bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub < backup_pre_v2.0.0_XXXXXXXX.sql
# Restart API
podman start bmc-hub-api-prod
```
---
## 📊 Post-Migration Validation:
### Test Cases:
1. **Upload Invoice**
- Upload PDF faktura
- Verificer Quick Analysis virker
- Tjek vendor auto-match
2. **Process Invoice**
- Klik "Behandl" på uploaded fil
- Verificer template extraction
- Tjek at linjer oprettes
3. **Assign Modkonto**
- Gå til "Varelinjer" tab
- Vælg modkonto fra dropdown (skal vise 20 konti)
- Vælg formål (Videresalg, Internt, osv.)
- Gem og verificer
4. **Check Ready for Booking**
- Gå til "Klar til Bogføring" tab
- Skal kun vise fakturaer hvor ALLE linjer har modkonto
- Test "Send til e-conomic" knap
5. **Payment View**
- Gå til "Til Betaling" tab
- Verificer sortering efter forfaldsdato
- Test bulk selection
---
## 🎯 Success Criteria:
- ✅ Migration SQL kørt uden fejl
- ✅ 20+ e-conomic accounts cached i database
- ✅ Nye faneblade vises korrekt
- ✅ Modkonto dropdown virker
- ✅ Inline editing af linjer fungerer
- ✅ Backup version tilgængelig på /supplier-invoices2
- ✅ Send til e-conomic virker med nye modkonti
---
## ⚠️ Known Issues & Workarounds:
### Issue 1: Accounts endpoint timeout
**Symptom**: Første kald til accounts endpoint er langsomt (2-3 sek)
**Reason**: Første gang syncer fra e-conomic API
**Workaround**: Pre-trigger sync efter deployment (Step 5)
### Issue 2: Eksisterende fakturaer har ingen modkonto
**Symptom**: Gamle fakturaer vises ikke i "Klar til Bogføring"
**Expected**: Kun nye fakturaer (efter migration) vil have modkonti
**Solution**: Manuel assignment via "Varelinjer" tab for gamle fakturaer hvis nødvendigt
### Issue 3: Browser cache
**Symptom**: Gamle faneblade vises stadig
**Solution**: Ctrl+Shift+R (hard refresh) i browser
---
## 📞 Support:
Ved problemer, tjek:
1. Container logs: `podman logs bmc-hub-api-prod --tail 100`
2. Database logs: `podman logs bmc-hub-postgres-prod --tail 100`
3. Migration status: `podman exec bmc-hub-postgres-prod psql -U bmc_hub -d bmc_hub -c "SELECT * FROM economic_accounts LIMIT 5;"`
---
**Version**: 2.0.0
**Date**: 2026-01-07
**Migration File**: 1000_supplier_invoice_enhancements.sql

View File

@ -1 +1 @@
1.3.124 1.3.123

View File

@ -1,6 +1,6 @@
""" """
Backup Scheduler Backup Scheduler
Manages scheduled backup jobs, rotation, offsite uploads, retry logic, and email fetch Manages scheduled backup jobs, rotation, offsite uploads, and retry logic
""" """
import logging import logging
@ -26,42 +26,17 @@ class BackupScheduler:
self.running = False self.running = False
def start(self): def start(self):
"""Start the scheduler with enabled jobs (backups and/or emails)""" """Start the backup scheduler with all jobs"""
if self.running: if not self.enabled:
logger.warning("⚠️ Scheduler already running") logger.info("⏭️ Backup scheduler disabled (BACKUP_ENABLED=false)")
return return
logger.info("🚀 Starting unified scheduler...") if self.running:
logger.warning("⚠️ Backup scheduler already running")
return
# Add backup jobs if enabled logger.info("🚀 Starting backup scheduler...")
if self.enabled:
self._add_backup_jobs()
else:
logger.info("⏭️ Backup jobs disabled (BACKUP_ENABLED=false)")
# Email fetch job (every N minutes if enabled)
if settings.EMAIL_TO_TICKET_ENABLED:
self.scheduler.add_job(
func=self._email_fetch_job,
trigger=IntervalTrigger(minutes=settings.EMAIL_PROCESS_INTERVAL_MINUTES),
id='email_fetch',
name='Email Fetch & Process',
max_instances=1,
replace_existing=True
)
logger.info("✅ Scheduled: Email fetch every %d minute(s)",
settings.EMAIL_PROCESS_INTERVAL_MINUTES)
else:
logger.info("⏭️ Email fetch disabled (EMAIL_TO_TICKET_ENABLED=false)")
# Start the scheduler
self.scheduler.start()
self.running = True
logger.info("✅ Scheduler started successfully")
def _add_backup_jobs(self):
"""Add all backup-related jobs to scheduler"""
# Daily full backup at 02:00 CET # Daily full backup at 02:00 CET
self.scheduler.add_job( self.scheduler.add_job(
func=self._daily_backup_job, func=self._daily_backup_job,
@ -131,6 +106,12 @@ class BackupScheduler:
) )
logger.info("✅ Scheduled: Storage check at 01:30") logger.info("✅ Scheduled: Storage check at 01:30")
# Start the scheduler
self.scheduler.start()
self.running = True
logger.info("✅ Backup scheduler started successfully")
def stop(self): def stop(self):
"""Stop the backup scheduler""" """Stop the backup scheduler"""
if not self.running: if not self.running:
@ -396,25 +377,6 @@ class BackupScheduler:
except Exception as e: except Exception as e:
logger.error("❌ Storage check error: %s", str(e), exc_info=True) logger.error("❌ Storage check error: %s", str(e), exc_info=True)
async def _email_fetch_job(self):
"""Email fetch and processing job"""
try:
logger.info("🔄 Email processing job started...")
# Import here to avoid circular dependencies
from app.services.email_processor_service import EmailProcessorService
processor = EmailProcessorService()
start_time = datetime.now()
stats = await processor.process_inbox()
duration = (datetime.now() - start_time).total_seconds()
logger.info(f"✅ Email processing complete: {stats} (duration: {duration:.1f}s)")
except Exception as e:
logger.error(f"❌ Email processing job failed: {e}")
def _get_weekday_number(self, day_name: str) -> int: def _get_weekday_number(self, day_name: str) -> int:
"""Convert day name to APScheduler weekday number (0=Monday, 6=Sunday)""" """Convert day name to APScheduler weekday number (0=Monday, 6=Sunday)"""
days = { days = {

View File

@ -248,15 +248,11 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card"> <div class="card">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header">
<span><i class="bi bi-clock-history"></i> Scheduled Jobs</span> <i class="bi bi-clock-history"></i> Scheduler Status
<button class="btn btn-light btn-sm" onclick="loadSchedulerStatus()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div> </div>
<div class="card-body p-0"> <div class="card-body">
<div id="scheduler-status"> <div id="scheduler-status">
<div class="text-center p-4">
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading...</span> <span class="ms-2">Loading...</span>
</div> </div>
@ -264,7 +260,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Backup History --> <!-- Backup History -->
<div class="row"> <div class="row">
@ -506,138 +501,29 @@
if (!status.running) { if (!status.running) {
container.innerHTML = ` container.innerHTML = `
<div class="alert alert-warning mb-0 m-3"> <div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-triangle"></i> Scheduler not running <i class="bi bi-exclamation-triangle"></i> Scheduler not running
</div> </div>
`; `;
return; return;
} }
// Group jobs by type container.innerHTML = `
const backupJobs = status.jobs.filter(j => ['daily_backup', 'monthly_backup'].includes(j.id)); <div class="alert alert-success mb-0">
const maintenanceJobs = status.jobs.filter(j => ['backup_rotation', 'storage_check', 'offsite_upload', 'offsite_retry'].includes(j.id)); <i class="bi bi-check-circle"></i> Active
const emailJob = status.jobs.find(j => j.id === 'email_fetch');
let html = `
<div class="list-group list-group-flush">
<div class="list-group-item bg-success bg-opacity-10">
<div class="d-flex align-items-center">
<i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>Scheduler Active</strong>
</div>
</div> </div>
<small class="text-muted">Next jobs:</small>
<ul class="list-unstyled mb-0 mt-1">
${status.jobs.slice(0, 3).map(j => `
<li><small>${j.name}: ${j.next_run ? formatDate(j.next_run) : 'N/A'}</small></li>
`).join('')}
</ul>
`; `;
// Email Fetch Job
if (emailJob) {
const nextRun = emailJob.next_run ? new Date(emailJob.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<i class="bi bi-envelope text-primary"></i>
<strong class="ms-1">Email Fetch</strong>
<br>
<small class="text-muted">Every 5 minutes</small>
</div>
<span class="badge bg-primary">${timeUntil}</span>
</div>
</div>
`;
}
// Backup Jobs
if (backupJobs.length > 0) {
html += `
<div class="list-group-item bg-light">
<small class="text-muted fw-bold"><i class="bi bi-database"></i> BACKUP JOBS</small>
</div>
`;
backupJobs.forEach(job => {
const nextRun = job.next_run ? new Date(job.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
const icon = job.id === 'daily_backup' ? 'bi-arrow-repeat' : 'bi-calendar-month';
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div>
<i class="bi ${icon} text-info"></i>
<small class="ms-1">${job.name}</small>
<br>
<small class="text-muted">${nextRun ? formatDateTime(nextRun) : 'N/A'}</small>
</div>
<span class="badge bg-info">${timeUntil}</span>
</div>
</div>
`;
});
}
// Maintenance Jobs
if (maintenanceJobs.length > 0) {
html += `
<div class="list-group-item bg-light">
<small class="text-muted fw-bold"><i class="bi bi-wrench"></i> MAINTENANCE</small>
</div>
`;
maintenanceJobs.forEach(job => {
const nextRun = job.next_run ? new Date(job.next_run) : null;
const timeUntil = nextRun ? formatTimeUntil(nextRun) : 'N/A';
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start">
<div style="max-width: 70%;">
<i class="bi bi-gear text-secondary"></i>
<small class="ms-1">${job.name}</small>
<br>
<small class="text-muted">${nextRun ? formatDateTime(nextRun) : 'N/A'}</small>
</div>
<span class="badge bg-secondary text-nowrap">${timeUntil}</span>
</div>
</div>
`;
});
}
html += `</div>`;
container.innerHTML = html;
} catch (error) { } catch (error) {
console.error('Load scheduler status error:', error); console.error('Load scheduler status error:', error);
document.getElementById('scheduler-status').innerHTML = `
<div class="alert alert-danger m-3">
<i class="bi bi-exclamation-triangle"></i> Failed to load scheduler status
</div>
`;
} }
} }
function formatTimeUntil(date) {
const now = new Date();
const diff = date - now;
if (diff < 0) return 'Overdue';
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}d`;
if (hours > 0) return `${hours}h`;
if (minutes > 0) return `${minutes}m`;
return 'Now';
}
function formatDateTime(date) {
return date.toLocaleString('da-DK', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Create manual backup // Create manual backup
async function createBackup(event) { async function createBackup(event) {
event.preventDefault(); event.preventDefault();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -16,16 +16,7 @@ async def supplier_invoices_page(request: Request):
"""Supplier invoices (kassekladde) page""" """Supplier invoices (kassekladde) page"""
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", { return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
"request": request, "request": request,
"title": "Leverandør fakturaer" "title": "Kassekladde"
})
@router.get("/billing/supplier-invoices2", response_class=HTMLResponse)
async def supplier_invoices_v1_backup(request: Request):
"""Supplier invoices V1 backup - original version"""
return templates.TemplateResponse("billing/frontend/supplier_invoices_v1_backup.html", {
"request": request,
"title": "Leverandør fakturaer (V1 Backup)"
}) })

View File

@ -3,27 +3,15 @@ Contact API Router - Simplified (Read-Only)
Only GET endpoints for now Only GET endpoints for now
""" """
from fastapi import APIRouter, HTTPException, Query, Body, status from fastapi import APIRouter, HTTPException, Query
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from app.core.database import execute_query
from app.core.database import execute_query, execute_insert
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
class ContactCreate(BaseModel):
"""Schema for creating a contact"""
first_name: str
last_name: str = ""
email: Optional[str] = None
phone: Optional[str] = None
title: Optional[str] = None
company_id: Optional[int] = None
@router.get("/contacts-debug") @router.get("/contacts-debug")
async def debug_contacts(): async def debug_contacts():
"""Debug endpoint: Check contact-company links""" """Debug endpoint: Check contact-company links"""
@ -131,55 +119,6 @@ async def get_contacts(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/contacts", status_code=status.HTTP_201_CREATED)
async def create_contact(contact: ContactCreate):
"""
Create a new basic contact
"""
try:
# Check if email exists
if contact.email:
existing = execute_query(
"SELECT id FROM contacts WHERE email = %s",
(contact.email,)
)
if existing:
# Return existing contact if found? Or error?
# For now, let's error to be safe, or just return it?
# User prompted "Smart Create", implies if it exists, use it?
# But safer to say "Email already exists"
pass
insert_query = """
INSERT INTO contacts (first_name, last_name, email, phone, title, is_active)
VALUES (%s, %s, %s, %s, %s, true)
RETURNING id
"""
contact_id = execute_insert(
insert_query,
(contact.first_name, contact.last_name, contact.email, contact.phone, contact.title)
)
# Link to company if provided
if contact.company_id:
try:
link_query = """
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
VALUES (%s, %s, true, 'primary')
"""
execute_insert(link_query, (contact_id, contact.company_id))
except Exception as e:
logger.error(f"Failed to link new contact {contact_id} to company {contact.company_id}: {e}")
# Don't fail the whole request, just log it
return await get_contact(contact_id)
except Exception as e:
logger.error(f"Failed to create contact: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/contacts/{contact_id}") @router.get("/contacts/{contact_id}")
async def get_contact(contact_id: int): async def get_contact(contact_id: int):
"""Get a single contact by ID with linked companies""" """Get a single contact by ID with linked companies"""

View File

@ -1,186 +0,0 @@
"""
Conversations Router
Handles audio conversations, transcriptions, and privacy settings.
"""
from fastapi import APIRouter, HTTPException, Request, Depends, Query, status
from fastapi.responses import FileResponse, JSONResponse
from typing import List, Optional
from datetime import datetime
import os
from pathlib import Path
from app.core.database import execute_query, execute_update
from app.models.schemas import Conversation, ConversationUpdate
from app.core.config import settings
router = APIRouter()
@router.get("/conversations", response_model=List[Conversation])
async def get_conversations(
request: Request,
customer_id: Optional[int] = None,
ticket_id: Optional[int] = None,
only_mine: bool = False,
include_deleted: bool = False
):
"""
List conversations with filtering.
"""
where_clauses = []
params = []
# Default: Exclude deleted
if not include_deleted:
where_clauses.append("deleted_at IS NULL")
if customer_id:
where_clauses.append("customer_id = %s")
params.append(customer_id)
if ticket_id:
where_clauses.append("ticket_id = %s")
params.append(ticket_id)
# Filtering Logic for Privacy
# 1. Technical implementation of 'only_mine' depends on auth user context
# Assuming we might have a user_id in session or request state (not fully clear from context, defaulting to param)
# For this implementation, I'll assume 'only_mine' filters by the current user if available, or just ignored if no auth.
# Note: Access Control logic should be here.
# For now, we return public conversations OR private ones owned by user.
# Since auth is light in this project, we implement basic logic.
auth_user_id = None # data.get('user_id') # To be implemented with auth middleware
# Taking a pragmatic approach: if is_private is true, we ideally shouldn't return it unless authorized.
# For now, we return all, assuming the frontend filters or backend auth is added later.
if only_mine and auth_user_id:
where_clauses.append("user_id = %s")
params.append(auth_user_id)
where_sql = " AND ".join(where_clauses) if where_clauses else "TRUE"
query = f"""
SELECT * FROM conversations
WHERE {where_sql}
ORDER BY created_at DESC
"""
results = execute_query(query, tuple(params))
return results
@router.get("/conversations/{conversation_id}/audio")
async def get_conversation_audio(conversation_id: int):
"""
Stream the audio file for a conversation.
"""
query = "SELECT audio_file_path FROM conversations WHERE id = %s"
results = execute_query(query, (conversation_id,))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
# Security check: Check if deleted
record = results[0]
# (If using soft delete, check deleted_at if not admin)
file_path_str = record['audio_file_path']
file_path = Path(file_path_str)
# Validation
if not file_path.exists():
# Fallback to absolute path check if stored relative
abs_path = Path(os.getcwd()) / file_path
if not abs_path.exists():
raise HTTPException(status_code=404, detail="Audio file not found on disk")
file_path = abs_path
return FileResponse(file_path, media_type="audio/mpeg", filename=file_path.name)
@router.delete("/conversations/{conversation_id}")
async def delete_conversation(conversation_id: int, hard_delete: bool = False):
"""
Delete a conversation.
hard_delete=True removes file and record permanently (GDPR).
hard_delete=False sets deleted_at (Recycle Bin).
"""
# 1. Fetch info
query = "SELECT * FROM conversations WHERE id = %s"
results = execute_query(query, (conversation_id,))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
conv = results[0]
if hard_delete:
# HARD DELETE
# 1. Delete file
try:
file_path = Path(conv['audio_file_path'])
if not file_path.is_absolute():
file_path = Path(os.getcwd()) / file_path
if file_path.exists():
os.remove(file_path)
except Exception as e:
# Log error but continue to cleanup DB? Or fail?
# Better to fail safely or ensure DB matches reality
print(f"Error deleting file: {e}")
# 2. Delete DB Record
execute_update("DELETE FROM conversations WHERE id = %s", (conversation_id,))
return {"status": "permanently_deleted"}
else:
# SOFT DELETE
execute_update(
"UPDATE conversations SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s",
(conversation_id,)
)
return {"status": "moved_to_trash"}
@router.patch("/conversations/{conversation_id}", response_model=Conversation)
async def update_conversation(conversation_id: int, update: ConversationUpdate):
"""
Update conversation metadata (privacy, title, links).
"""
# Build update query dynamically
fields = []
values = []
if update.title is not None:
fields.append("title = %s")
values.append(update.title)
if update.is_private is not None:
fields.append("is_private = %s")
values.append(update.is_private)
if update.ticket_id is not None:
fields.append("ticket_id = %s")
values.append(update.ticket_id)
if update.customer_id is not None:
fields.append("customer_id = %s")
values.append(update.customer_id)
if update.category is not None:
fields.append("category = %s")
values.append(update.category)
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields.append("updated_at = CURRENT_TIMESTAMP")
values.append(conversation_id)
query = f"UPDATE conversations SET {', '.join(fields)} WHERE id = %s RETURNING *"
# execute_query often returns list of dicts for SELECT/RETURNING
results = execute_query(query, tuple(values))
if not results:
raise HTTPException(status_code=404, detail="Conversation not found")
return results[0]

View File

@ -1,297 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Mine Samtaler - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid pb-5">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 border-bottom pb-3">
<div>
<h1 class="h2 fw-bold text-primary mb-1"><i class="bi bi-mic me-2"></i>Mine samtaler</h1>
<p class="text-muted mb-0 small">Administrer og analysér dine optagede telefonsamtaler.</p>
</div>
<div class="d-flex gap-2">
<div class="btn-group" role="group">
<input type="radio" class="btn-check" name="filterradio" id="btnradio1" autocomplete="off" checked onclick="filterView('all')">
<label class="btn btn-outline-primary btn-sm" for="btnradio1">Alle</label>
<input type="radio" class="btn-check" name="filterradio" id="btnradio2" autocomplete="off" onclick="filterView('private')">
<label class="btn btn-outline-primary btn-sm" for="btnradio2">Private</label>
</div>
<button class="btn btn-primary btn-sm shadow-sm" onclick="loadMyConversations()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
</div>
<div class="row g-4">
<!-- Sidebar: List of Conversations -->
<div class="col-lg-4 col-xl-3">
<div class="card shadow-sm h-100 border-0">
<div class="card-header bg-white border-bottom-0 pt-3">
<div class="input-group input-group-sm">
<span class="input-group-text bg-light border-end-0"><i class="bi bi-search text-muted"></i></span>
<input type="text" class="form-control bg-light border-start-0" id="conversationSearch" placeholder="Søg..." onkeyup="filterConversations()">
</div>
</div>
<div class="card-body p-0 overflow-auto" style="max-height: 80vh;" id="conversationsList">
<div class="text-center py-5">
<div class="spinner-border text-primary spinner-border-sm"></div>
<p class="mt-2 text-muted small">Indlæser...</p>
</div>
</div>
</div>
</div>
<!-- Main Content: Detailed One View -->
<div class="col-lg-8 col-xl-9">
<div id="conversationDetail" class="h-100">
<!-- Placeholder State -->
<div class="d-flex flex-column align-items-center justify-content-center h-100 text-muted py-5 border rounded-3 bg-light">
<i class="bi bi-chat-square-quote display-4 mb-3 text-secondary"></i>
<h5>Vælg en samtale for at se detaljer</h5>
<p class="small">Klik på en samtale i listen til venstre.</p>
</div>
</div>
</div>
</div>
</div>
<style>
.list-group-item-action { cursor: pointer; border-left: 3px solid transparent; }
.list-group-item-action:hover { background-color: #f8f9fa; }
.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:hover { text-decoration: underline; }
.transcript-line { transition: background-color 0.2s; border-radius: 4px; padding: 2px 4px; }
.transcript-line:hover { background-color: #fff3cd; }
</style>
<script>
let allConversations = [];
let currentConversationId = null;
document.addEventListener('DOMContentLoaded', () => {
loadMyConversations();
});
async function loadMyConversations() {
try {
const response = await fetch('/api/v1/conversations');
if (!response.ok) throw new Error('Fejl ved hentning');
allConversations = await response.json();
renderConversationList(allConversations);
// If we have data and no selection, select the first one
if (allConversations.length > 0 && !currentConversationId) {
selectConversation(allConversations[0].id);
} else if (currentConversationId) {
// Refresh current view if needed
selectConversation(currentConversationId);
}
} catch(e) {
console.error("Error loading conversations:", e);
document.getElementById('conversationsList').innerHTML =
'<div class="p-3 text-center text-danger small">Kunne ikke hente liste. <br><button class="btn btn-link btn-sm" onclick="loadMyConversations()">Prøv igen</button></div>';
}
}
function renderConversationList(list) {
const container = document.getElementById('conversationsList');
if(list.length === 0) {
container.innerHTML = '<div class="text-center py-5 text-muted small">Ingen samtaler fundet</div>';
return;
}
container.innerHTML = '<div class="list-group list-group-flush">' + list.map(c => `
<a onclick="selectConversation(${c.id})" class="list-group-item list-group-item-action py-3 ${currentConversationId === c.id ? 'active' : ''}" id="conv-item-${c.id}" data-type="${c.is_private ? 'private' : 'public'}" data-text="${(c.title||'').toLowerCase()}">
<div class="d-flex w-100 justify-content-between mb-1">
<strong class="mb-1 text-truncate" style="max-width: 70%;">${c.title}</strong>
<small class="text-muted" style="font-size: 0.75rem;">${new Date(c.created_at).toLocaleDateString()}</small>
</div>
<div class="d-flex justify-content-between align-items-center">
<small class="text-muted text-truncate" style="max-width: 150px;">
${c.customer_id ? '<i class="bi bi-building"></i> Kunde #' + c.customer_id : 'Ingen kunde'}
</small>
${c.is_private ? '<i class="bi bi-lock-fill text-warning small"></i>' : ''}
${c.category === 'Support' ? '<span class="badge bg-info text-dark rounded-pill" style="font-size:0.6rem">Support</span>' : ''}
${c.category === 'Sales' ? '<span class="badge bg-success rounded-pill" style="font-size:0.6rem">Salg</span>' : ''}
</div>
</a>
`).join('') + '</div>';
}
function selectConversation(id) {
currentConversationId = id;
const conv = allConversations.find(c => c.id === id);
if (!conv) return;
// Highlight active item
document.querySelectorAll('.list-group-item-action').forEach(el => el.classList.remove('active'));
const activeItem = document.getElementById(`conv-item-${id}`);
if(activeItem) activeItem.classList.add('active');
// Render Detail View
const detailContainer = document.getElementById('conversationDetail');
// Simulate segments if not present (simple sentence splitting)
const segments = conv.transcript ? splitIntoSegments(conv.transcript) : [];
const formattedTranscript = segments.map((seg, idx) => {
// Mock timestamps if simple text
const time = formatTime(idx * 5); // Fake 5 sec increments for demo if no real timestamps
return `<div class="d-flex mb-3 transcript-line">
<div class="me-3 text-muted font-monospace small pt-1" style="min-width: 50px;">
<a href="#" onclick="seekAudio(${idx * 5}); return false;" class="timestamp-link">${time}</a>
</div>
<div class="flex-grow-1">${seg}</div>
</div>`;
}).join('');
detailContainer.innerHTML = `
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white py-3 border-bottom-0">
<div class="d-flex justify-content-between align-items-start">
<div>
<h3 class="mb-1 fw-bold text-dark">${conv.title}</h3>
<p class="text-muted small mb-2">
<i class="bi bi-clock"></i> ${new Date(conv.created_at).toLocaleString()}
<span class="mx-2"></span>
<span class="badge bg-light text-dark border">${conv.category || 'Generelt'}</span>
</p>
</div>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Handlinger</h6></li>
<li><a class="dropdown-item" href="#" onclick="togglePrivacy(${conv.id}, ${!conv.is_private})">${conv.is_private ? 'Gør offentlig' : 'Gør privat'}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="deleteConversation(${conv.id})">Slet samtale</a></li>
</ul>
</div>
</div>
</div>
<div class="card-body overflow-auto">
<!-- Audio Player -->
<div class="card bg-light border-0 mb-4">
<div class="card-body p-3">
<div class="d-flex align-items-center mb-2">
<div class="bg-primary text-white rounded-circle d-flex align-items-center justify-content-center me-3" style="width:40px;height:40px">
<i class="bi bi-play-fill fs-4"></i>
</div>
<div class="flex-grow-1">
<h6 class="mb-0 small fw-bold">Optagelse</h6>
<span class="text-muted small" style="font-size: 0.7rem">MP3 • ${conv.duration_seconds || '--:--'}</span>
</div>
</div>
<audio controls class="w-100" id="audioPlayer">
<source src="/api/v1/conversations/${conv.id}/audio" type="audio/mpeg">
</audio>
</div>
</div>
<!-- Summary Section -->
<div class="mb-4">
<h6 class="text-uppercase text-muted small fw-bold mb-2">Resumé</h6>
<div class="p-3 bg-light rounded-3 border-start border-4 border-info">
${conv.summary || '<span class="text-muted fst-italic">Intet resumé genereret endnu.</span>'}
</div>
</div>
<!-- Transcript Section -->
<div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="text-uppercase text-muted small fw-bold mb-0">Transskription</h6>
<button class="btn btn-sm btn-link text-decoration-none p-0" onclick="copyTranscript()">Kopier tekst</button>
</div>
${conv.transcript ?
`<div class="p-2">${formattedTranscript}</div>` :
'<div class="alert alert-info small">Ingen transskription tilgængelig.</div>'
}
</div>
</div>
</div>
`;
}
function splitIntoSegments(text) {
// If text already has timestamps like [00:00], preserve them.
// Otherwise split by sentence endings.
if (!text) return [];
// Very basic sentence splitter
return text.match( /[^\.!\?]+[\.!\?]+/g ) || [text];
}
function formatTime(seconds) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`;
}
function seekAudio(seconds) {
const audio = document.getElementById('audioPlayer');
if(audio) {
audio.currentTime = seconds;
audio.play();
}
}
function copyTranscript() {
// Logic to copy text
const conv = allConversations.find(c => c.id === currentConversationId);
if(conv && conv.transcript) {
navigator.clipboard.writeText(conv.transcript);
alert('Tekst kopieret!');
}
}
// ... Keep existing helper functions (togglePrivacy, deleteConversation, etc) but allow them to refresh list properly ...
async function togglePrivacy(id, makePrivate) {
await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_private: makePrivate})
});
// Update local state without full reload
const c = allConversations.find(x => x.id === id);
if(c) c.is_private = makePrivate;
loadMyConversations(); // Reload for sorting/filtering
}
async function deleteConversation(id) {
if(!confirm('Vil du slette denne samtale?')) return;
const hard = confirm('Permanent sletning?');
await fetch(`/api/v1/conversations/${id}?hard_delete=${hard}`, { method: 'DELETE' });
currentConversationId = null;
loadMyConversations();
document.getElementById('conversationDetail').innerHTML = '<div class="d-flex flex-column align-items-center justify-content-center h-100 text-muted py-5"><i class="bi bi-check-circle display-4 mb-3"></i><h5>Slettet</h5></div>';
}
function filterView(type) {
const items = document.querySelectorAll('.list-group-item');
items.forEach(item => {
if (type === 'all') item.classList.remove('d-none');
else if (type === 'private') {
item.dataset.type === 'private' ? item.classList.remove('d-none') : item.classList.add('d-none');
}
});
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.list-group-item');
items.forEach(item => {
const text = item.dataset.text;
text.includes(query) ? item.classList.remove('d-none') : item.classList.add('d-none');
});
}
</script>
{% endblock %}

View File

@ -1,16 +0,0 @@
from fastapi import APIRouter, Request, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pathlib import Path
router = APIRouter()
# Use "app" as base directory so we can reference templates like "conversations/frontend/templates/xxx.html"
templates = Jinja2Templates(directory="app")
@router.get("/conversations/my", response_class=HTMLResponse)
async def my_conversations_view(request: Request):
"""
Render the Technician's Conversations Dashboard
"""
return templates.TemplateResponse("conversations/frontend/templates/my_conversations.html", {"request": request})

View File

@ -19,7 +19,6 @@ class Settings(BaseSettings):
API_HOST: str = "0.0.0.0" API_HOST: str = "0.0.0.0"
API_PORT: int = 8000 API_PORT: int = 8000
API_RELOAD: bool = False API_RELOAD: bool = False
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
# Security # Security
SECRET_KEY: str = "dev-secret-key-change-in-production" SECRET_KEY: str = "dev-secret-key-change-in-production"
@ -64,7 +63,7 @@ class Settings(BaseSettings):
EMAIL_RULES_ENABLED: bool = True EMAIL_RULES_ENABLED: bool = True
EMAIL_RULES_AUTO_PROCESS: bool = False EMAIL_RULES_AUTO_PROCESS: bool = False
EMAIL_AI_ENABLED: bool = False EMAIL_AI_ENABLED: bool = False
EMAIL_AUTO_CLASSIFY: bool = True # Enable classification by default (uses keywords if AI disabled) EMAIL_AUTO_CLASSIFY: bool = False
EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7 EMAIL_AI_CONFIDENCE_THRESHOLD: float = 0.7
EMAIL_MAX_FETCH_PER_RUN: int = 50 EMAIL_MAX_FETCH_PER_RUN: int = 50
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5 EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
@ -77,11 +76,6 @@ class Settings(BaseSettings):
VTIGER_USERNAME: str = "" VTIGER_USERNAME: str = ""
VTIGER_API_KEY: str = "" VTIGER_API_KEY: str = ""
# Data Consistency Settings
VTIGER_SYNC_ENABLED: bool = True
ECONOMIC_SYNC_ENABLED: bool = True
AUTO_CHECK_CONSISTENCY: bool = True
# Time Tracking Module Settings # Time Tracking Module Settings
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 1200.00 TIMETRACKING_DEFAULT_HOURLY_RATE: float = 1200.00
TIMETRACKING_AUTO_ROUND: bool = True TIMETRACKING_AUTO_ROUND: bool = True
@ -126,9 +120,6 @@ class Settings(BaseSettings):
# Offsite Backup Settings (SFTP) # Offsite Backup Settings (SFTP)
OFFSITE_ENABLED: bool = False OFFSITE_ENABLED: bool = False
OFFSITE_WEEKLY_DAY: str = "sunday"
OFFSITE_RETRY_DELAY_HOURS: int = 1
OFFSITE_RETRY_MAX_ATTEMPTS: int = 3
SFTP_HOST: str = "" SFTP_HOST: str = ""
SFTP_PORT: int = 22 SFTP_PORT: int = 22
SFTP_USER: str = "" SFTP_USER: str = ""
@ -151,12 +142,6 @@ class Settings(BaseSettings):
GITHUB_TOKEN: str = "" GITHUB_TOKEN: str = ""
GITHUB_REPO: str = "ct/bmc_hub" GITHUB_REPO: str = "ct/bmc_hub"
# Whisper Transcription
WHISPER_ENABLED: bool = True
WHISPER_API_URL: str = "http://172.16.31.115:5000/transcribe"
WHISPER_TIMEOUT: int = 300
WHISPER_SUPPORTED_FORMATS: List[str] = [".mp3", ".wav", ".m4a", ".ogg"]
@field_validator('*', mode='before') @field_validator('*', mode='before')
@classmethod @classmethod
def strip_whitespace(cls, v): def strip_whitespace(cls, v):

View File

@ -85,17 +85,14 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
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 id clause)"""
conn = get_db_connection() conn = 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)
conn.commit() conn.commit()
result = cursor.fetchone() result = cursor.fetchone()
if result: return result['id'] if result and 'id' in result else None
# Return first column value regardless of name (id, extraction_id, file_id, etc.)
return result[list(result.keys())[0]] if result else None
return None
except Exception as e: except Exception as e:
conn.rollback() conn.rollback()
logger.error(f"Insert error: {e}") logger.error(f"Insert error: {e}")

View File

@ -9,10 +9,9 @@ from typing import List, Optional, Dict
from pydantic import BaseModel from pydantic import BaseModel
import logging import logging
from app.core.database import execute_query, execute_query_single, execute_update from app.core.database import execute_query, execute_query_single
from app.services.cvr_service import get_cvr_service from app.services.cvr_service import get_cvr_service
from app.services.customer_activity_logger import CustomerActivityLogger from app.services.customer_activity_logger import CustomerActivityLogger
from app.services.customer_consistency import CustomerConsistencyService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -380,29 +379,13 @@ async def get_customer(customer_id: int):
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching BMC Låst status: {e}") logger.error(f"❌ Error fetching BMC Låst status: {e}")
# Check for ACTIVE bankruptcy alerts
bankruptcy_alert = execute_query_single(
"""
SELECT id, subject, received_date
FROM email_messages
WHERE customer_id = %s
AND classification = 'bankruptcy'
AND status NOT IN ('processed', 'archived')
ORDER BY received_date DESC
LIMIT 1
""",
(customer_id,)
)
return { return {
**customer, **customer,
'contact_count': contact_count, 'contact_count': contact_count,
'bmc_locked': bmc_locked, 'bmc_locked': bmc_locked
'bankruptcy_alert': bankruptcy_alert
} }
@router.post("/customers") @router.post("/customers")
async def create_customer(customer: CustomerCreate): async def create_customer(customer: CustomerCreate):
"""Create a new customer""" """Create a new customer"""
@ -491,101 +474,6 @@ async def update_customer(customer_id: int, update: CustomerUpdate):
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/data-consistency")
async def check_customer_data_consistency(customer_id: int):
"""
🔍 Check data consistency across Hub, vTiger, and e-conomic
Returns discrepancies found between the three systems
"""
try:
from app.core.config import settings
if not settings.AUTO_CHECK_CONSISTENCY:
return {
"enabled": False,
"message": "Data consistency checking is disabled"
}
consistency_service = CustomerConsistencyService()
# Fetch data from all systems
all_data = await consistency_service.fetch_all_data(customer_id)
# Compare data
discrepancies = consistency_service.compare_data(all_data)
# Count actual discrepancies
discrepancy_count = sum(
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
return {
"enabled": True,
"customer_id": customer_id,
"discrepancy_count": discrepancy_count,
"discrepancies": discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
"economic": all_data.get('economic') is not None
}
}
except Exception as e:
logger.error(f"❌ Failed to check consistency for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/sync-field")
async def sync_customer_field(
customer_id: int,
field_name: str = Query(..., description="Hub field name to sync"),
source_system: str = Query(..., description="Source system: hub, vtiger, or economic"),
source_value: str = Query(..., description="The correct value to sync")
):
"""
🔄 Sync a single field across all systems
Takes the correct value from one system and updates the others
"""
try:
from app.core.config import settings
# Validate source system
if source_system not in ['hub', 'vtiger', 'economic']:
raise HTTPException(
status_code=400,
detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic"
)
consistency_service = CustomerConsistencyService()
# Perform sync
results = await consistency_service.sync_field(
customer_id=customer_id,
field_name=field_name,
source_system=source_system,
source_value=source_value
)
return {
"success": True,
"customer_id": customer_id,
"field_name": field_name,
"source_system": source_system,
"source_value": source_value,
"sync_results": results
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Failed to sync field {field_name} for customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/sync-economic-from-simplycrm") @router.post("/customers/sync-economic-from-simplycrm")
async def sync_economic_numbers_from_simplycrm(): async def sync_economic_numbers_from_simplycrm():
""" """
@ -1355,51 +1243,3 @@ async def get_subscription_comment(customer_id: int):
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching subscription comment: {e}") logger.error(f"❌ Error fetching subscription comment: {e}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/customers/{customer_id}/subscriptions/billing-matrix")
async def get_subscription_billing_matrix(
customer_id: int,
months: int = Query(default=12, ge=1, le=60, description="Number of months to show")
):
"""
Get subscription billing matrix showing monthly invoiced amounts per product
Rows: Products from e-conomic invoices
Columns: Months
Cells: Amount, status (paid/invoiced/missing), invoice reference
Data source: e-conomic sales invoices only
"""
try:
# Verify customer exists
customer = execute_query_single(
"SELECT id, economic_customer_number FROM customers WHERE id = %s",
(customer_id,)
)
if not customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found")
if not customer.get('economic_customer_number'):
logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
return {
"customer_id": customer_id,
"error": "Customer not linked to e-conomic",
"products": []
}
# Generate matrix
from app.services.subscription_matrix import get_subscription_matrix_service
matrix_service = get_subscription_matrix_service()
matrix = await matrix_service.generate_billing_matrix(customer_id, months)
logger.info(f"✅ Generated billing matrix for customer {customer_id}")
return matrix
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error generating billing matrix: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -163,56 +163,6 @@
border-radius: 50%; border-radius: 50%;
border: 3px solid var(--bg-body); border: 3px solid var(--bg-body);
} }
/* Enhanced Edit Button */
.btn-edit-customer {
background: linear-gradient(135deg, #0f4c75 0%, #1a5f8e 100%);
color: white;
border: none;
padding: 0.75rem 1.75rem;
border-radius: 10px;
font-weight: 600;
letter-spacing: 0.3px;
box-shadow: 0 4px 15px rgba(15, 76, 117, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.btn-edit-customer::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
transition: left 0.5s;
}
.btn-edit-customer:hover {
background: linear-gradient(135deg, #1a5f8e 0%, #0f4c75 100%);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(15, 76, 117, 0.4);
color: white;
}
.btn-edit-customer:hover::before {
left: 100%;
}
.btn-edit-customer:active {
transform: translateY(0);
box-shadow: 0 2px 8px rgba(15, 76, 117, 0.3);
}
.btn-edit-customer i {
transition: transform 0.3s;
}
.btn-edit-customer:hover i {
transform: rotate(-15deg) scale(1.1);
}
</style> </style>
{% endblock %} {% endblock %}
@ -233,8 +183,8 @@
</div> </div>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-edit-customer" onclick="editCustomer()"> <button class="btn btn-light btn-sm" onclick="editCustomer()">
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde <i class="bi bi-pencil me-2"></i>Rediger
</button> </button>
<button class="btn btn-light btn-sm" onclick="window.location.href='/customers'"> <button class="btn btn-light btn-sm" onclick="window.location.href='/customers'">
<i class="bi bi-arrow-left me-2"></i>Tilbage <i class="bi bi-arrow-left me-2"></i>Tilbage
@ -243,39 +193,6 @@
</div> </div>
</div> </div>
<!-- Bankruptcy Alert -->
<div id="bankruptcyAlert" class="alert alert-danger d-flex align-items-center mb-4 d-none border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<div>Der er registreret en aktiv konkurs-notifikation på denne kunde. Stop levering og kontakt bogholderiet.</div>
<div class="mt-2 small">
<strong>Emne:</strong> <span id="bankruptcySubject" class="fst-italic"></span><br>
<strong>Modtaget:</strong> <span id="bankruptcyDate"></span>
</div>
</div>
<div>
<a id="bankruptcyLink" href="#" class="btn btn-sm btn-danger px-3">Se Email <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
<!-- Data Consistency Alert -->
<div id="consistencyAlert" class="alert alert-warning alert-dismissible fade show d-none mt-4" role="alert">
<div class="d-flex align-items-center">
<i class="bi bi-exclamation-triangle-fill fs-4 me-3"></i>
<div class="flex-grow-1">
<strong>Data Uoverensstemmelser Fundet!</strong>
<p class="mb-0">
Der er <span id="discrepancyCount" class="fw-bold">0</span> felter med forskellige værdier mellem BMC Hub, vTiger og e-conomic.
</p>
</div>
<button type="button" class="btn btn-warning ms-3" onclick="showConsistencyModal()">
<i class="bi bi-search me-2"></i>Sammenlign
</button>
</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<!-- Content Layout with Sidebar Navigation --> <!-- Content Layout with Sidebar Navigation -->
<div class="row"> <div class="row">
<div class="col-lg-3 col-md-4"> <div class="col-lg-3 col-md-4">
@ -301,11 +218,6 @@
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek <i class="bi bi-arrow-repeat"></i>Abonnnents tjek
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#billing-matrix">
<i class="bi bi-table"></i>Abonnements Matrix
</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#hardware"> <a class="nav-link" data-bs-toggle="tab" href="#hardware">
<i class="bi bi-hdd"></i>Hardware <i class="bi bi-hdd"></i>Hardware
@ -316,11 +228,6 @@
<i class="bi bi-clock-history"></i>Aktivitet <i class="bi bi-clock-history"></i>Aktivitet
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link" data-bs-toggle="tab" href="#conversations">
<i class="bi bi-mic"></i>Samtaler
</a>
</li>
</ul> </ul>
</div> </div>
@ -495,42 +402,6 @@
</div> </div>
</div> </div>
<!-- Billing Matrix Tab -->
<div class="tab-pane fade" id="billing-matrix">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">
<i class="bi bi-table me-2"></i>Abonnements-matrix
<small class="text-muted fw-normal">(fra e-conomic)</small>
</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadBillingMatrix()" title="Hent fakturaer fra e-conomic">
<i class="bi bi-arrow-repeat me-1"></i>Opdater
</button>
</div>
<div id="billingMatrixContainer" style="display: none;">
<div class="table-responsive">
<table class="table table-sm table-hover mb-0" id="billingMatrixTable">
<thead class="table-light">
<tr id="matrixHeaderRow">
<th style="min-width: 200px;">Vare</th>
<!-- Months will be added dynamically -->
</tr>
</thead>
<tbody id="matrixBodyRows">
<!-- Rows will be added dynamically -->
</tbody>
</table>
</div>
</div>
<div id="billingMatrixLoading" class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary"></div>
<p class="text-muted mt-2">Henter fakturamatrix fra e-conomic...</p>
</div>
<div id="billingMatrixEmpty" style="display: none;" class="text-center py-5">
<p class="text-muted">Ingen fakturaer fundet for denne kunde</p>
</div>
</div>
<!-- Hardware Tab --> <!-- Hardware Tab -->
<div class="tab-pane fade" id="hardware"> <div class="tab-pane fade" id="hardware">
<h5 class="fw-bold mb-4">Hardware</h5> <h5 class="fw-bold mb-4">Hardware</h5>
@ -548,143 +419,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Conversations Tab -->
<div class="tab-pane fade" id="conversations">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="fw-bold mb-0">Samtaler</h5>
<!--
<button class="btn btn-primary btn-sm" onclick="showUploadConversationModal()">
<i class="bi bi-upload me-2"></i>Upload Samtale
</button>
-->
</div>
<div class="input-group mb-4">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="conversationSearch" placeholder="Søg i transskriberinger..." onkeyup="filterConversations()">
</div>
<div id="conversationsContainer">
<!-- Loaded via JS -->
<div class="text-center py-5">
<span class="text-muted">Henter samtaler...</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Edit Customer Modal -->
<div class="modal fade" id="editCustomerModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title">
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="editCustomerForm">
<div class="row g-3">
<!-- Basic Info -->
<div class="col-12">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-building me-2"></i>Grundlæggende Oplysninger
</h6>
</div>
<div class="col-md-6">
<label for="editName" class="form-label">Virksomhedsnavn *</label>
<input type="text" class="form-control" id="editName" required>
</div>
<div class="col-md-6">
<label for="editCvrNumber" class="form-label">CVR-nummer</label>
<input type="text" class="form-control" id="editCvrNumber" maxlength="20">
</div>
<div class="col-md-6">
<label for="editEmail" class="form-label">Email</label>
<input type="email" class="form-control" id="editEmail">
</div>
<div class="col-md-6">
<label for="editInvoiceEmail" class="form-label">Faktura Email</label>
<input type="email" class="form-control" id="editInvoiceEmail">
</div>
<div class="col-md-6">
<label for="editPhone" class="form-label">Telefon</label>
<input type="tel" class="form-control" id="editPhone">
</div>
<div class="col-md-6">
<label for="editMobilePhone" class="form-label">Mobil</label>
<input type="tel" class="form-control" id="editMobilePhone">
</div>
<div class="col-md-6">
<label for="editWebsite" class="form-label">Hjemmeside</label>
<input type="url" class="form-control" id="editWebsite" placeholder="https://">
</div>
<div class="col-md-6">
<label for="editCountry" class="form-label">Land</label>
<select class="form-select" id="editCountry">
<option value="DK">Danmark</option>
<option value="NO">Norge</option>
<option value="SE">Sverige</option>
<option value="DE">Tyskland</option>
<option value="GB">Storbritannien</option>
</select>
</div>
<!-- Address -->
<div class="col-12 mt-4">
<h6 class="text-muted text-uppercase small fw-bold mb-3">
<i class="bi bi-geo-alt me-2"></i>Adresse
</h6>
</div>
<div class="col-12">
<label for="editAddress" class="form-label">Adresse</label>
<input type="text" class="form-control" id="editAddress">
</div>
<div class="col-md-4">
<label for="editPostalCode" class="form-label">Postnummer</label>
<input type="text" class="form-control" id="editPostalCode" maxlength="10">
</div>
<div class="col-md-8">
<label for="editCity" class="form-label">By</label>
<input type="text" class="form-control" id="editCity">
</div>
<!-- Status -->
<div class="col-12 mt-4">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editIsActive" checked>
<label class="form-check-label" for="editIsActive">
<strong>Aktiv kunde</strong>
<div class="small text-muted">Deaktiver for at skjule kunden fra lister</div>
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-lg me-2"></i>Annuller
</button>
<button type="button" class="btn btn-primary" onclick="saveCustomerEdit()">
<i class="bi bi-check-lg me-2"></i>Gem Ændringer
</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -791,14 +525,6 @@ document.addEventListener('DOMContentLoaded', () => {
}, { once: false }); }, { once: false });
} }
// Load conversations when tab is shown
const conversationsTab = document.querySelector('a[href="#conversations"]');
if (conversationsTab) {
conversationsTab.addEventListener('shown.bs.tab', () => {
loadConversations();
}, { once: false });
}
eventListenersAdded = true; eventListenersAdded = true;
}); });
@ -811,9 +537,6 @@ async function loadCustomer() {
customerData = await response.json(); customerData = await response.json();
displayCustomer(customerData); displayCustomer(customerData);
// Check data consistency
await checkDataConsistency();
} catch (error) { } catch (error) {
console.error('Failed to load customer:', error); console.error('Failed to load customer:', error);
alert('Kunne ikke indlæse kunde'); alert('Kunne ikke indlæse kunde');
@ -825,23 +548,6 @@ function displayCustomer(customer) {
// Update page title // Update page title
document.title = `${customer.name} - BMC Hub`; document.title = `${customer.name} - BMC Hub`;
// Bankruptcy Alert
const bankruptcyAlert = document.getElementById('bankruptcyAlert');
if (customer.bankruptcy_alert) {
document.getElementById('bankruptcySubject').textContent = customer.bankruptcy_alert.subject;
document.getElementById('bankruptcyDate').textContent = new Date(customer.bankruptcy_alert.received_date).toLocaleString('da-DK');
document.getElementById('bankruptcyLink').href = `/emails?id=${customer.bankruptcy_alert.id}`;
bankruptcyAlert.classList.remove('d-none');
// Also add a badge to the header
const extraBadge = document.createElement('span');
extraBadge.className = 'badge bg-danger animate__animated animate__pulse animate__infinite ms-2';
extraBadge.innerHTML = '<i class="bi bi-shield-exclamation me-1"></i>KONKURS';
document.getElementById('customerStatus').parentNode.appendChild(extraBadge);
} else {
bankruptcyAlert.classList.add('d-none');
}
// Header // Header
document.getElementById('customerAvatar').textContent = getInitials(customer.name); document.getElementById('customerAvatar').textContent = getInitials(customer.name);
document.getElementById('customerName').textContent = customer.name; document.getElementById('customerName').textContent = customer.name;
@ -1472,121 +1178,6 @@ async function loadActivity() {
}, 500); }, 500);
} }
async function loadConversations() {
const container = document.getElementById('conversationsContainer');
container.innerHTML = '<div class="text-center py-5"><div class="spinner-border text-primary"></div></div>';
try {
const response = await fetch(`/api/v1/conversations?customer_id=${customerId}`);
if (!response.ok) throw new Error('Failed to load conversations');
const conversations = await response.json();
if (conversations.length === 0) {
container.innerHTML = '<div class="text-muted text-center py-5">Ingen samtaler fundet</div>';
return;
}
container.innerHTML = conversations.map(c => renderConversationCard(c)).join('');
} catch (error) {
console.error('Error loading conversations:', error);
container.innerHTML = '<div class="alert alert-danger">Kunne ikke hente samtaler</div>';
}
}
function renderConversationCard(c) {
const date = new Date(c.created_at).toLocaleString();
const duration = c.duration_seconds ? `${Math.floor(c.duration_seconds/60)}:${(c.duration_seconds%60).toString().padStart(2,'0')}` : '';
return `
<div class="card mb-3 shadow-sm conversation-item ${c.is_private ? 'border-warning' : ''}" data-text="${(c.transcript || '') + ' ' + (c.title || '')}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 class="card-title fw-bold mb-1">
${c.is_private ? '<i class="bi bi-lock-fill text-warning" title="Privat"></i> ' : ''}
${c.title}
</h6>
<div class="small text-muted">
<i class="bi bi-calendar me-1"></i> ${date}
${duration ? `• <i class="bi bi-clock me-1"></i> ${duration}` : ''}
<span class="badge bg-light text-dark border">${c.source}</span>
<span class="badge bg-secondary">${c.category || 'General'}</span>
</div>
</div>
<div class="dropdown">
<button class="btn btn-link text-muted p-0" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="#" onclick="togglePrivacy(${c.id}, ${!c.is_private})">
${c.is_private ? 'Gør Offentlig' : 'Gør Privat'}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item text-danger" href="#" onclick="deleteConversation(${c.id})">Slet</a></li>
</ul>
</div>
</div>
<audio controls class="w-100 mb-3 bg-light rounded">
<source src="/api/v1/conversations/${c.id}/audio" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
${c.transcript ? `
<div class="accordion" id="accordion-${c.id}">
<div class="accordion-item border-0">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2 bg-light rounded" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-${c.id}">
<i class="bi bi-file-text me-2"></i> Vis Transskribering
</button>
</h2>
<div id="collapse-${c.id}" class="accordion-collapse collapse" data-bs-parent="#accordion-${c.id}">
<div class="accordion-body bg-light rounded mt-2 small font-monospace" style="white-space: pre-wrap;">${c.transcript}</div>
</div>
</div>
</div>
` : ''}
</div>
</div>
`;
}
function filterConversations() {
const query = document.getElementById('conversationSearch').value.toLowerCase();
const items = document.querySelectorAll('.conversation-item');
items.forEach(item => {
const text = item.getAttribute('data-text').toLowerCase();
item.style.display = text.includes(query) ? 'block' : 'none';
});
}
async function togglePrivacy(id, makePrivate) {
try {
await fetch(`/api/v1/conversations/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({is_private: makePrivate})
});
loadConversations(); // Reload
} catch(e) { alert('Fejl'); }
}
async function deleteConversation(id) {
if(!confirm('Vil du slette denne samtale?')) return;
// User requested "SLET alt" capability.
// If user confirms again, we do hard delete.
const hard = confirm('ADVARSEL: Skal dette være en permanent sletning af fil og data? (Kan ikke fortrydes)\n\nTryk OK for Permanent Sletning.\nTryk Cancel for Papirkurv.');
try {
await fetch(`/api/v1/conversations/${id}?hard_delete=${hard}`, { method: 'DELETE' });
loadConversations();
} catch(e) { alert('Fejl under sletning'); }
}
async function toggleSubscriptionDetails(subscriptionId, itemId) { async function toggleSubscriptionDetails(subscriptionId, itemId) {
const linesDiv = document.getElementById(`${itemId}-lines`); const linesDiv = document.getElementById(`${itemId}-lines`);
const icon = document.getElementById(`${itemId}-icon`); const icon = document.getElementById(`${itemId}-icon`);
@ -1683,270 +1274,8 @@ function toggleLineItems(itemId) {
} }
function editCustomer() { function editCustomer() {
if (!customerData) { // TODO: Open edit modal with pre-filled data
alert('Kunde data ikke indlæst endnu'); console.log('Edit customer:', customerId);
return;
}
// Pre-fill form with current data
document.getElementById('editName').value = customerData.name || '';
document.getElementById('editCvrNumber').value = customerData.cvr_number || '';
document.getElementById('editEmail').value = customerData.email || '';
document.getElementById('editInvoiceEmail').value = customerData.invoice_email || '';
document.getElementById('editPhone').value = customerData.phone || '';
document.getElementById('editMobilePhone').value = customerData.mobile_phone || '';
document.getElementById('editWebsite').value = customerData.website || '';
document.getElementById('editCountry').value = customerData.country || 'DK';
document.getElementById('editAddress').value = customerData.address || '';
document.getElementById('editPostalCode').value = customerData.postal_code || '';
document.getElementById('editCity').value = customerData.city || '';
document.getElementById('editIsActive').checked = customerData.is_active !== false;
// Show modal
const modal = new bootstrap.Modal(document.getElementById('editCustomerModal'));
modal.show();
}
async function saveCustomerEdit() {
const updateData = {
name: document.getElementById('editName').value,
cvr_number: document.getElementById('editCvrNumber').value || null,
email: document.getElementById('editEmail').value || null,
invoice_email: document.getElementById('editInvoiceEmail').value || null,
phone: document.getElementById('editPhone').value || null,
mobile_phone: document.getElementById('editMobilePhone').value || null,
website: document.getElementById('editWebsite').value || null,
country: document.getElementById('editCountry').value || 'DK',
address: document.getElementById('editAddress').value || null,
postal_code: document.getElementById('editPostalCode').value || null,
city: document.getElementById('editCity').value || null,
is_active: document.getElementById('editIsActive').checked
};
// Validate required fields
if (!updateData.name || updateData.name.trim() === '') {
alert('Virksomhedsnavn er påkrævet');
return;
}
try {
const response = await fetch(`/api/v1/customers/${customerId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(updateData)
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Kunne ikke opdatere kunde');
}
const updatedCustomer = await response.json();
// Close modal
bootstrap.Modal.getInstance(document.getElementById('editCustomerModal')).hide();
// Reload customer data
await loadCustomer();
// Show success message
alert('✓ Kunde opdateret succesfuldt!');
} catch (error) {
console.error('Error updating customer:', error);
alert('Fejl ved opdatering: ' + error.message);
}
}
// Data Consistency Functions
let consistencyData = null;
async function checkDataConsistency() {
try {
const response = await fetch(`/api/v1/customers/${customerId}/data-consistency`);
const data = await response.json();
if (!data.enabled) {
console.log('Data consistency checking is disabled');
return;
}
consistencyData = data;
// Show alert if there are discrepancies
if (data.discrepancy_count > 0) {
document.getElementById('discrepancyCount').textContent = data.discrepancy_count;
document.getElementById('consistencyAlert').classList.remove('d-none');
} else {
document.getElementById('consistencyAlert').classList.add('d-none');
}
} catch (error) {
console.error('Error checking data consistency:', error);
}
}
function showConsistencyModal() {
if (!consistencyData) {
alert('Ingen data tilgængelig');
return;
}
const tbody = document.getElementById('consistencyTableBody');
tbody.innerHTML = '';
// Field labels in Danish
const fieldLabels = {
'name': 'Navn',
'cvr_number': 'CVR Nummer',
'address': 'Adresse',
'city': 'By',
'postal_code': 'Postnummer',
'country': 'Land',
'phone': 'Telefon',
'mobile_phone': 'Mobil',
'email': 'Email',
'website': 'Hjemmeside',
'invoice_email': 'Faktura Email'
};
// Only show fields with discrepancies
for (const [fieldName, fieldData] of Object.entries(consistencyData.discrepancies)) {
if (!fieldData.discrepancy) continue;
const row = document.createElement('tr');
row.className = 'table-warning';
// Field name
const fieldCell = document.createElement('td');
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
row.appendChild(fieldCell);
// Hub value
const hubCell = document.createElement('td');
hubCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
<label class="form-check-label" for="hub_${fieldName}">
${fieldData.hub || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
row.appendChild(hubCell);
// vTiger value
const vtigerCell = document.createElement('td');
if (consistencyData.systems_available.vtiger) {
vtigerCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
<label class="form-check-label" for="vtiger_${fieldName}">
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
vtigerCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(vtigerCell);
// e-conomic value
const economicCell = document.createElement('td');
if (consistencyData.systems_available.economic) {
economicCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
<label class="form-check-label" for="economic_${fieldName}">
${fieldData.economic || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
economicCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(economicCell);
// Action cell (which system to use)
const actionCell = document.createElement('td');
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
row.appendChild(actionCell);
tbody.appendChild(row);
}
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
modal.show();
}
async function syncSelectedFields() {
const selections = [];
// Gather all selected values
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
if (radioButtons.length === 0) {
alert('Vælg venligst mindst ét felt at synkronisere');
return;
}
radioButtons.forEach(radio => {
const fieldName = radio.name.replace('field_', '');
const sourceSystem = radio.value;
const sourceValue = radio.dataset.value;
selections.push({
field_name: fieldName,
source_system: sourceSystem,
source_value: sourceValue
});
});
// Confirm action
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
return;
}
// Sync each field
let successCount = 0;
let failCount = 0;
for (const selection of selections) {
try {
const response = await fetch(
`/api/v1/customers/${customerId}/sync-field?` +
new URLSearchParams(selection),
{ method: 'POST' }
);
if (response.ok) {
successCount++;
} else {
failCount++;
console.error(`Failed to sync ${selection.field_name}`);
}
} catch (error) {
failCount++;
console.error(`Error syncing ${selection.field_name}:`, error);
}
}
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
modal.hide();
// Show result
if (failCount === 0) {
alert(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`);
} else {
alert(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`);
}
// Reload customer data and recheck consistency
await loadCustomer();
await checkDataConsistency();
} }
function showAddContactModal() { function showAddContactModal() {
@ -2208,199 +1537,5 @@ function editInternalComment() {
editDiv.style.display = 'block'; editDiv.style.display = 'block';
displayDiv.style.display = 'none'; displayDiv.style.display = 'none';
} }
/**
* Load and render subscription billing matrix from e-conomic
*/
async function loadBillingMatrix() {
const loading = document.getElementById('billingMatrixLoading');
const container = document.getElementById('billingMatrixContainer');
const empty = document.getElementById('billingMatrixEmpty');
// Show loading state
loading.style.display = 'block';
container.style.display = 'none';
empty.style.display = 'none';
try {
const response = await fetch(`/api/v1/customers/${customerId}/subscriptions/billing-matrix`);
const matrix = await response.json();
console.log('📊 Billing matrix:', matrix);
if (!response.ok) {
throw new Error(matrix.detail || 'Failed to load billing matrix');
}
// Check if matrix has products
if (!matrix.products || matrix.products.length === 0) {
empty.style.display = 'block';
loading.style.display = 'none';
return;
}
// Render matrix
renderBillingMatrix(matrix);
} catch (error) {
console.error('Failed to load billing matrix:', error);
loading.innerHTML = `<div class="alert alert-danger"><i class="bi bi-exclamation-circle me-2"></i>${error.message}</div>`;
}
}
function renderBillingMatrix(matrix) {
const headerRow = document.getElementById('matrixHeaderRow');
const bodyRows = document.getElementById('matrixBodyRows');
// Get all unique months across all products
const monthsSet = new Set();
matrix.products.forEach(product => {
product.rows.forEach(row => {
monthsSet.add(row.year_month);
});
});
const months = Array.from(monthsSet).sort();
if (months.length === 0) {
document.getElementById('billingMatrixEmpty').style.display = 'block';
document.getElementById('billingMatrixLoading').style.display = 'none';
return;
}
// Build header with month columns
headerRow.innerHTML = '<th style="min-width: 200px;">Vare</th>' +
months.map(month => {
const date = new Date(month + '-01');
const label = date.toLocaleDateString('da-DK', { month: 'short', year: '2-digit' });
return `<th style="min-width: 100px; text-align: center; font-size: 0.9rem;">${label}</th>`;
}).join('') +
'<th style="min-width: 80px; text-align: right;">I alt</th>';
// Build body with product rows
bodyRows.innerHTML = matrix.products.map(product => {
const monthCells = months.map(month => {
const cell = product.rows.find(r => r.year_month === month);
if (!cell) {
return '<td class="text-muted text-center" style="font-size: 0.85rem;">-</td>';
}
const amount = cell.amount || 0;
const statusBadge = getStatusBadge(cell.status);
const tooltip = cell.period_label ? ` title="${cell.period_label}${cell.invoice_number ? ' • ' + cell.invoice_number : ''}"` : '';
return `<td class="text-center" style="font-size: 0.9rem;"${tooltip}>
<div class="d-flex flex-column align-items-center">
<div class="fw-500">${formatDKK(amount)}</div>
<div>${statusBadge}</div>
</div>
</td>`;
}).join('');
return `<tr>
<td class="fw-500">${escapeHtml(product.product_name)}</td>
${monthCells}
<td class="text-right fw-bold" style="text-align: right;">${formatDKK(product.total_amount)}</td>
</tr>`;
}).join('');
// Show matrix, hide loading
document.getElementById('billingMatrixContainer').style.display = 'block';
document.getElementById('billingMatrixLoading').style.display = 'none';
}
function getStatusBadge(status) {
const badgeMap = {
'paid': { color: 'success', icon: 'check-circle', label: 'Betalt' },
'invoiced': { color: 'warning', icon: 'file-text', label: 'Faktureret' },
'draft': { color: 'secondary', icon: 'file-earmark', label: 'Kladde' },
'missing': { color: 'danger', icon: 'exclamation-triangle', label: 'Manglende' },
'credited': { color: 'info', icon: 'arrow-counterclockwise', label: 'Krediteret' }
};
const badge = badgeMap[status] || { color: 'secondary', icon: 'question-circle', label: status };
return `<span class="badge bg-${badge.color}" style="font-size: 0.75rem; margin-top: 2px;">
<i class="bi bi-${badge.icon}" style="font-size: 0.65rem;"></i> ${badge.label}
</span>`;
}
function formatDKK(amount) {
if (!amount || amount === 0) return '0 kr';
return amount.toLocaleString('da-DK', { style: 'currency', currency: 'DKK', minimumFractionDigits: 0 });
}
// Auto-load matrix when subscriptions tab is shown
document.addEventListener('DOMContentLoaded', () => {
const subscriptionsTab = document.querySelector('a[href="#subscriptions"]');
if (subscriptionsTab) {
subscriptionsTab.addEventListener('shown.bs.tab', () => {
// Load matrix if not already loaded
if (!document.getElementById('billingMatrixContainer').innerHTML.includes('table-responsive')) {
setTimeout(() => loadBillingMatrix(), 300);
}
});
}
// Auto-load matrix when billing-matrix tab is shown
const billingMatrixTab = document.querySelector('a[href="#billing-matrix"]');
if (billingMatrixTab) {
billingMatrixTab.addEventListener('shown.bs.tab', () => {
const container = document.getElementById('billingMatrixContainer');
const loading = document.getElementById('billingMatrixLoading');
// Check if we need to load
if (loading.style.display !== 'none' && container.style.display === 'none') {
loadBillingMatrix();
}
});
}
});
</script> </script>
<!-- Data Consistency Comparison Modal -->
<div class="modal fade" id="consistencyModal" tabindex="-1" aria-labelledby="consistencyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="consistencyModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Sammenlign Kundedata
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer.
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 20%;">Felt</th>
<th style="width: 20%;">BMC Hub</th>
<th style="width: 20%;">vTiger</th>
<th style="width: 20%;">e-conomic</th>
<th style="width: 20%;">Vælg Korrekt</th>
</tr>
</thead>
<tbody id="consistencyTableBody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="bi bi-x-circle me-2"></i>Luk
</button>
<button type="button" class="btn btn-primary" onclick="syncSelectedFields()">
<i class="bi bi-arrow-repeat me-2"></i>Synkroniser Valgte
</button>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}

View File

@ -1,7 +1,6 @@
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from app.core.database import execute_query_single
router = APIRouter() router = APIRouter()
templates = Jinja2Templates(directory="app") templates = Jinja2Templates(directory="app")
@ -11,52 +10,4 @@ async def dashboard(request: Request):
""" """
Render the dashboard page Render the dashboard page
""" """
# Fetch count of unknown billing worklogs return templates.TemplateResponse("dashboard/frontend/index.html", {"request": request})
unknown_query = """
SELECT COUNT(*) as count
FROM tticket_worklog
WHERE billing_method = 'unknown'
AND status NOT IN ('billed', 'rejected')
"""
# Fetch active bankruptcy alerts
# Finds emails classified as 'bankruptcy' that are not processed
bankruptcy_query = """
SELECT e.id, e.subject, e.received_date,
v.name as vendor_name, v.id as vendor_id,
c.name as customer_name, c.id as customer_id
FROM email_messages e
LEFT JOIN vendors v ON e.supplier_id = v.id
LEFT JOIN customers c ON e.customer_id = c.id
WHERE e.classification = 'bankruptcy'
AND e.status NOT IN ('archived')
AND (e.customer_id IS NOT NULL OR e.supplier_id IS NOT NULL)
ORDER BY e.received_date DESC
"""
from app.core.database import execute_query
result = execute_query_single(unknown_query)
unknown_count = result['count'] if result else 0
raw_alerts = execute_query(bankruptcy_query) or []
bankruptcy_alerts = []
for alert in raw_alerts:
item = dict(alert)
# Determine display name
if item.get('customer_name'):
item['display_name'] = f"Kunde: {item['customer_name']}"
elif item.get('vendor_name'):
item['display_name'] = item['vendor_name']
elif 'statstidende' in item.get('subject', '').lower():
item['display_name'] = 'Statstidende'
else:
item['display_name'] = 'Ukendt Afsender'
bankruptcy_alerts.append(item)
return templates.TemplateResponse("dashboard/frontend/index.html", {
"request": request,
"unknown_worklog_count": unknown_count,
"bankruptcy_alerts": bankruptcy_alerts
})

View File

@ -14,40 +14,6 @@
</div> </div>
</div> </div>
<!-- Alerts -->
{% if bankruptcy_alerts %}
<div class="alert alert-danger d-flex align-items-center mb-4 border-0 shadow-sm" role="alert" style="background-color: #ffeaea; color: #842029;">
<i class="bi bi-shield-exclamation flex-shrink-0 me-3 fs-2 animate__animated animate__pulse animate__infinite"></i>
<div class="flex-grow-1">
<h5 class="alert-heading mb-1 fw-bold">⚠️ KONKURS ALARM</h5>
<div>Systemet har registreret <strong>{{ bankruptcy_alerts|length }}</strong> potentiel(le) konkurssag(er).</div>
<ul class="mb-0 mt-2 small list-unstyled">
{% for alert in bankruptcy_alerts %}
<li class="mb-1">
<span class="badge bg-danger me-2">ALARM</span>
<strong>{{ alert.display_name }}:</strong>
<a href="/emails?id={{ alert.id }}" class="alert-link text-decoration-underline">{{ alert.subject }}</a>
</li>
{% endfor %}
</ul>
</div>
<div>
<a href="/emails?filter=bankruptcy" class="btn btn-sm btn-danger px-3">Håndter Nu <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
{% endif %}
{% if unknown_worklog_count > 0 %}
<div class="alert alert-warning d-flex align-items-center mb-5" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 me-3 fs-4"></i>
<div>
<h5 class="alert-heading mb-1">Tidsregistreringer kræver handling</h5>
Der er <strong>{{ unknown_worklog_count }}</strong> tidsregistrering(er) med typen "Ved ikke".
<a href="/ticket/worklog/review" class="alert-link">Gå til godkendelse</a> for at afklare dem.
</div>
</div>
{% endif %}
<div class="row g-4 mb-5"> <div class="row g-4 mb-5">
<div class="col-md-3"> <div class="col-md-3">
<div class="card stat-card p-4 h-100"> <div class="card stat-card p-4 h-100">

View File

@ -4,7 +4,7 @@ API endpoints for email viewing, classification, and rule management
""" """
import logging import logging
from fastapi import APIRouter, HTTPException, Query, UploadFile, File from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel
from datetime import datetime, date from datetime import datetime, date
@ -148,7 +148,6 @@ class ProcessingStats(BaseModel):
async def list_emails( async def list_emails(
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
classification: Optional[str] = Query(None), classification: 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)
): ):
@ -165,11 +164,6 @@ async def list_emails(
where_clauses.append("em.classification = %s") where_clauses.append("em.classification = %s")
params.append(classification) params.append(classification)
if q:
where_clauses.append("(em.subject ILIKE %s OR em.sender_email ILIKE %s OR em.sender_name ILIKE %s)")
search_term = f"%{q}%"
params.extend([search_term, search_term, search_term])
where_sql = " AND ".join(where_clauses) where_sql = " AND ".join(where_clauses)
query = f""" query = f"""
@ -354,7 +348,7 @@ async def delete_email(email_id: int):
@router.post("/emails/{email_id}/reprocess") @router.post("/emails/{email_id}/reprocess")
async def reprocess_email(email_id: int): async def reprocess_email(email_id: int):
"""Reprocess email (re-classify, run workflows, and apply rules)""" """Reprocess email (re-classify and apply rules)"""
try: try:
# Get email # Get email
query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL" query = "SELECT * FROM email_messages WHERE id = %s AND deleted_at IS NULL"
@ -365,21 +359,30 @@ async def reprocess_email(email_id: int):
email = result[0] email = result[0]
# Re-classify and run full processing pipeline # Re-classify
processor = EmailProcessorService() processor = EmailProcessorService()
processing_result = await processor.process_single_email(email) classification, confidence = await processor.classify_email(
email['subject'],
email['body_text'] or email['body_html']
)
# Re-fetch updated email # Update classification
result = execute_query(query, (email_id,)) update_query = """
email = result[0] UPDATE email_messages
SET classification = %s,
confidence_score = %s,
classification_date = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (classification, confidence, email_id))
logger.info(f"🔄 Reprocessed email {email_id}: {email['classification']} ({email.get('confidence_score', 0):.2f})") logger.info(f"🔄 Reprocessed email {email_id}: {classification} ({confidence:.2f})")
return { return {
"success": True, "success": True,
"message": "Email reprocessed with workflows", "message": "Email reprocessed",
"classification": email['classification'], "classification": classification,
"confidence": email.get('confidence_score', 0), "confidence": confidence
"workflows_executed": processing_result.get('workflows_executed', 0)
} }
except HTTPException: except HTTPException:
@ -407,211 +410,6 @@ async def process_emails():
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/upload")
async def upload_emails(files: List[UploadFile] = File(...)):
"""
Upload email files (.eml or .msg) via drag-and-drop
Supports multiple files at once
"""
from app.services.email_service import EmailService
from app.services.email_processor_service import EmailProcessorService
from app.services.email_workflow_service import email_workflow_service
from app.services.email_activity_logger import EmailActivityLogger
from app.core.config import settings
email_service = EmailService()
processor = EmailProcessorService()
activity_logger = EmailActivityLogger()
results = []
max_size = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024 # Convert MB to bytes
logger.info(f"📤 Upload started: {len(files)} file(s)")
for file in files:
try:
logger.info(f"📄 Processing file: {file.filename}")
# Validate file type
if not file.filename.lower().endswith(('.eml', '.msg')):
logger.warning(f"⚠️ Skipped non-email file: {file.filename}")
results.append({
"filename": file.filename,
"status": "skipped",
"message": "Only .eml and .msg files are supported"
})
continue
# Read file content
content = await file.read()
logger.info(f"📊 File size: {len(content)} bytes")
# Check file size
if len(content) > max_size:
logger.warning(f"⚠️ File too large: {file.filename}")
results.append({
"filename": file.filename,
"status": "error",
"message": f"File too large (max {settings.EMAIL_MAX_UPLOAD_SIZE_MB}MB)"
})
continue
# Parse email based on file type
if file.filename.lower().endswith('.eml'):
logger.info(f"📧 Parsing .eml file: {file.filename}")
email_data = email_service.parse_eml_file(content)
else: # .msg
logger.info(f"📧 Parsing .msg file: {file.filename}")
email_data = email_service.parse_msg_file(content)
if not email_data:
logger.error(f"❌ Failed to parse: {file.filename}")
results.append({
"filename": file.filename,
"status": "error",
"message": "Failed to parse email file"
})
continue
logger.info(f"✅ Parsed: {email_data.get('subject', 'No Subject')[:50]}")
# Save to database
email_id = await email_service.save_uploaded_email(email_data)
if email_id is None:
logger.info(f"⏭️ Duplicate email: {file.filename}")
results.append({
"filename": file.filename,
"status": "duplicate",
"message": "Email already exists in system"
})
continue
logger.info(f"💾 Saved to database with ID: {email_id}")
# Log activity
activity_logger.log_fetched(
email_id=email_id,
source="manual_upload",
metadata={"filename": file.filename}
)
# Auto-classify
classification = None
confidence = None
try:
logger.info(f"🤖 Classifying email {email_id}...")
classification, confidence = await processor.classify_email(
email_data['subject'],
email_data['body_text'] or email_data['body_html']
)
logger.info(f"✅ Classified as: {classification} ({confidence:.2f})")
# Update classification
update_query = """
UPDATE email_messages
SET classification = %s, confidence_score = %s,
classification_date = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (classification, confidence, email_id))
activity_logger.log_classified(
email_id=email_id,
classification=classification,
confidence=confidence,
metadata={"method": "auto", "source": "manual_upload"}
)
except Exception as e:
logger.warning(f"⚠️ Classification failed for uploaded email: {e}")
# Execute workflows
try:
logger.info(f"⚙️ Executing workflows for email {email_id}...")
await email_workflow_service.execute_workflows_for_email(email_id)
except Exception as e:
logger.warning(f"⚠️ Workflow execution failed for uploaded email: {e}")
results.append({
"filename": file.filename,
"status": "success",
"message": "Email imported successfully",
"email_id": email_id,
"subject": email_data['subject'],
"classification": classification,
"confidence": confidence,
"attachments": len(email_data.get('attachments', []))
})
logger.info(f"✅ Successfully processed: {file.filename} -> Email ID {email_id}")
except Exception as e:
logger.error(f"❌ Failed to process {file.filename}: {e}", exc_info=True)
results.append({
"filename": file.filename,
"status": "error",
"message": str(e)
})
# Summary
success_count = len([r for r in results if r["status"] == "success"])
duplicate_count = len([r for r in results if r["status"] == "duplicate"])
error_count = len([r for r in results if r["status"] == "error"])
skipped_count = len([r for r in results if r["status"] == "skipped"])
logger.info(f"📊 Upload summary: {success_count} success, {duplicate_count} duplicates, {error_count} errors, {skipped_count} skipped")
return {
"uploaded": success_count,
"duplicates": duplicate_count,
"failed": error_count,
"skipped": skipped_count,
"results": results
}
@router.get("/emails/processing/stats")
async def get_processing_stats():
"""Get email processing statistics"""
try:
query = """
SELECT
COUNT(*) as total_emails,
COUNT(*) FILTER (WHERE status = 'new') as new_emails,
COUNT(*) FILTER (WHERE status = 'processed') as processed_emails,
COUNT(*) FILTER (WHERE status = 'error') as error_emails,
COUNT(*) FILTER (WHERE has_attachments = true) as with_attachments,
COUNT(*) FILTER (WHERE import_method = 'manual_upload') as manually_uploaded,
COUNT(*) FILTER (WHERE import_method = 'imap') as from_imap,
COUNT(*) FILTER (WHERE import_method = 'graph_api') as from_graph_api,
MAX(received_date) as last_email_received
FROM email_messages
WHERE deleted_at IS NULL
AND received_date >= NOW() - INTERVAL '30 days'
"""
result = execute_query(query)
if result:
return result[0]
else:
return {
"total_emails": 0,
"new_emails": 0,
"processed_emails": 0,
"error_emails": 0,
"with_attachments": 0,
"manually_uploaded": 0,
"from_imap": 0,
"from_graph_api": 0,
"last_email_received": None
}
except Exception as e:
logger.error(f"❌ Error getting processing stats: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/emails/bulk/archive") @router.post("/emails/bulk/archive")
async def bulk_archive(email_ids: List[int]): async def bulk_archive(email_ids: List[int]):
"""Bulk archive emails""" """Bulk archive emails"""
@ -652,14 +450,25 @@ async def bulk_reprocess(email_ids: List[int]):
result = execute_query(query, (email_id,)) result = execute_query(query, (email_id,))
if result: if result:
email_data = result[0] email = result[0]
# Use central processing logic classification, confidence = await processor.classify_email(
await processor.process_single_email(email_data) email['subject'],
email['body_text'] or email['body_html']
)
update_query = """
UPDATE email_messages
SET classification = %s, confidence_score = %s,
classification_date = CURRENT_TIMESTAMP
WHERE id = %s
"""
execute_update(update_query, (classification, confidence, email_id))
success_count += 1 success_count += 1
except Exception as e: except Exception as e:
logger.error(f"Error reprocessing email {email_id}: {e}") logger.error(f"Failed to reprocess email {email_id}: {e}")
return {"success": True, "count": success_count} logger.info(f"🔄 Bulk reprocessed {success_count}/{len(email_ids)} emails")
return {"success": True, "message": f"{success_count} emails reprocessed"}
except Exception as e: except Exception as e:
logger.error(f"❌ Error bulk reprocessing: {e}") logger.error(f"❌ Error bulk reprocessing: {e}")
@ -870,7 +679,6 @@ async def get_email_stats():
COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails, COUNT(CASE WHEN status = 'processed' THEN 1 END) as processed_emails,
COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices, COUNT(CASE WHEN classification = 'invoice' THEN 1 END) as invoices,
COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations, COUNT(CASE WHEN classification = 'time_confirmation' THEN 1 END) as time_confirmations,
COUNT(CASE WHEN classification = 'newsletter' THEN 1 END) as newsletters,
COUNT(CASE WHEN classification = 'spam' THEN 1 END) as spam_emails, COUNT(CASE WHEN classification = 'spam' THEN 1 END) as spam_emails,
COUNT(CASE WHEN auto_processed THEN 1 END) as auto_processed, COUNT(CASE WHEN auto_processed THEN 1 END) as auto_processed,
AVG(confidence_score) as avg_confidence AVG(confidence_score) as avg_confidence
@ -1065,13 +873,11 @@ async def execute_workflows_for_email(email_id: int):
FROM email_messages FROM email_messages
WHERE id = %s AND deleted_at IS NULL WHERE id = %s AND deleted_at IS NULL
""" """
email_result = execute_query(query, (email_id,)) email_data = execute_query(query, (email_id,))
if not email_result: if not email_data:
raise HTTPException(status_code=404, detail="Email not found") raise HTTPException(status_code=404, detail="Email not found")
email_data = email_result[0] # Get first row as dict
# Execute workflows # Execute workflows
result = await email_workflow_service.execute_workflows(email_data) result = await email_workflow_service.execute_workflows(email_data)

View File

@ -713,64 +713,6 @@
max-height: calc(100vh - 250px); max-height: calc(100vh - 250px);
overflow-y: auto; overflow-y: auto;
} }
/* Email Upload Drop Zone */
.email-upload-zone {
padding: 0.75rem 1rem;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.drop-zone {
border: 2px dashed var(--accent);
border-radius: var(--border-radius);
padding: 1.5rem 1rem;
text-align: center;
background: rgba(15, 76, 117, 0.03);
cursor: pointer;
transition: all 0.3s ease;
}
.drop-zone:hover {
background: rgba(15, 76, 117, 0.08);
border-color: #0a3a5c;
transform: scale(1.02);
}
.drop-zone.dragover {
background: rgba(15, 76, 117, 0.15);
border-color: var(--accent);
border-width: 3px;
transform: scale(1.05);
}
.drop-zone i {
font-size: 2rem;
color: var(--accent);
display: block;
margin-bottom: 0.5rem;
}
.drop-zone p {
margin: 0;
font-size: 0.9rem;
}
.upload-progress {
padding: 0 0.5rem;
}
.upload-progress .progress {
height: 4px;
}
[data-bs-theme="dark"] .drop-zone {
background: rgba(15, 76, 117, 0.1);
border-color: var(--accent-light);
}
[data-bs-theme="dark"] .drop-zone:hover {
background: rgba(15, 76, 117, 0.2);
}
</style> </style>
{% endblock %} {% endblock %}
@ -827,28 +769,6 @@
</div> </div>
</div> </div>
<!-- Email Upload Drop Zone -->
<div class="email-upload-zone" id="emailUploadZone" style="display: none;">
<div class="drop-zone" id="dropZone">
<i class="bi bi-cloud-upload"></i>
<p class="mb-1"><strong>Træk emails hertil</strong></p>
<small class="text-muted">eller klik for at vælge filer</small>
<small class="text-muted d-block mt-1">.eml og .msg filer</small>
<input type="file" id="fileInput" multiple accept=".eml,.msg" style="display: none;">
</div>
<div class="upload-progress mt-2" id="uploadProgress" style="display: none;">
<div class="progress">
<div class="progress-bar progress-bar-striped progress-bar-animated" id="progressBar" style="width: 0%"></div>
</div>
<small class="text-muted d-block text-center mt-1" id="uploadStatus">Uploader...</small>
</div>
</div>
<div class="p-2 border-bottom">
<button class="btn btn-sm btn-outline-secondary w-100" onclick="toggleUploadZone()">
<i class="bi bi-upload me-1"></i> Upload Emails
</button>
</div>
<div class="email-list-filters"> <div class="email-list-filters">
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')"> <button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
Aktive <span class="count" id="countActive">0</span> Aktive <span class="count" id="countActive">0</span>
@ -877,9 +797,6 @@
<button class="filter-pill" data-filter="general" onclick="setFilter('general')"> <button class="filter-pill" data-filter="general" onclick="setFilter('general')">
Generel <span class="count" id="countGeneral">0</span> Generel <span class="count" id="countGeneral">0</span>
</button> </button>
<button class="filter-pill" data-filter="newsletter" onclick="setFilter('newsletter')">
Info/Nyhed <span class="count" id="countNewsletter">0</span>
</button>
<button class="filter-pill" data-filter="spam" onclick="setFilter('spam')"> <button class="filter-pill" data-filter="spam" onclick="setFilter('spam')">
Spam <span class="count" id="countSpam">0</span> Spam <span class="count" id="countSpam">0</span>
</button> </button>
@ -1432,10 +1349,8 @@ async function loadEmails(searchQuery = '') {
// Handle special filters // Handle special filters
if (currentFilter === 'active') { if (currentFilter === 'active') {
// Show only new, error, or flagged (pending review) emails // Show only new, error, or flagged (pending review) emails
// If searching, ignore status filter to allow global search // Exclude processed and archived
if (!searchQuery) {
url += '&status=new'; url += '&status=new';
}
} else if (currentFilter === 'processed') { } else if (currentFilter === 'processed') {
url += '&status=processed'; url += '&status=processed';
} else if (currentFilter !== 'all') { } else if (currentFilter !== 'all') {
@ -1584,9 +1499,7 @@ async function loadEmailDetail(emailId) {
} }
} catch (error) { } catch (error) {
console.error('Failed to load email detail:', error); console.error('Failed to load email detail:', error);
const errorMsg = error?.message || String(error) || 'Ukendt fejl'; showError('Kunne ikke indlæse email detaljer: ' + error.message);
alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
showEmptyState();
} }
} }
@ -1748,11 +1661,6 @@ function renderEmailDetail(email) {
function renderEmailAnalysis(email) { function renderEmailAnalysis(email) {
const aiAnalysisTab = document.getElementById('aiAnalysisTab'); const aiAnalysisTab = document.getElementById('aiAnalysisTab');
if (!aiAnalysisTab) {
console.error('aiAnalysisTab element not found in DOM');
return;
}
const classification = email.classification || 'general'; const classification = email.classification || 'general';
const confidence = email.confidence_score || 0; const confidence = email.confidence_score || 0;
@ -1786,7 +1694,6 @@ function renderEmailAnalysis(email) {
<option value="freight_note" ${classification === 'freight_note' ? 'selected' : ''}>🚚 Fragtnote</option> <option value="freight_note" ${classification === 'freight_note' ? 'selected' : ''}>🚚 Fragtnote</option>
<option value="time_confirmation" ${classification === 'time_confirmation' ? 'selected' : ''}>⏰ Tidsregistrering</option> <option value="time_confirmation" ${classification === 'time_confirmation' ? 'selected' : ''}>⏰ Tidsregistrering</option>
<option value="case_notification" ${classification === 'case_notification' ? 'selected' : ''}>📋 Sagsnotifikation</option> <option value="case_notification" ${classification === 'case_notification' ? 'selected' : ''}>📋 Sagsnotifikation</option>
<option value="recording" ${classification === 'recording' ? 'selected' : ''}>🎤 Optagelse</option>
<option value="bankruptcy" ${classification === 'bankruptcy' ? 'selected' : ''}>⚠️ Konkurs</option> <option value="bankruptcy" ${classification === 'bankruptcy' ? 'selected' : ''}>⚠️ Konkurs</option>
<option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option> <option value="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option> <option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
@ -1892,8 +1799,7 @@ async function loadStats() {
document.getElementById('countFreight').textContent = 0; document.getElementById('countFreight').textContent = 0;
document.getElementById('countTime').textContent = stats.time_confirmations || 0; document.getElementById('countTime').textContent = stats.time_confirmations || 0;
document.getElementById('countCase').textContent = 0; document.getElementById('countCase').textContent = 0;
document.getElementById('countNewsletter').textContent = stats.newsletters || 0; document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations || 0;
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations - (stats.newsletters || 0) || 0;
document.getElementById('countSpam').textContent = stats.spam_emails || 0; document.getElementById('countSpam').textContent = stats.spam_emails || 0;
} catch (error) { } catch (error) {
console.error('Failed to load stats:', error); console.error('Failed to load stats:', error);
@ -3971,143 +3877,5 @@ function showNotification(message, type = 'info') {
document.body.appendChild(toast); document.body.appendChild(toast);
setTimeout(() => toast.remove(), 3000); setTimeout(() => toast.remove(), 3000);
} }
// Email Upload Functionality
function toggleUploadZone() {
const uploadZone = document.getElementById('emailUploadZone');
if (uploadZone.style.display === 'none') {
uploadZone.style.display = 'block';
} else {
uploadZone.style.display = 'none';
}
}
// Setup drag and drop
document.addEventListener('DOMContentLoaded', () => {
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
if (!dropZone || !fileInput) return;
// Click to select files
dropZone.addEventListener('click', () => fileInput.click());
// Drag and drop events
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
uploadEmailFiles(files);
});
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
uploadEmailFiles(files);
});
});
async function uploadEmailFiles(files) {
if (files.length === 0) return;
console.log(`📤 Starting upload of ${files.length} file(s)`);
const uploadProgress = document.getElementById('uploadProgress');
const progressBar = document.getElementById('progressBar');
const uploadStatus = document.getElementById('uploadStatus');
// Show progress
uploadProgress.style.display = 'block';
progressBar.style.width = '0%';
uploadStatus.textContent = `Uploader ${files.length} fil(er)...`;
const formData = new FormData();
files.forEach(file => {
console.log(`📎 Adding file to upload: ${file.name} (${file.size} bytes)`);
formData.append('files', file);
});
try {
console.log('🌐 Sending upload request to /api/v1/emails/upload');
const response = await fetch('/api/v1/emails/upload', {
method: 'POST',
body: formData
});
console.log(`📡 Response status: ${response.status}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
console.log('✅ Upload result:', result);
// Update progress
progressBar.style.width = '100%';
// Show result
const totalFiles = files.length;
const { uploaded, duplicates, failed, skipped } = result;
let statusMessage = [];
if (uploaded > 0) statusMessage.push(`${uploaded} uploadet`);
if (duplicates > 0) statusMessage.push(`${duplicates} eksisterer allerede`);
if (failed > 0) statusMessage.push(`${failed} fejlet`);
if (skipped > 0) statusMessage.push(`${skipped} sprunget over`);
uploadStatus.textContent = `✅ ${statusMessage.join(', ')}`;
// Show detailed results
if (result.results && result.results.length > 0) {
console.log('📋 Detailed results:');
result.results.forEach(r => {
console.log(` ${r.status}: ${r.filename} - ${r.message}`);
if (r.status === 'success') {
console.log(` ✅ Email ID: ${r.email_id}, Subject: ${r.subject}`);
showNotification(`✅ ${r.filename} uploadet`, 'success');
} else if (r.status === 'duplicate') {
showNotification(` ${r.filename} findes allerede`, 'info');
} else if (r.status === 'error') {
showNotification(`❌ ${r.filename}: ${r.message}`, 'danger');
}
});
}
// Reload email list after successful uploads
if (uploaded > 0) {
console.log(`🔄 Reloading email list (${uploaded} new emails uploaded)`);
setTimeout(async () => {
await loadEmails();
await loadStats();
console.log('✅ Email list and stats reloaded');
uploadProgress.style.display = 'none';
fileInput.value = '';
toggleUploadZone(); // Close upload zone after successful upload
}, 2000);
} else {
console.log(' No new emails uploaded, not reloading');
setTimeout(() => {
uploadProgress.style.display = 'none';
fileInput.value = '';
}, 3000);
}
} catch (error) {
console.error('❌ Upload failed:', error);
uploadStatus.textContent = '❌ Upload fejlede';
progressBar.classList.add('bg-danger');
showNotification('Upload fejlede: ' + error.message, 'danger');
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@ -1,3 +0,0 @@
"""
Scheduled Jobs Module
"""

View File

@ -1,115 +0,0 @@
"""
Daily sync job for e-conomic chart of accounts (kontoplan)
Scheduled to run every day at 06:00
Updates the economic_accounts cache table with latest data from e-conomic API
"""
import logging
from datetime import datetime
from app.core.database import execute_query, execute_update, execute_insert
from app.services.economic_service import get_economic_service
from app.core.config import settings
logger = logging.getLogger(__name__)
async def sync_economic_accounts():
"""
Sync e-conomic chart of accounts to local cache
This job:
1. Fetches all accounts from e-conomic API
2. Updates economic_accounts table
3. Logs sync statistics
"""
try:
logger.info("🔄 Starting daily e-conomic accounts sync...")
# Check if e-conomic is configured
if not settings.ECONOMIC_APP_SECRET_TOKEN or not settings.ECONOMIC_AGREEMENT_GRANT_TOKEN:
logger.warning("⚠️ e-conomic credentials not configured - skipping sync")
return {
"success": False,
"reason": "e-conomic credentials not configured"
}
# Get economic service
economic_service = get_economic_service()
# Fetch accounts from e-conomic
logger.info("📥 Fetching accounts from e-conomic API...")
accounts = economic_service.get_accounts()
if not accounts:
logger.warning("⚠️ No accounts returned from e-conomic API")
return {
"success": False,
"reason": "No accounts returned from API"
}
logger.info(f"📦 Fetched {len(accounts)} accounts from e-conomic")
# Update database - upsert each account
updated_count = 0
inserted_count = 0
for account in accounts:
account_number = account.get('accountNumber')
name = account.get('name')
account_type = account.get('accountType')
vat_code = account.get('vatCode', {}).get('vatCode') if account.get('vatCode') else None
if not account_number:
continue
# Check if account exists
existing = execute_query(
"SELECT account_number FROM economic_accounts WHERE account_number = %s",
(account_number,)
)
if existing:
# Update existing
execute_update(
"""UPDATE economic_accounts
SET name = %s, account_type = %s, vat_code = %s,
updated_at = CURRENT_TIMESTAMP
WHERE account_number = %s""",
(name, account_type, vat_code, account_number)
)
updated_count += 1
else:
# Insert new
execute_insert(
"""INSERT INTO economic_accounts
(account_number, name, account_type, vat_code, updated_at)
VALUES (%s, %s, %s, %s, CURRENT_TIMESTAMP)""",
(account_number, name, account_type, vat_code)
)
inserted_count += 1
logger.info(f"✅ e-conomic accounts sync complete: {inserted_count} inserted, {updated_count} updated")
return {
"success": True,
"fetched": len(accounts),
"inserted": inserted_count,
"updated": updated_count,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error(f"❌ e-conomic accounts sync failed: {e}", exc_info=True)
return {
"success": False,
"error": str(e),
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
# Allow running this job manually for testing
import asyncio
result = asyncio.run(sync_economic_accounts())
print(f"Sync result: {result}")

View File

@ -20,22 +20,6 @@ class CustomerCreate(CustomerBase):
pass pass
class CustomerUpdate(BaseModel):
"""Schema for updating a customer"""
name: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
cvr_number: Optional[str] = None
city: Optional[str] = None
postal_code: Optional[str] = None
country: Optional[str] = None
website: Optional[str] = None
mobile_phone: Optional[str] = None
invoice_email: Optional[str] = None
is_active: Optional[bool] = None
class Customer(CustomerBase): class Customer(CustomerBase):
"""Full customer schema""" """Full customer schema"""
id: int id: int
@ -105,37 +89,3 @@ class Vendor(VendorBase):
class Config: class Config:
from_attributes = True from_attributes = True
class ConversationBase(BaseModel):
title: str
transcript: Optional[str] = None
summary: Optional[str] = None
is_private: bool = False
customer_id: Optional[int] = None
ticket_id: Optional[int] = None
category: str = "General"
class ConversationCreate(ConversationBase):
audio_file_path: str
duration_seconds: int = 0
email_message_id: Optional[int] = None
class ConversationUpdate(BaseModel):
title: Optional[str] = None
is_private: Optional[bool] = None
ticket_id: Optional[int] = None
customer_id: Optional[int] = None
category: Optional[str] = None
# For soft delete via update if needed, though usually strict API endpoint
class Conversation(ConversationBase):
id: int
audio_file_path: str
duration_seconds: int
user_id: Optional[int] = None
source: str
created_at: datetime
updated_at: Optional[datetime] = None
deleted_at: Optional[datetime] = None
model_config = ConfigDict(from_attributes=True)

View File

@ -1,174 +0,0 @@
# Webshop Module
Dette er template strukturen for nye BMC Hub moduler.
## Struktur
```
webshop/
├── module.json # Metadata og konfiguration
├── README.md # Dokumentation
├── backend/
│ ├── __init__.py
│ └── router.py # FastAPI routes (API endpoints)
├── frontend/
│ ├── __init__.py
│ └── views.py # HTML view routes
├── templates/
│ └── index.html # Jinja2 templates
└── migrations/
└── 001_init.sql # Database migrations
```
## Opret nyt modul
```bash
python scripts/create_module.py webshop "My Module Description"
```
## Database Tables
Alle tabeller SKAL bruge `table_prefix` fra module.json:
```sql
-- Hvis table_prefix = "webshop_"
CREATE TABLE webshop_items (
id SERIAL PRIMARY KEY,
name VARCHAR(255)
);
```
Dette sikrer at moduler ikke kolliderer med core eller andre moduler.
### Customer Linking (Hvis nødvendigt)
Hvis dit modul skal have sin egen kunde-tabel (f.eks. ved sync fra eksternt system):
**SKAL altid linke til core customers:**
```sql
CREATE TABLE webshop_customers (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
external_id VARCHAR(100), -- ID fra eksternt system
hub_customer_id INTEGER REFERENCES customers(id), -- VIGTIG!
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Auto-link trigger (se migrations/001_init.sql for komplet eksempel)
CREATE TRIGGER trigger_auto_link_webshop_customer
BEFORE INSERT OR UPDATE OF name
ON webshop_customers
FOR EACH ROW
EXECUTE FUNCTION auto_link_webshop_customer();
```
**Hvorfor?** Dette sikrer at:
- ✅ E-conomic export virker automatisk
- ✅ Billing integration fungerer
- ✅ Ingen manuel linking nødvendig
**Alternativ:** Hvis modulet kun har simple kunde-relationer, brug direkte FK:
```sql
CREATE TABLE webshop_orders (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) -- Direkte link
);
```
## Konfiguration
Modul-specifikke miljøvariable følger mønsteret:
```bash
MODULES__MY_MODULE__API_KEY=secret123
MODULES__MY_MODULE__READ_ONLY=true
```
Tilgå i kode:
```python
from app.core.config import get_module_config
api_key = get_module_config("webshop", "API_KEY")
read_only = get_module_config("webshop", "READ_ONLY", default="true") == "true"
```
## Database Queries
Brug ALTID helper functions fra `app.core.database`:
```python
from app.core.database import execute_query, execute_insert
# Fetch
customers = execute_query(
"SELECT * FROM webshop_customers WHERE active = %s",
(True,)
)
# Insert
customer_id = execute_insert(
"INSERT INTO webshop_customers (name) VALUES (%s)",
("Test Customer",)
)
```
## Migrations
Migrations ligger i `migrations/` og køres manuelt eller via migration tool:
```python
from app.core.database import execute_module_migration
with open("migrations/001_init.sql") as f:
migration_sql = f.read()
success = execute_module_migration("webshop", migration_sql)
```
## Enable/Disable
```bash
# Enable via API
curl -X POST http://localhost:8000/api/v1/modules/webshop/enable
# Eller rediger module.json
{
"enabled": true
}
# Restart app
docker-compose restart api
```
## Fejlhåndtering
Moduler er isolerede - hvis dit modul crasher ved opstart:
- Core systemet kører videre
- Modulet bliver ikke loaded
- Fejl logges til console og logs/app.log
Runtime fejl i endpoints påvirker ikke andre moduler.
## Testing
```python
import pytest
from app.core.database import execute_query
def test_webshop():
# Test bruger samme database helpers
result = execute_query("SELECT 1 as test")
assert result[0]["test"] == 1
```
## Best Practices
1. **Database isolering**: Brug ALTID `table_prefix` fra module.json
2. **Safety switches**: Tilføj `READ_ONLY` og `DRY_RUN` flags
3. **Error handling**: Log fejl, raise HTTPException med status codes
4. **Dependencies**: Deklarer i `module.json` hvis du bruger andre moduler
5. **Migrations**: Nummer sekventielt (001, 002, 003...)
6. **Documentation**: Opdater README.md med API endpoints og use cases

View File

@ -1 +0,0 @@
# Backend package for template module

View File

@ -1,556 +0,0 @@
"""
Webshop Module - API Router
Backend endpoints for webshop administration og konfiguration
"""
from fastapi import APIRouter, HTTPException, UploadFile, File, Form
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime
import logging
import os
import shutil
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
logger = logging.getLogger(__name__)
# APIRouter instance (module_loader kigger efter denne)
router = APIRouter()
# Upload directory for logos
LOGO_UPLOAD_DIR = "/app/uploads/webshop_logos"
os.makedirs(LOGO_UPLOAD_DIR, exist_ok=True)
# ============================================================================
# PYDANTIC MODELS
# ============================================================================
class WebshopConfigCreate(BaseModel):
customer_id: int
name: str
allowed_email_domains: str # Comma-separated
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: str = "#0f4c75"
accent_color: str = "#3282b8"
default_margin_percent: float = 10.0
min_order_amount: float = 0.0
shipping_cost: float = 0.0
enabled: bool = True
class WebshopConfigUpdate(BaseModel):
name: Optional[str] = None
allowed_email_domains: Optional[str] = None
header_text: Optional[str] = None
intro_text: Optional[str] = None
primary_color: Optional[str] = None
accent_color: Optional[str] = None
default_margin_percent: Optional[float] = None
min_order_amount: Optional[float] = None
shipping_cost: Optional[float] = None
enabled: Optional[bool] = None
class WebshopProductCreate(BaseModel):
webshop_config_id: int
product_number: str
ean: Optional[str] = None
name: str
description: Optional[str] = None
unit: str = "stk"
base_price: float
category: Optional[str] = None
custom_margin_percent: Optional[float] = None
visible: bool = True
sort_order: int = 0
# ============================================================================
# WEBSHOP CONFIG ENDPOINTS
# ============================================================================
@router.get("/webshop/configs")
async def get_all_webshop_configs():
"""
Hent alle webshop konfigurationer med kunde info
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
(SELECT COUNT(*) FROM webshop_products WHERE webshop_config_id = wc.id) as product_count
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
ORDER BY wc.created_at DESC
"""
configs = execute_query(query)
return {
"success": True,
"configs": configs
}
except Exception as e:
logger.error(f"❌ Error fetching webshop configs: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/webshop/configs/{config_id}")
async def get_webshop_config(config_id: int):
"""
Hent enkelt webshop konfiguration
"""
try:
query = """
SELECT
wc.*,
c.name as customer_name,
c.cvr_number as customer_cvr,
c.email as customer_email
FROM webshop_configs wc
LEFT JOIN customers c ON c.id = wc.customer_id
WHERE wc.id = %s
"""
config = execute_query_single(query, (config_id,))
if not config:
raise HTTPException(status_code=404, detail="Webshop config not found")
# Hent produkter
products_query = """
SELECT * FROM webshop_products
WHERE webshop_config_id = %s
ORDER BY sort_order, name
"""
products = execute_query(products_query, (config_id,))
return {
"success": True,
"config": config,
"products": products
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs")
async def create_webshop_config(config: WebshopConfigCreate):
"""
Opret ny webshop konfiguration
"""
try:
# Check if customer already has a webshop
existing = execute_query_single(
"SELECT id FROM webshop_configs WHERE customer_id = %s",
(config.customer_id,)
)
if existing:
raise HTTPException(
status_code=400,
detail="Customer already has a webshop configuration"
)
query = """
INSERT INTO webshop_configs (
customer_id, name, allowed_email_domains, header_text, intro_text,
primary_color, accent_color, default_margin_percent,
min_order_amount, shipping_cost, enabled
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
config.customer_id, config.name, config.allowed_email_domains,
config.header_text, config.intro_text, config.primary_color,
config.accent_color, config.default_margin_percent,
config.min_order_amount, config.shipping_cost, config.enabled
)
)
logger.info(f"✅ Created webshop config {result['id']} for customer {config.customer_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration created successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating webshop config: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.put("/webshop/configs/{config_id}")
async def update_webshop_config(config_id: int, config: WebshopConfigUpdate):
"""
Opdater webshop konfiguration
"""
try:
# Build dynamic update query
update_fields = []
params = []
if config.name is not None:
update_fields.append("name = %s")
params.append(config.name)
if config.allowed_email_domains is not None:
update_fields.append("allowed_email_domains = %s")
params.append(config.allowed_email_domains)
if config.header_text is not None:
update_fields.append("header_text = %s")
params.append(config.header_text)
if config.intro_text is not None:
update_fields.append("intro_text = %s")
params.append(config.intro_text)
if config.primary_color is not None:
update_fields.append("primary_color = %s")
params.append(config.primary_color)
if config.accent_color is not None:
update_fields.append("accent_color = %s")
params.append(config.accent_color)
if config.default_margin_percent is not None:
update_fields.append("default_margin_percent = %s")
params.append(config.default_margin_percent)
if config.min_order_amount is not None:
update_fields.append("min_order_amount = %s")
params.append(config.min_order_amount)
if config.shipping_cost is not None:
update_fields.append("shipping_cost = %s")
params.append(config.shipping_cost)
if config.enabled is not None:
update_fields.append("enabled = %s")
params.append(config.enabled)
if not update_fields:
raise HTTPException(status_code=400, detail="No fields to update")
params.append(config_id)
query = f"""
UPDATE webshop_configs
SET {', '.join(update_fields)}
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, tuple(params))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Updated webshop config {config_id}")
return {
"success": True,
"config": result,
"message": "Webshop configuration updated successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/webshop/configs/{config_id}/upload-logo")
async def upload_webshop_logo(config_id: int, logo: UploadFile = File(...)):
"""
Upload logo til webshop
"""
try:
# Validate file type
if not logo.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="File must be an image")
# Generate filename
ext = logo.filename.split(".")[-1]
filename = f"webshop_{config_id}.{ext}"
filepath = os.path.join(LOGO_UPLOAD_DIR, filename)
# Save file
with open(filepath, 'wb') as f:
shutil.copyfileobj(logo.file, f)
# Update database
query = """
UPDATE webshop_configs
SET logo_filename = %s
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (filename, config_id))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Uploaded logo for webshop {config_id}: {filename}")
return {
"success": True,
"filename": filename,
"config": result,
"message": "Logo uploaded successfully"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error uploading logo for webshop {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/configs/{config_id}")
async def delete_webshop_config(config_id: int):
"""
Slet webshop konfiguration (soft delete - disable)
"""
try:
query = """
UPDATE webshop_configs
SET enabled = FALSE
WHERE id = %s
RETURNING *
"""
result = execute_query_single(query, (config_id,))
if not result:
raise HTTPException(status_code=404, detail="Webshop config not found")
logger.info(f"✅ Disabled webshop config {config_id}")
return {
"success": True,
"message": "Webshop configuration disabled"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting webshop config {config_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# WEBSHOP PRODUCT ENDPOINTS
# ============================================================================
@router.post("/webshop/products")
async def add_webshop_product(product: WebshopProductCreate):
"""
Tilføj produkt til webshop whitelist
"""
try:
query = """
INSERT INTO webshop_products (
webshop_config_id, product_number, ean, name, description,
unit, base_price, category, custom_margin_percent,
visible, sort_order
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING *
"""
result = execute_query_single(
query,
(
product.webshop_config_id, product.product_number, product.ean,
product.name, product.description, product.unit, product.base_price,
product.category, product.custom_margin_percent, product.visible,
product.sort_order
)
)
logger.info(f"✅ Added product {product.product_number} to webshop {product.webshop_config_id}")
return {
"success": True,
"product": result,
"message": "Product added to webshop"
}
except Exception as e:
if "unique" in str(e).lower():
raise HTTPException(status_code=400, detail="Product already exists in this webshop")
logger.error(f"❌ Error adding product to webshop: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/webshop/products/{product_id}")
async def remove_webshop_product(product_id: int):
"""
Fjern produkt fra webshop whitelist
"""
try:
query = "DELETE FROM webshop_products WHERE id = %s RETURNING *"
result = execute_query_single(query, (product_id,))
if not result:
raise HTTPException(status_code=404, detail="Product not found")
logger.info(f"✅ Removed product {product_id} from webshop")
return {
"success": True,
"message": "Product removed from webshop"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error removing product {product_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# PUBLISH TO GATEWAY
# ============================================================================
@router.post("/webshop/configs/{config_id}/publish")
async def publish_webshop_to_gateway(config_id: int):
"""
Push webshop konfiguration til API Gateway
"""
try:
from app.core.config import settings
import aiohttp
# Hent config og produkter
config_data = await get_webshop_config(config_id)
if not config_data["config"]["enabled"]:
raise HTTPException(status_code=400, detail="Cannot publish disabled webshop")
# Byg payload
payload = {
"config_id": config_id,
"customer_id": config_data["config"]["customer_id"],
"company_name": config_data["config"]["customer_name"],
"config_version": config_data["config"]["config_version"].isoformat(),
"branding": {
"logo_url": f"{settings.BASE_URL}/uploads/webshop_logos/{config_data['config']['logo_filename']}" if config_data['config'].get('logo_filename') else None,
"header_text": config_data["config"]["header_text"],
"intro_text": config_data["config"]["intro_text"],
"primary_color": config_data["config"]["primary_color"],
"accent_color": config_data["config"]["accent_color"]
},
"allowed_email_domains": config_data["config"]["allowed_email_domains"].split(","),
"products": [
{
"id": p["id"],
"product_number": p["product_number"],
"ean": p["ean"],
"name": p["name"],
"description": p["description"],
"unit": p["unit"],
"base_price": float(p["base_price"]),
"calculated_price": float(p["base_price"]) * (1 + (float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]) / 100)),
"margin_percent": float(p.get("custom_margin_percent") or config_data["config"]["default_margin_percent"]),
"category": p["category"],
"visible": p["visible"]
}
for p in config_data["products"]
],
"settings": {
"min_order_amount": float(config_data["config"]["min_order_amount"]),
"shipping_cost": float(config_data["config"]["shipping_cost"]),
"default_margin_percent": float(config_data["config"]["default_margin_percent"])
}
}
# Send til Gateway
gateway_url = "https://apigateway.bmcnetworks.dk/webshop/ingest"
# TODO: Uncomment når Gateway er klar
# async with aiohttp.ClientSession() as session:
# async with session.post(gateway_url, json=payload) as response:
# if response.status != 200:
# error_text = await response.text()
# raise HTTPException(status_code=500, detail=f"Gateway error: {error_text}")
#
# gateway_response = await response.json()
# Mock response for now
logger.warning("⚠️ Gateway push disabled - mock response returned")
gateway_response = {"success": True, "message": "Config received (MOCK)"}
# Update last_published timestamps
update_query = """
UPDATE webshop_configs
SET last_published_at = CURRENT_TIMESTAMP,
last_published_version = config_version
WHERE id = %s
RETURNING *
"""
updated_config = execute_query_single(update_query, (config_id,))
logger.info(f"✅ Published webshop {config_id} to Gateway")
return {
"success": True,
"config": updated_config,
"payload": payload,
"gateway_response": gateway_response,
"message": "Webshop configuration published to Gateway"
}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error publishing webshop {config_id} to Gateway: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# ORDERS FROM GATEWAY
# ============================================================================
@router.get("/webshop/orders")
async def get_webshop_orders(config_id: Optional[int] = None, status: Optional[str] = None):
"""
Hent importerede ordrer fra Gateway
"""
try:
query = """
SELECT
wo.*,
wc.name as webshop_name,
c.name as customer_name
FROM webshop_orders wo
LEFT JOIN webshop_configs wc ON wc.id = wo.webshop_config_id
LEFT JOIN customers c ON c.id = wo.customer_id
WHERE 1=1
"""
params = []
if config_id:
query += " AND wo.webshop_config_id = %s"
params.append(config_id)
if status:
query += " AND wo.status = %s"
params.append(status)
query += " ORDER BY wo.created_at DESC"
orders = execute_query(query, tuple(params) if params else None)
return {
"success": True,
"orders": orders
}
except Exception as e:
logger.error(f"❌ Error fetching webshop orders: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@ -1 +0,0 @@
# Frontend package for template module

View File

@ -1,634 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Webshop Administration - BMC Hub{% endblock %}
{% block extra_css %}
<style>
.webshop-card {
transition: all 0.2s;
border: 1px solid rgba(0,0,0,0.1);
}
.webshop-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.status-badge {
padding: 0.375rem 0.75rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.color-preview {
width: 30px;
height: 30px;
border-radius: 50%;
border: 2px solid rgba(0,0,0,0.1);
}
.form-label-required::after {
content: " *";
color: #dc3545;
}
</style>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-5">
<div>
<h2 class="fw-bold mb-1">Webshop Administration</h2>
<p class="text-muted mb-0">Administrer kunde-webshops og konfigurationer</p>
</div>
<div class="d-flex gap-3">
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Webshop
</button>
</div>
</div>
<div class="row g-4" id="webshopsGrid">
<div class="col-12 text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
<!-- Create/Edit Modal -->
<div class="modal fade" id="webshopModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalTitle">Opret Webshop</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="webshopForm">
<input type="hidden" id="configId">
<!-- Kunde Selection -->
<div class="mb-3">
<label for="customerId" class="form-label form-label-required">Kunde</label>
<select class="form-select" id="customerId" required>
<option value="">Vælg kunde...</option>
</select>
</div>
<!-- Webshop Navn -->
<div class="mb-3">
<label for="webshopName" class="form-label form-label-required">Webshop Navn</label>
<input type="text" class="form-control" id="webshopName" required
placeholder="fx 'Advokatfirmaet A/S Webshop'">
</div>
<!-- Email Domæner -->
<div class="mb-3">
<label for="emailDomains" class="form-label form-label-required">Tilladte Email Domæner</label>
<input type="text" class="form-control" id="emailDomains" required
placeholder="fx 'firma.dk,firma.com' (komma-separeret)">
<small class="form-text text-muted">Kun brugere med disse email-domæner kan logge ind</small>
</div>
<!-- Header & Intro Text -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="headerText" class="form-label">Header Tekst</label>
<input type="text" class="form-control" id="headerText"
placeholder="fx 'Velkommen til vores webshop'">
</div>
<div class="col-md-6 mb-3">
<label for="introText" class="form-label">Intro Tekst</label>
<textarea class="form-control" id="introText" rows="2"
placeholder="Kort introduktion til webshoppen"></textarea>
</div>
</div>
<!-- Colors -->
<div class="row">
<div class="col-md-6 mb-3">
<label for="primaryColor" class="form-label">Primær Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="primaryColor" value="#0f4c75">
<input type="text" class="form-control" id="primaryColorHex"
value="#0f4c75" maxlength="7">
</div>
</div>
<div class="col-md-6 mb-3">
<label for="accentColor" class="form-label">Accent Farve</label>
<div class="input-group">
<input type="color" class="form-control form-control-color"
id="accentColor" value="#3282b8">
<input type="text" class="form-control" id="accentColorHex"
value="#3282b8" maxlength="7">
</div>
</div>
</div>
<!-- Pricing -->
<div class="row">
<div class="col-md-4 mb-3">
<label for="defaultMargin" class="form-label">Standard Avance (%)</label>
<input type="number" class="form-control" id="defaultMargin"
value="10" min="0" max="100" step="0.1">
</div>
<div class="col-md-4 mb-3">
<label for="minOrderAmount" class="form-label">Min. Ordre Beløb (DKK)</label>
<input type="number" class="form-control" id="minOrderAmount"
value="0" min="0" step="0.01">
</div>
<div class="col-md-4 mb-3">
<label for="shippingCost" class="form-label">Forsendelse (DKK)</label>
<input type="number" class="form-control" id="shippingCost"
value="0" min="0" step="0.01">
</div>
</div>
<!-- Enabled -->
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="enabled" checked>
<label class="form-check-label" for="enabled">
Webshop aktiveret
</label>
</div>
</form>
</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" onclick="saveWebshop()">
<i class="bi bi-check-lg me-2"></i>Gem Webshop
</button>
</div>
</div>
</div>
</div>
<!-- Products Modal -->
<div class="modal fade" id="productsModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Produkter - <span id="productsModalWebshopName"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="mb-0 text-muted">Administrer tilladte produkter for denne webshop</p>
</div>
<button class="btn btn-sm btn-primary" onclick="openAddProductModal()">
<i class="bi bi-plus-lg me-2"></i>Tilføj Produkt
</button>
</div>
<input type="hidden" id="currentConfigId">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Varenr</th>
<th>Navn</th>
<th>EAN</th>
<th>Basispris</th>
<th>Avance %</th>
<th>Salgspris</th>
<th>Synlig</th>
<th></th>
</tr>
</thead>
<tbody id="productsTableBody">
<tr>
<td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
</div>
</div>
</div>
</div>
<!-- Add Product Modal -->
<div class="modal fade" id="addProductModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Tilføj Produkt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="addProductForm">
<div class="mb-3">
<label for="productNumber" class="form-label form-label-required">Varenummer</label>
<input type="text" class="form-control" id="productNumber" required>
</div>
<div class="mb-3">
<label for="productEan" class="form-label">EAN</label>
<input type="text" class="form-control" id="productEan">
</div>
<div class="mb-3">
<label for="productName" class="form-label form-label-required">Navn</label>
<input type="text" class="form-control" id="productName" required>
</div>
<div class="mb-3">
<label for="productDescription" class="form-label">Beskrivelse</label>
<textarea class="form-control" id="productDescription" rows="2"></textarea>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label for="productBasePrice" class="form-label form-label-required">Basispris (DKK)</label>
<input type="number" class="form-control" id="productBasePrice" required step="0.01">
</div>
<div class="col-md-6 mb-3">
<label for="productCustomMargin" class="form-label">Custom Avance (%)</label>
<input type="number" class="form-control" id="productCustomMargin" step="0.1" placeholder="Standard bruges">
</div>
</div>
<div class="mb-3">
<label for="productCategory" class="form-label">Kategori</label>
<input type="text" class="form-control" id="productCategory" placeholder="fx 'Network Security'">
</div>
</form>
</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" onclick="addProduct()">
<i class="bi bi-plus-lg me-2"></i>Tilføj
</button>
</div>
</div>
</div>
</div>
<script>
let webshopsData = [];
let currentWebshopConfig = null;
let webshopModal, productsModal, addProductModal;
// Load on page ready
document.addEventListener('DOMContentLoaded', () => {
// Initialize Bootstrap modals after DOM is loaded
webshopModal = new bootstrap.Modal(document.getElementById('webshopModal'));
productsModal = new bootstrap.Modal(document.getElementById('productsModal'));
addProductModal = new bootstrap.Modal(document.getElementById('addProductModal'));
loadWebshops();
loadCustomers();
// Color picker sync
document.getElementById('primaryColor').addEventListener('input', (e) => {
document.getElementById('primaryColorHex').value = e.target.value;
});
document.getElementById('primaryColorHex').addEventListener('input', (e) => {
document.getElementById('primaryColor').value = e.target.value;
});
document.getElementById('accentColor').addEventListener('input', (e) => {
document.getElementById('accentColorHex').value = e.target.value;
});
document.getElementById('accentColorHex').addEventListener('input', (e) => {
document.getElementById('accentColor').value = e.target.value;
});
});
async function loadWebshops() {
try {
const response = await fetch('/api/v1/webshop/configs');
const data = await response.json();
if (data.success) {
webshopsData = data.configs;
renderWebshops();
}
} catch (error) {
console.error('Error loading webshops:', error);
showToast('Fejl ved indlæsning af webshops', 'danger');
}
}
function renderWebshops() {
const grid = document.getElementById('webshopsGrid');
if (webshopsData.length === 0) {
grid.innerHTML = `
<div class="col-12 text-center py-5">
<i class="bi bi-shop display-1 text-muted mb-3"></i>
<p class="text-muted">Ingen webshops oprettet endnu</p>
<button class="btn btn-primary" onclick="openCreateModal()">
<i class="bi bi-plus-lg me-2"></i>Opret Din Første Webshop
</button>
</div>
`;
return;
}
grid.innerHTML = webshopsData.map(ws => `
<div class="col-md-6 col-lg-4">
<div class="card webshop-card h-100">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-1">${ws.name}</h5>
<small class="text-muted">${ws.customer_name || 'Ingen kunde'}</small>
</div>
<span class="status-badge ${ws.enabled ? 'bg-success bg-opacity-10 text-success' : 'bg-danger bg-opacity-10 text-danger'}">
${ws.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Branding</small>
<div class="d-flex gap-2">
<div class="color-preview" style="background-color: ${ws.primary_color}"></div>
<div class="color-preview" style="background-color: ${ws.accent_color}"></div>
</div>
</div>
<div class="mb-3">
<small class="text-muted d-block mb-1">Email Domæner</small>
<div class="small">${ws.allowed_email_domains}</div>
</div>
<div class="row g-2 mb-3">
<div class="col-6">
<small class="text-muted d-block">Produkter</small>
<strong>${ws.product_count || 0}</strong>
</div>
<div class="col-6">
<small class="text-muted d-block">Avance</small>
<strong>${ws.default_margin_percent}%</strong>
</div>
</div>
${ws.last_published_at ? `
<div class="mb-3">
<small class="text-muted">Sidst publiceret: ${new Date(ws.last_published_at).toLocaleString('da-DK')}</small>
</div>
` : '<div class="mb-3"><small class="text-warning">⚠️ Ikke publiceret endnu</small></div>'}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" onclick="openEditModal(${ws.id})">
<i class="bi bi-pencil me-1"></i>Rediger
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="openProductsModal(${ws.id})">
<i class="bi bi-box-seam"></i>
</button>
<button class="btn btn-sm btn-success" onclick="publishWebshop(${ws.id})"
${!ws.enabled ? 'disabled' : ''}>
<i class="bi bi-cloud-upload"></i>
</button>
</div>
</div>
</div>
</div>
`).join('');
}
async function loadCustomers() {
try {
const response = await fetch('/api/v1/customers?limit=1000');
const data = await response.json();
const customers = Array.isArray(data) ? data : (data.customers || []);
const select = document.getElementById('customerId');
select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
} catch (error) {
console.error('Error loading customers:', error);
}
}
function openCreateModal() {
document.getElementById('modalTitle').textContent = 'Opret Webshop';
document.getElementById('webshopForm').reset();
document.getElementById('configId').value = '';
document.getElementById('enabled').checked = true;
webshopModal.show();
}
async function openEditModal(configId) {
const ws = webshopsData.find(w => w.id === configId);
if (!ws) return;
document.getElementById('modalTitle').textContent = 'Rediger Webshop';
document.getElementById('configId').value = ws.id;
document.getElementById('customerId').value = ws.customer_id;
document.getElementById('webshopName').value = ws.name;
document.getElementById('emailDomains').value = ws.allowed_email_domains;
document.getElementById('headerText').value = ws.header_text || '';
document.getElementById('introText').value = ws.intro_text || '';
document.getElementById('primaryColor').value = ws.primary_color;
document.getElementById('primaryColorHex').value = ws.primary_color;
document.getElementById('accentColor').value = ws.accent_color;
document.getElementById('accentColorHex').value = ws.accent_color;
document.getElementById('defaultMargin').value = ws.default_margin_percent;
document.getElementById('minOrderAmount').value = ws.min_order_amount;
document.getElementById('shippingCost').value = ws.shipping_cost;
document.getElementById('enabled').checked = ws.enabled;
webshopModal.show();
}
async function saveWebshop() {
const configId = document.getElementById('configId').value;
const isEdit = !!configId;
const payload = {
customer_id: parseInt(document.getElementById('customerId').value),
name: document.getElementById('webshopName').value,
allowed_email_domains: document.getElementById('emailDomains').value,
header_text: document.getElementById('headerText').value || null,
intro_text: document.getElementById('introText').value || null,
primary_color: document.getElementById('primaryColorHex').value,
accent_color: document.getElementById('accentColorHex').value,
default_margin_percent: parseFloat(document.getElementById('defaultMargin').value),
min_order_amount: parseFloat(document.getElementById('minOrderAmount').value),
shipping_cost: parseFloat(document.getElementById('shippingCost').value),
enabled: document.getElementById('enabled').checked
};
try {
const url = isEdit ? `/api/v1/webshop/configs/${configId}` : '/api/v1/webshop/configs';
const method = isEdit ? 'PUT' : 'POST';
const response = await fetch(url, {
method: method,
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Webshop gemt', 'success');
webshopModal.hide();
loadWebshops();
} else {
showToast(data.message || 'Fejl ved gemning', 'danger');
}
} catch (error) {
console.error('Error saving webshop:', error);
showToast('Fejl ved gemning af webshop', 'danger');
}
}
async function openProductsModal(configId) {
currentWebshopConfig = configId;
document.getElementById('currentConfigId').value = configId;
const ws = webshopsData.find(w => w.id === configId);
document.getElementById('productsModalWebshopName').textContent = ws ? ws.name : '';
productsModal.show();
loadProducts(configId);
}
async function loadProducts(configId) {
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}`);
const data = await response.json();
if (data.success) {
renderProducts(data.products, data.config.default_margin_percent);
}
} catch (error) {
console.error('Error loading products:', error);
showToast('Fejl ved indlæsning af produkter', 'danger');
}
}
function renderProducts(products, defaultMargin) {
const tbody = document.getElementById('productsTableBody');
if (!products || products.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center py-4 text-muted">Ingen produkter endnu</td></tr>';
return;
}
tbody.innerHTML = products.map(p => {
const margin = p.custom_margin_percent || defaultMargin;
const salePrice = p.base_price * (1 + margin / 100);
return `
<tr>
<td><code>${p.product_number}</code></td>
<td>${p.name}</td>
<td>${p.ean || '-'}</td>
<td>${p.base_price.toFixed(2)} kr</td>
<td>${margin.toFixed(1)}%</td>
<td><strong>${salePrice.toFixed(2)} kr</strong></td>
<td>
<span class="badge ${p.visible ? 'bg-success' : 'bg-secondary'}">
${p.visible ? 'Ja' : 'Nej'}
</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger" onclick="removeProduct(${p.id})">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
}
function openAddProductModal() {
document.getElementById('addProductForm').reset();
addProductModal.show();
}
async function addProduct() {
const configId = document.getElementById('currentConfigId').value;
const payload = {
webshop_config_id: parseInt(configId),
product_number: document.getElementById('productNumber').value,
ean: document.getElementById('productEan').value || null,
name: document.getElementById('productName').value,
description: document.getElementById('productDescription').value || null,
base_price: parseFloat(document.getElementById('productBasePrice').value),
custom_margin_percent: document.getElementById('productCustomMargin').value ?
parseFloat(document.getElementById('productCustomMargin').value) : null,
category: document.getElementById('productCategory').value || null,
visible: true,
sort_order: 0
};
try {
const response = await fetch('/api/v1/webshop/products', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (data.success) {
showToast('Produkt tilføjet', 'success');
addProductModal.hide();
loadProducts(configId);
} else {
showToast(data.detail || 'Fejl ved tilføjelse', 'danger');
}
} catch (error) {
console.error('Error adding product:', error);
showToast('Fejl ved tilføjelse af produkt', 'danger');
}
}
async function removeProduct(productId) {
if (!confirm('Er du sikker på at du vil fjerne dette produkt?')) return;
try {
const response = await fetch(`/api/v1/webshop/products/${productId}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.success) {
showToast('Produkt fjernet', 'success');
const configId = document.getElementById('currentConfigId').value;
loadProducts(configId);
}
} catch (error) {
console.error('Error removing product:', error);
showToast('Fejl ved fjernelse', 'danger');
}
}
async function publishWebshop(configId) {
if (!confirm('Vil du publicere denne webshop til Gateway?')) return;
try {
const response = await fetch(`/api/v1/webshop/configs/${configId}/publish`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
showToast('Webshop publiceret til Gateway!', 'success');
loadWebshops();
} else {
showToast(data.detail || 'Fejl ved publicering', 'danger');
}
} catch (error) {
console.error('Error publishing webshop:', error);
showToast('Fejl ved publicering', 'danger');
}
}
function showToast(message, type = 'info') {
// Reuse existing toast system if available
console.log(`[${type.toUpperCase()}] ${message}`);
alert(message); // Replace with proper toast when available
}
</script>
{% endblock %}

View File

@ -1,23 +0,0 @@
"""
Webshop Module - Views Router
HTML page serving for webshop admin interface
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
import os
# Router for HTML views
router = APIRouter()
# Template directory - must be root "app" to allow extending shared/frontend/base.html
templates = Jinja2Templates(directory="app")
@router.get("/webshop", response_class=HTMLResponse, include_in_schema=False)
async def webshop_admin(request: Request):
"""
Webshop administration interface
"""
return templates.TemplateResponse("modules/webshop/frontend/index.html", {"request": request})

View File

@ -1,161 +0,0 @@
-- Webshop Module - Initial Migration
-- Opret basis tabeller for webshop konfiguration og administration
-- Webshop konfigurationer (én per kunde/domæne)
CREATE TABLE IF NOT EXISTS webshop_configs (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL, -- Webshop navn (fx "Advokatfirmaet A/S Webshop")
-- Email domæner der må logge ind (komma-separeret, fx "firma.dk,firma.com")
allowed_email_domains TEXT NOT NULL,
-- Branding
logo_filename VARCHAR(255), -- Filnavn i uploads/webshop_logos/
header_text TEXT,
intro_text TEXT,
primary_color VARCHAR(7) DEFAULT '#0f4c75', -- Hex color
accent_color VARCHAR(7) DEFAULT '#3282b8',
-- Pricing regler
default_margin_percent DECIMAL(5, 2) DEFAULT 10.00, -- Standard avance %
min_order_amount DECIMAL(10, 2) DEFAULT 0.00,
shipping_cost DECIMAL(10, 2) DEFAULT 0.00,
-- Status og versioning
enabled BOOLEAN DEFAULT TRUE,
config_version TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- Timestamp for seneste ændring
last_published_at TIMESTAMP, -- Hvornår blev config sendt til Gateway
last_published_version TIMESTAMP, -- Hvilken version blev sendt
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(customer_id) -- Én webshop per kunde
);
-- Tilladte produkter per webshop (whitelist)
CREATE TABLE IF NOT EXISTS webshop_products (
id SERIAL PRIMARY KEY,
webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE CASCADE,
-- Produkt identifikation (fra e-conomic)
product_number VARCHAR(100) NOT NULL,
ean VARCHAR(50),
-- Produkt info (synced fra e-conomic)
name VARCHAR(255) NOT NULL,
description TEXT,
unit VARCHAR(50) DEFAULT 'stk',
base_price DECIMAL(10, 2), -- Pris fra e-conomic
category VARCHAR(100),
-- Webshop-specifik konfiguration
custom_margin_percent DECIMAL(5, 2), -- Override standard avance for dette produkt
visible BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(webshop_config_id, product_number)
);
-- Ordre importeret fra Gateway (når Hub poller Gateway)
CREATE TABLE IF NOT EXISTS webshop_orders (
id SERIAL PRIMARY KEY,
webshop_config_id INTEGER REFERENCES webshop_configs(id) ON DELETE SET NULL,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
-- Order data fra Gateway
gateway_order_id VARCHAR(100) NOT NULL UNIQUE, -- ORD-2026-00123
order_email VARCHAR(255), -- Hvem bestilte
total_amount DECIMAL(10, 2),
status VARCHAR(50), -- pending, processing, completed, cancelled
-- Shipping info
shipping_company_name VARCHAR(255),
shipping_street TEXT,
shipping_postal_code VARCHAR(20),
shipping_city VARCHAR(100),
shipping_country VARCHAR(2) DEFAULT 'DK',
delivery_note TEXT,
-- Payload fra Gateway (JSON)
gateway_payload JSONB, -- Komplet order data fra Gateway
-- Import tracking
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
processed_in_economic BOOLEAN DEFAULT FALSE,
economic_order_number INTEGER, -- e-conomic ordre nummer når oprettet
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Ordre linjer (items på ordre)
CREATE TABLE IF NOT EXISTS webshop_order_items (
id SERIAL PRIMARY KEY,
webshop_order_id INTEGER REFERENCES webshop_orders(id) ON DELETE CASCADE,
product_number VARCHAR(100),
product_name VARCHAR(255),
quantity INTEGER NOT NULL,
unit_price DECIMAL(10, 2),
total_price DECIMAL(10, 2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_webshop_configs_customer ON webshop_configs(customer_id);
CREATE INDEX IF NOT EXISTS idx_webshop_configs_enabled ON webshop_configs(enabled);
CREATE INDEX IF NOT EXISTS idx_webshop_products_config ON webshop_products(webshop_config_id);
CREATE INDEX IF NOT EXISTS idx_webshop_products_visible ON webshop_products(visible);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_gateway_id ON webshop_orders(gateway_order_id);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_customer ON webshop_orders(customer_id);
CREATE INDEX IF NOT EXISTS idx_webshop_orders_status ON webshop_orders(status);
CREATE INDEX IF NOT EXISTS idx_webshop_order_items_order ON webshop_order_items(webshop_order_id);
-- Trigger for updated_at på configs
CREATE OR REPLACE FUNCTION update_webshop_configs_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
NEW.config_version = CURRENT_TIMESTAMP; -- Bump version ved hver ændring
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_configs_updated_at
BEFORE UPDATE ON webshop_configs
FOR EACH ROW
EXECUTE FUNCTION update_webshop_configs_updated_at();
-- Trigger for updated_at på products
CREATE OR REPLACE FUNCTION update_webshop_products_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_products_updated_at
BEFORE UPDATE ON webshop_products
FOR EACH ROW
EXECUTE FUNCTION update_webshop_products_updated_at();
-- Trigger for updated_at på orders
CREATE OR REPLACE FUNCTION update_webshop_orders_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_webshop_orders_updated_at
BEFORE UPDATE ON webshop_orders
FOR EACH ROW
EXECUTE FUNCTION update_webshop_orders_updated_at();

View File

@ -1,19 +0,0 @@
{
"name": "webshop",
"version": "1.0.0",
"description": "Webshop administration og konfiguration",
"author": "BMC Networks",
"enabled": true,
"dependencies": [],
"table_prefix": "webshop_",
"api_prefix": "/api/v1/webshop",
"tags": [
"Webshop"
],
"config": {
"safety_switches": {
"read_only": false,
"dry_run": false
}
}
}

View File

@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page_title }} - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<h1>{{ page_title }}</h1>
{% if error %}
<div class="alert alert-danger">
<strong>Error:</strong> {{ error }}
</div>
{% endif %}
<div class="card mt-4">
<div class="card-header">
<h5>Template Items</h5>
</div>
<div class="card-body">
{% if items %}
<table class="table">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.name }}</td>
<td>{{ item.description or '-' }}</td>
<td>{{ item.created_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted">No items found. This is a template module.</p>
{% endif %}
</div>
</div>
<div class="mt-4">
<a href="/api/docs#/Template" class="btn btn-primary">API Documentation</a>
<a href="/" class="btn btn-secondary">Back to Dashboard</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@ -122,13 +122,23 @@ async def get_prepaid_card(card_id: int):
async def create_prepaid_card(card: PrepaidCardCreate): async def create_prepaid_card(card: PrepaidCardCreate):
""" """
Create a new prepaid card Create a new prepaid card
Note: As of migration 065, customers can have multiple active cards simultaneously.
""" """
try: try:
# Calculate total amount # Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour total_amount = card.purchased_hours * card.price_per_hour
# Check if customer already has active card
existing = execute_query("""
SELECT id FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""", (card.customer_id,))
if existing and len(existing) > 0:
raise HTTPException(
status_code=400,
detail="Customer already has an active prepaid card"
)
# Create card (need to use fetch=False for INSERT RETURNING) # Create card (need to use fetch=False for INSERT RETURNING)
conn = None conn = None
try: try:

View File

@ -48,17 +48,6 @@
<h5 class="mb-0">Oversigt</h5> <h5 class="mb-0">Oversigt</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<!-- Usage Meter -->
<div class="mb-4">
<div class="d-flex justify-content-between mb-1">
<small class="text-muted fw-bold">Forbrug</small>
<small class="fw-bold" id="statPercent">0%</small>
</div>
<div class="progress" style="height: 10px; background-color: #e9ecef; border-radius: 6px;">
<div class="progress-bar transition-all" id="statProgressBar" role="progressbar" style="width: 0%; transition: width 0.6s ease;"></div>
</div>
</div>
<div class="mb-3 pb-3 border-bottom"> <div class="mb-3 pb-3 border-bottom">
<small class="text-muted d-block mb-1">Købte Timer</small> <small class="text-muted d-block mb-1">Købte Timer</small>
<h4 class="mb-0" id="statPurchased">-</h4> <h4 class="mb-0" id="statPurchased">-</h4>
@ -159,27 +148,6 @@ async function loadCardDetails() {
currency: 'DKK' currency: 'DKK'
}).format(parseFloat(card.total_amount)); }).format(parseFloat(card.total_amount));
// Update Progress Bar
const purchased = parseFloat(card.purchased_hours) || 0;
const used = parseFloat(card.used_hours) || 0;
const percent = purchased > 0 ? (used / purchased) * 100 : 0;
const progressBar = document.getElementById('statProgressBar');
progressBar.style.width = Math.min(percent, 100) + '%';
document.getElementById('statPercent').textContent = Math.round(percent) + '%';
// Color logic for progress bar
progressBar.className = 'progress-bar transition-all'; // Reset class but keep transition
if (percent >= 100) {
progressBar.classList.add('bg-secondary'); // Depleted
} else if (percent > 90) {
progressBar.classList.add('bg-danger'); // Critical
} else if (percent > 75) {
progressBar.classList.add('bg-warning'); // Warning
} else {
progressBar.classList.add('bg-success'); // Good
}
// Update card info // Update card info
const statusBadge = getStatusBadge(card.status); const statusBadge = getStatusBadge(card.status);
const expiresAt = card.expires_at ? const expiresAt = card.expires_at ?

View File

@ -129,7 +129,6 @@
<th class="text-end">Købte Timer</th> <th class="text-end">Købte Timer</th>
<th class="text-end">Brugte Timer</th> <th class="text-end">Brugte Timer</th>
<th class="text-end">Tilbage</th> <th class="text-end">Tilbage</th>
<th>Forbrug</th>
<th class="text-end">Pris/Time</th> <th class="text-end">Pris/Time</th>
<th class="text-end">Total</th> <th class="text-end">Total</th>
<th>Status</th> <th>Status</th>
@ -139,7 +138,7 @@
</thead> </thead>
<tbody id="cardsTableBody"> <tbody id="cardsTableBody">
<tr> <tr>
<td colspan="11" class="text-center py-5"> <td colspan="10" class="text-center py-5">
<div class="spinner-border text-primary" role="status"> <div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
@ -160,73 +159,35 @@
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5> <h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body p-4"> <div class="modal-body">
<form id="createCardForm" class="needs-validation" novalidate> <form id="createCardForm">
<!-- Customer Dropdown -->
<div class="mb-4">
<label class="form-label fw-bold">Kunde <span class="text-danger">*</span></label>
<div class="dropdown" id="customerDropdown">
<button class="form-select text-start d-flex justify-content-between align-items-center" type="button"
data-bs-toggle="dropdown" aria-expanded="false" id="customerDropdownBtn">
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
</button>
<div class="dropdown-menu w-100 p-2 shadow-sm" aria-labelledby="customerDropdownBtn">
<div class="px-2 pb-2">
<input type="text" class="form-control form-control-sm" id="customerSearchInput"
placeholder="🔍 Søg kunde..." autocomplete="off">
</div>
<div id="customerList" style="max-height: 250px; overflow-y: auto;">
<!-- Options will be injected here -->
</div>
</div>
<input type="hidden" id="customerId" required>
<div class="invalid-feedback">
Vælg venligst en kunde
</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-7">
<label class="form-label fw-bold">Antal Timer <span class="text-danger">*</span></label>
<div class="input-group">
<span class="input-group-text"><i class="bi bi-clock"></i></span>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required placeholder="0.0">
</div>
<div class="mt-2 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(10)">10t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(25)">25t</button>
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="setPurchasedHours(50)">50t</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label fw-bold">Pris / Time <span class="text-danger">*</span></label>
<div class="input-group">
<input type="number" class="form-control text-end" id="pricePerHour"
step="0.01" min="0" required placeholder="0.00">
<span class="input-group-text">kr</span>
</div>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label> <label class="form-label">Kunde *</label>
<div class="input-group"> <select class="form-select" id="customerId" required>
<span class="input-group-text"><i class="bi bi-calendar"></i></span> <option value="">Vælg kunde...</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">Antal Timer *</label>
<input type="number" class="form-control" id="purchasedHours"
step="0.5" min="1" required>
</div>
<div class="mb-3">
<label class="form-label">Pris pr. Time (DKK) *</label>
<input type="number" class="form-control" id="pricePerHour"
step="0.01" min="0" required>
</div>
<div class="mb-3">
<label class="form-label">Udløbsdato (valgfri)</label>
<input type="date" class="form-control" id="expiresAt"> <input type="date" class="form-control" id="expiresAt">
</div> </div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-bold">Bemærkninger</label> <label class="form-label">Bemærkninger</label>
<textarea class="form-control" id="notes" rows="3" placeholder="Interne noter..."></textarea> <textarea class="form-control" id="notes" rows="3"></textarea>
</div> </div>
<div class="alert alert-info small">
<div class="alert alert-light border small text-muted d-flex align-items-center gap-2"> <i class="bi bi-info-circle"></i>
<i class="bi bi-info-circle-fill text-primary"></i> Kortnummeret bliver automatisk genereret
Kortnummeret genereres automatisk ved oprettelse
</div> </div>
</form> </form>
</div> </div>
@ -309,7 +270,7 @@ function renderCards(cards) {
if (!cards || cards.length === 0) { if (!cards || cards.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="11" class="text-center text-muted py-5"> <tr><td colspan="10" class="text-center text-muted py-5">
Ingen kort fundet Ingen kort fundet
</td></tr> </td></tr>
`; `;
@ -328,14 +289,6 @@ function renderCards(cards) {
const pricePerHour = parseFloat(card.price_per_hour); const pricePerHour = parseFloat(card.price_per_hour);
const totalAmount = parseFloat(card.total_amount); const totalAmount = parseFloat(card.total_amount);
// Calculate usage percentage
const usedPercent = purchasedHours > 0 ? Math.min(100, Math.max(0, (usedHours / purchasedHours) * 100)) : 0;
// Progress bar color based on usage
let progressClass = 'bg-success';
if (usedPercent >= 90) progressClass = 'bg-danger';
else if (usedPercent >= 75) progressClass = 'bg-warning';
return ` return `
<tr> <tr>
<td> <td>
@ -354,19 +307,6 @@ function renderCards(cards) {
${remainingHours.toFixed(1)} t ${remainingHours.toFixed(1)} t
</strong> </strong>
</td> </td>
<td>
<div class="progress" style="height: 20px; min-width: 100px;">
<div class="progress-bar ${progressClass}"
role="progressbar"
style="width: ${usedPercent.toFixed(0)}%"
aria-valuenow="${usedPercent.toFixed(0)}"
aria-valuemin="0"
aria-valuemax="100">
${usedPercent.toFixed(0)}%
</div>
</div>
<small class="text-muted">Forbrug</small>
</td>
<td class="text-end">${pricePerHour.toFixed(2)} kr</td> <td class="text-end">${pricePerHour.toFixed(2)} kr</td>
<td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td> <td class="text-end"><strong>${totalAmount.toFixed(2)} kr</strong></td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
@ -401,120 +341,36 @@ function getStatusBadge(status) {
return badges[status] || status; return badges[status] || status;
} }
// Set purchased hours from quick template buttons
function setPurchasedHours(hours) {
document.getElementById('purchasedHours').value = hours;
// Optionally focus next field (pricePerHour) for quick workflow
document.getElementById('pricePerHour').focus();
}
// Load Customers for Dropdown // Load Customers for Dropdown
let allCustomers = [];
async function loadCustomers() { async function loadCustomers() {
try { try {
// Fetch max customers for client-side filtering (up to 1000) const response = await fetch('/api/v1/customers');
const response = await fetch('/api/v1/customers?limit=1000'); const customers = await response.json();
const data = await response.json();
// Handle API response format (might be array or paginated object) const select = document.getElementById('customerId');
allCustomers = Array.isArray(data) ? data : (data.customers || []); select.innerHTML = '<option value="">Vælg kunde...</option>' +
customers.map(c => `<option value="${c.id}">${c.name}</option>`).join('');
renderCustomerDropdown(allCustomers);
// Setup search listener
const searchInput = document.getElementById('customerSearchInput');
if (searchInput) {
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = allCustomers.filter(c =>
(c.name || '').toLowerCase().includes(query) ||
(c.email || '').toLowerCase().includes(query)
);
renderCustomerDropdown(filtered);
});
// Prevent dropdown from closing when clicking input
searchInput.addEventListener('click', (e) => e.stopPropagation());
}
} catch (error) { } catch (error) {
console.error('Error loading customers:', error); console.error('Error loading customers:', error);
allCustomers = [];
renderCustomerDropdown([]);
} }
} }
function renderCustomerDropdown(customers) {
const list = document.getElementById('customerList');
if (!list) return;
if (customers.length === 0) {
list.innerHTML = '<div class="text-muted p-2 text-center small">Ingen kunder fundet</div>';
return;
}
list.innerHTML = customers.map(c => `
<a href="#" class="dropdown-item py-2 border-bottom" onclick="selectCustomer(${c.id}, '${c.name.replace(/'/g, "\\'")}')">
<div class="fw-bold">${c.name}</div>
${c.email ? `<small class="text-muted">${c.email}</small>` : ''}
</a>
`).join('');
}
function selectCustomer(id, name) {
document.getElementById('customerId').value = id;
const btn = document.getElementById('customerDropdownBtn');
btn.innerHTML = `
<span class="fw-bold text-dark">${name}</span>
<i class="bi bi-chevron-down small"></i>
`;
btn.classList.add('border-primary'); // Highlight selection
// Reset search
document.getElementById('customerSearchInput').value = '';
renderCustomerDropdown(allCustomers);
}
// Open Create Modal // Open Create Modal
function openCreateModal() { function openCreateModal() {
const form = document.getElementById('createCardForm'); document.getElementById('createCardForm').reset();
form.reset();
form.classList.remove('was-validated');
// Reset dropdown
document.getElementById('customerId').value = '';
document.getElementById('customerDropdownBtn').innerHTML = `
<span class="text-muted">Vælg kunde...</span>
<i class="bi bi-chevron-down small"></i>
`;
document.getElementById('customerDropdownBtn').classList.remove('border-primary', 'is-invalid');
createCardModal.show(); createCardModal.show();
} }
// Create Card // Create Card
async function createCard() { async function createCard() {
const form = document.getElementById('createCardForm'); const form = document.getElementById('createCardForm');
if (!form.checkValidity()) {
// Custom validation for dropdown form.reportValidity();
const customerId = document.getElementById('customerId').value;
const dropdownBtn = document.getElementById('customerDropdownBtn');
if (!customerId) {
dropdownBtn.classList.add('is-invalid');
} else {
dropdownBtn.classList.remove('is-invalid');
}
if (!form.checkValidity() || !customerId) {
form.classList.add('was-validated');
return; return;
} }
const data = { const data = {
customer_id: parseInt(customerId), customer_id: parseInt(document.getElementById('customerId').value),
purchased_hours: parseFloat(document.getElementById('purchasedHours').value), purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
price_per_hour: parseFloat(document.getElementById('pricePerHour').value), price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
expires_at: document.getElementById('expiresAt').value || null, expires_at: document.getElementById('expiresAt').value || null,
@ -537,9 +393,6 @@ async function createCard() {
loadStats(); loadStats();
loadCards(); loadCards();
// Show success toast or alert
// alert('✅ Prepaid kort oprettet!'); // Using toast instead if available, keeping alert for now
// But let's use a nicer non-blocking notification if possible, but sticking to existing pattern
alert('✅ Prepaid kort oprettet!'); alert('✅ Prepaid kort oprettet!');
} catch (error) { } catch (error) {
console.error('Error creating card:', error); console.error('Error creating card:', error);
@ -547,7 +400,6 @@ async function createCard() {
} }
} }
// Cancel Card // Cancel Card
async function cancelCard(cardId) { async function cancelCard(cardId) {
if (!confirm('Er du sikker på at du vil annullere dette kort?')) { if (!confirm('Er du sikker på at du vil annullere dette kort?')) {
@ -605,52 +457,5 @@ document.getElementById('customerSearch').addEventListener('input', loadCards);
.btn-group-sm .btn { .btn-group-sm .btn {
padding: 0.25rem 0.5rem; padding: 0.25rem 0.5rem;
} }
/* Custom Dropdown Styles */
#customerDropdownBtn {
background-color: #fff;
border: 1px solid #ced4da;
}
#customerDropdownBtn:focus {
border-color: #86b7fe;
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
}
#customerDropdownBtn.is-invalid {
border-color: #dc3545;
}
#customerDropdownBtn.is-invalid ~ .invalid-feedback {
display: block;
}
#customerList .dropdown-item:active,
#customerList .dropdown-item.active {
background-color: var(--bs-primary);
color: white;
}
#customerList .dropdown-item:active small,
#customerList .dropdown-item.active small {
color: rgba(255,255,255,0.8) !important;
}
/* Modal Styling Improvements */
#createCardModal .modal-content {
border: none;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
}
#createCardModal .modal-header {
background-color: #f8f9fa;
border-bottom: 1px solid #eee;
}
#createCardModal .input-group-text {
background-color: #f8f9fa;
color: #6c757d;
}
</style> </style>
{% endblock %} {% endblock %}

View File

@ -1,262 +0,0 @@
"""
Customer Data Consistency Service
Compares customer data across BMC Hub, vTiger Cloud, and e-conomic
"""
import logging
import asyncio
from typing import Dict, List, Optional, Tuple, Any
from app.core.database import execute_query_single, execute_update
from app.services.vtiger_service import VTigerService
from app.services.economic_service import EconomicService
from app.core.config import settings
logger = logging.getLogger(__name__)
class CustomerConsistencyService:
"""Service for checking and syncing customer data across systems"""
# Field mapping: hub_field -> (vtiger_field, economic_field)
FIELD_MAP = {
'name': ('accountname', 'name'),
'cvr_number': ('cf_856', 'corporateIdentificationNumber'),
'address': ('bill_street', 'address'),
'city': ('bill_city', 'city'),
'postal_code': ('bill_code', 'zip'),
'country': ('bill_country', 'country'),
'phone': ('phone', 'telephoneAndFaxNumber'),
'mobile_phone': ('mobile', 'mobilePhone'),
'email': ('email1', 'email'),
'website': ('website', 'website'),
'invoice_email': ('email2', 'email'),
}
def __init__(self):
self.vtiger = VTigerService()
self.economic = EconomicService()
@staticmethod
def normalize_value(value: Any) -> Optional[str]:
"""
Normalize value for comparison
- Convert to string
- Strip whitespace
- Lowercase
- Convert empty strings to None
"""
if value is None:
return None
# Convert to string
str_value = str(value).strip()
# Empty string to None
if not str_value:
return None
# Lowercase for case-insensitive comparison
return str_value.lower()
async def fetch_all_data(self, customer_id: int) -> Dict[str, Optional[Dict[str, Any]]]:
"""
Fetch customer data from all three systems in parallel
Args:
customer_id: Hub customer ID
Returns:
Dict with keys 'hub', 'vtiger', 'economic' containing raw data (or None)
"""
logger.info(f"🔍 Fetching customer data from all systems for customer {customer_id}")
# Fetch Hub data first to get mapping IDs
hub_query = """
SELECT * FROM customers WHERE id = %s
"""
hub_data = await asyncio.to_thread(execute_query_single, hub_query, (customer_id,))
if not hub_data:
raise ValueError(f"Customer {customer_id} not found in Hub")
# Prepare async tasks for vTiger and e-conomic
vtiger_task = None
economic_task = None
# Fetch vTiger data if we have an ID and vTiger is configured
if hub_data.get('vtiger_id') and settings.VTIGER_URL:
vtiger_task = self.vtiger.get_account_by_id(hub_data['vtiger_id'])
# Fetch e-conomic data if we have a customer number and e-conomic is configured
if hub_data.get('economic_customer_number') and settings.ECONOMIC_APP_SECRET_TOKEN:
economic_task = self.economic.get_customer(hub_data['economic_customer_number'])
# Parallel fetch with error handling
tasks = {}
if vtiger_task:
tasks['vtiger'] = vtiger_task
if economic_task:
tasks['economic'] = economic_task
results = {}
if tasks:
task_results = await asyncio.gather(
*tasks.values(),
return_exceptions=True
)
# Map results back
for key, result in zip(tasks.keys(), task_results):
if isinstance(result, Exception):
logger.error(f"❌ Error fetching {key} data: {result}")
results[key] = None
else:
results[key] = result
return {
'hub': hub_data,
'vtiger': results.get('vtiger'),
'economic': results.get('economic')
}
@classmethod
def compare_data(cls, all_data: Dict[str, Optional[Dict[str, Any]]]) -> Dict[str, Dict[str, Any]]:
"""
Compare data across systems and identify discrepancies
Args:
all_data: Dict with 'hub', 'vtiger', 'economic' data (values may be None)
Returns:
Dict of discrepancies: {
field_name: {
'hub': value,
'vtiger': value,
'economic': value,
'discrepancy': True/False
}
}
"""
discrepancies = {}
hub_data = all_data.get('hub', {})
vtiger_data = all_data.get('vtiger', {})
economic_data = all_data.get('economic', {})
for hub_field, (vtiger_field, economic_field) in cls.FIELD_MAP.items():
# Get raw values
hub_value = hub_data.get(hub_field)
vtiger_value = vtiger_data.get(vtiger_field) if vtiger_data else None
economic_value = economic_data.get(economic_field) if economic_data else None
# Normalize for comparison
hub_norm = cls.normalize_value(hub_value)
vtiger_norm = cls.normalize_value(vtiger_value)
economic_norm = cls.normalize_value(economic_value)
# Check if all values are the same
# Only compare systems that are available
available_values = []
if hub_data:
available_values.append(hub_norm)
if vtiger_data:
available_values.append(vtiger_norm)
if economic_data:
available_values.append(economic_norm)
# Has discrepancy if there are different non-None values
has_discrepancy = len(set(available_values)) > 1 if len(available_values) > 1 else False
discrepancies[hub_field] = {
'hub': hub_value,
'vtiger': vtiger_value,
'economic': economic_value,
'discrepancy': has_discrepancy
}
return discrepancies
async def sync_field(
self,
customer_id: int,
field_name: str,
source_system: str,
source_value: Any
) -> Dict[str, bool]:
"""
Sync a field value to all enabled systems
Args:
customer_id: Hub customer ID
field_name: Hub field name (from FIELD_MAP keys)
source_system: 'hub', 'vtiger', or 'economic'
source_value: The correct value to sync
Returns:
Dict with sync status: {'hub': True/False, 'vtiger': True/False, 'economic': True/False}
"""
logger.info(f"🔄 Syncing {field_name} from {source_system} with value: {source_value}")
if field_name not in self.FIELD_MAP:
raise ValueError(f"Unknown field: {field_name}")
vtiger_field, economic_field = self.FIELD_MAP[field_name]
# Fetch Hub data to get mapping IDs
hub_query = "SELECT * FROM customers WHERE id = %s"
hub_data = await asyncio.to_thread(execute_query_single, hub_query, (customer_id,))
if not hub_data:
raise ValueError(f"Customer {customer_id} not found")
results = {}
# Update Hub if not the source
if source_system != 'hub':
try:
update_query = f"UPDATE customers SET {field_name} = %s WHERE id = %s"
await asyncio.to_thread(execute_update, update_query, (source_value, customer_id))
results['hub'] = True
logger.info(f"✅ Hub {field_name} updated")
except Exception as e:
logger.error(f"❌ Failed to update Hub: {e}")
results['hub'] = False
else:
results['hub'] = True # Already correct
# Update vTiger if enabled and not the source
if settings.VTIGER_SYNC_ENABLED and source_system != 'vtiger' and hub_data.get('vtiger_id'):
try:
update_data = {vtiger_field: source_value}
success = await self.vtiger.update_account(hub_data['vtiger_id'], update_data)
if success:
results['vtiger'] = True
logger.info(f"✅ vTiger {vtiger_field} updated")
else:
results['vtiger'] = False
logger.error(f"❌ vTiger update failed - API returned False")
except Exception as e:
logger.error(f"❌ Failed to update vTiger: {e}")
results['vtiger'] = False
else:
results['vtiger'] = True # Not applicable or already correct
# Update e-conomic if enabled and not the source
if settings.ECONOMIC_SYNC_ENABLED and source_system != 'economic' and hub_data.get('economic_customer_number'):
try:
# e-conomic update requires different handling based on field
update_data = {economic_field: source_value}
# Check safety flags
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
results['economic'] = False
else:
await self.economic.update_customer(hub_data['economic_customer_number'], update_data)
results['economic'] = True
logger.info(f"✅ e-conomic {economic_field} updated")
except Exception as e:
logger.error(f"❌ Failed to update e-conomic: {e}")
results['economic'] = False
else:
results['economic'] = True # Not applicable or already correct
return results

View File

@ -227,71 +227,6 @@ class EconomicService:
logger.error(f"❌ Error searching customer by name: {e}") logger.error(f"❌ Error searching customer by name: {e}")
return [] return []
async def get_customer(self, customer_number: int) -> Optional[Dict]:
"""
Get a single customer by customer number
Args:
customer_number: e-conomic customer number
Returns:
Customer record or None
"""
try:
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/customers/{customer_number}",
headers=self._get_headers()
) as response:
if response.status == 200:
customer = await response.json()
logger.info(f"✅ Found e-conomic customer {customer_number}")
return customer
elif response.status == 404:
logger.warning(f"⚠️ Customer {customer_number} not found in e-conomic")
return None
else:
error = await response.text()
logger.error(f"❌ Failed to get customer {customer_number}: {response.status} - {error}")
return None
except Exception as e:
logger.error(f"❌ Error getting customer {customer_number}: {e}")
return None
async def update_customer(self, customer_number: int, update_data: Dict) -> bool:
"""
Update a customer in e-conomic
Args:
customer_number: e-conomic customer number
update_data: Dictionary of fields to update
Returns:
True if successful, False otherwise
"""
if settings.ECONOMIC_READ_ONLY or settings.ECONOMIC_DRY_RUN:
logger.warning(f"⚠️ e-conomic update blocked by safety flags (READ_ONLY={settings.ECONOMIC_READ_ONLY}, DRY_RUN={settings.ECONOMIC_DRY_RUN})")
logger.info(f"Would update customer {customer_number} with: {update_data}")
return False
try:
async with aiohttp.ClientSession() as session:
async with session.put(
f"{self.api_url}/customers/{customer_number}",
json=update_data,
headers=self._get_headers()
) as response:
if response.status == 200:
logger.info(f"✅ Updated e-conomic customer {customer_number}")
return True
else:
error = await response.text()
logger.error(f"❌ Failed to update customer {customer_number}: {response.status} - {error}")
return False
except Exception as e:
logger.error(f"❌ Error updating e-conomic customer {customer_number}: {e}")
return False
# ========== SUPPLIER/VENDOR MANAGEMENT ========== # ========== SUPPLIER/VENDOR MANAGEMENT ==========
async def search_supplier_by_name(self, supplier_name: str) -> Optional[Dict]: async def search_supplier_by_name(self, supplier_name: str) -> Optional[Dict]:
@ -435,145 +370,6 @@ class EconomicService:
logger.error(f"❌ Error creating supplier: {e}") logger.error(f"❌ Error creating supplier: {e}")
return None return None
# ========== CUSTOMER INVOICES ==========
async def get_customer_invoices(self, customer_number: int, include_lines: bool = True) -> List[Dict]:
"""
Get customer invoices (sales invoices) from e-conomic
Args:
customer_number: e-conomic customer number
include_lines: Whether to include invoice lines (adds more API calls but gets full data)
Returns:
List of invoice records with lines
"""
try:
async with aiohttp.ClientSession() as session:
# Try multiple endpoints to find invoices
# Include drafts, paid, unpaid, booked, sent, archived, etc.
endpoints_to_try = [
f"{self.api_url}/invoices/archive", # ARCHIVED invoices (primary source)
f"{self.api_url}/invoices/drafts", # Draft invoices
f"{self.api_url}/invoices/sent", # Sent invoices
f"{self.api_url}/invoices/booked", # Booked invoices
f"{self.api_url}/invoices/paid", # Paid invoices
f"{self.api_url}/invoices/unpaid", # Unpaid invoices
f"{self.api_url}/invoices/sales", # All sales invoices
f"{self.api_url}/invoices", # Generic endpoint (returns links)
]
all_invoices = []
# TRY ALL ENDPOINTS AND AGGREGATE RESULTS (don't break after first success)
for endpoint in endpoints_to_try:
logger.info(f"📋 Trying invoice endpoint: {endpoint}")
try:
async with session.get(
endpoint,
params={"pagesize": 1000},
headers=self._get_headers()
) as response:
logger.info(f"🔍 [API] Response status from {endpoint}: {response.status}")
if response.status == 200:
data = await response.json()
invoices_from_endpoint = data.get('collection', [])
logger.info(f"✅ Successfully fetched {len(invoices_from_endpoint)} invoices from {endpoint}")
# Add new invoices (avoid duplicates by tracking invoice numbers)
existing_invoice_numbers = set()
for inv in all_invoices:
inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
if inv_num:
existing_invoice_numbers.add(inv_num)
for inv in invoices_from_endpoint:
inv_num = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
if inv_num and inv_num not in existing_invoice_numbers:
all_invoices.append(inv)
existing_invoice_numbers.add(inv_num)
else:
error_text = await response.text()
logger.warning(f"⚠️ Endpoint {endpoint} returned {response.status}: {error_text[:200]}")
except Exception as e:
logger.warning(f"⚠️ Error trying endpoint {endpoint}: {e}")
if not all_invoices:
logger.warning(f"⚠️ No invoices found from any endpoint")
return []
logger.info(f"✅ Found {len(all_invoices)} total invoices in e-conomic")
# Debug: log response structure
if all_invoices:
logger.info(f"🔍 [API] First invoice structure keys: {list(all_invoices[0].keys())}")
logger.info(f"🔍 [API] First invoice customer field: {all_invoices[0].get('customer')}")
# Log unique customer numbers in response
customer_numbers = set()
for inv in all_invoices[:20]: # Check first 20
cust_num = inv.get('customer', {}).get('customerNumber')
if cust_num:
customer_numbers.add(cust_num)
logger.info(f"🔍 [API] Unique customer numbers in response (first 20): {customer_numbers}")
# Filter invoices for this customer
customer_invoices = [
inv for inv in all_invoices
if inv.get('customer', {}).get('customerNumber') == customer_number
]
logger.info(f"📊 Filtered to {len(customer_invoices)} invoices for customer {customer_number}")
# Fetch full invoice data with lines if requested
if include_lines and customer_invoices:
full_invoices = []
for inv in customer_invoices:
# Try to get full invoice data using the 'self' link or direct URL
invoice_self_link = inv.get('self')
invoice_number = inv.get('draftInvoiceNumber') or inv.get('bookedInvoiceNumber')
logger.debug(f"Fetching full data for invoice: {invoice_number}")
try:
# Try the self link first
fetch_url = invoice_self_link or f"{self.api_url}/invoices/sales/{invoice_number}"
async with session.get(
fetch_url,
headers=self._get_headers()
) as inv_response:
if inv_response.status == 200:
full_inv = await inv_response.json()
full_invoices.append(full_inv)
lines_count = len(full_inv.get('lines', []))
logger.debug(f"✅ Fetched invoice {invoice_number} with {lines_count} lines")
else:
# If self link fails, try with the existing data (may have lines already)
if 'lines' in inv:
full_invoices.append(inv)
logger.debug(f"Using cached data for invoice {invoice_number}")
else:
logger.warning(f"Could not fetch invoice {invoice_number}: {inv_response.status}")
except Exception as e:
# If fetch fails, try to use existing data if it has lines
if 'lines' in inv:
full_invoices.append(inv)
logger.debug(f"Using cached data for invoice {invoice_number} (fetch failed: {e})")
else:
logger.warning(f"⚠️ Could not fetch invoice {invoice_number}: {e}")
logger.info(f"✅ Fetched full data for {len(full_invoices)} invoices")
return full_invoices
return customer_invoices
except Exception as e:
logger.error(f"❌ Error getting customer invoices: {e}")
import traceback
logger.error(traceback.format_exc())
return []
# ========== KASSEKLADDE (JOURNALS/VOUCHERS) ========== # ========== KASSEKLADDE (JOURNALS/VOUCHERS) ==========
async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]: async def check_invoice_number_exists(self, invoice_number: str, journal_number: Optional[int] = None) -> Optional[Dict]:
@ -656,81 +452,6 @@ class EconomicService:
logger.error(f"❌ Error fetching journals: {e}") logger.error(f"❌ Error fetching journals: {e}")
raise raise
async def get_accounts(self) -> List[Dict]:
"""
Get chart of accounts (kontoplan) from e-conomic
Returns:
List of account dictionaries with accountNumber, name, accountType, vatCode
"""
try:
url = f"{self.api_url}/accounts"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self._get_headers()) as response:
if response.status != 200:
error_text = await response.text()
raise Exception(f"e-conomic API error: {response.status} - {error_text}")
data = await response.json()
# Extract relevant account info
accounts = []
for account in data.get('collection', []):
accounts.append({
'accountNumber': account.get('accountNumber'),
'name': account.get('name'),
'accountType': account.get('accountType'),
'vatCode': account.get('vatCode', {}).get('vatCode') if account.get('vatCode') else None,
'balance': account.get('balance', 0.0)
})
logger.info(f"📥 Fetched {len(accounts)} accounts from e-conomic")
return accounts
except Exception as e:
logger.error(f"❌ Error fetching accounts: {e}")
raise
async def sync_accounts_to_database(self) -> int:
"""
Fetch accounts from e-conomic and cache them in database
Returns:
Number of accounts synced
"""
from app.core.database import execute_query
try:
accounts = await self.get_accounts()
# Upsert accounts into database
for account in accounts:
execute_query("""
INSERT INTO economic_accounts
(account_number, name, account_type, vat_code, balance, last_synced)
VALUES (%s, %s, %s, %s, %s, NOW())
ON CONFLICT (account_number)
DO UPDATE SET
name = EXCLUDED.name,
account_type = EXCLUDED.account_type,
vat_code = EXCLUDED.vat_code,
balance = EXCLUDED.balance,
last_synced = NOW()
""", (
account['accountNumber'],
account['name'],
account['accountType'],
account['vatCode'],
account['balance']
))
logger.info(f"✅ Synced {len(accounts)} accounts to database")
return len(accounts)
except Exception as e:
logger.error(f"❌ Error syncing accounts to database: {e}")
raise
async def create_journal_supplier_invoice(self, async def create_journal_supplier_invoice(self,
journal_number: int, journal_number: int,
supplier_number: int, supplier_number: int,

View File

@ -61,14 +61,13 @@ class EmailAnalysisService:
"""Build Danish system prompt for email classification""" """Build Danish system prompt for email classification"""
return """Classify this Danish business email into ONE category. Return ONLY valid JSON with no explanation. return """Classify this Danish business email into ONE category. Return ONLY valid JSON with no explanation.
Categories: invoice, freight_note, order_confirmation, time_confirmation, case_notification, customer_email, bankruptcy, newsletter, general, spam, unknown Categories: invoice, freight_note, order_confirmation, time_confirmation, case_notification, customer_email, bankruptcy, general, spam, unknown
Rules: Rules:
- invoice: Contains invoice number, amount, or payment info - invoice: Contains invoice number, amount, or payment info
- time_confirmation: Time/hours confirmation, often with case references - time_confirmation: Time/hours confirmation, often with case references
- case_notification: Notifications about specific cases (CC0001, Case #123) - case_notification: Notifications about specific cases (CC0001, Case #123)
- bankruptcy: Explicit bankruptcy/insolvency notice - bankruptcy: Explicit bankruptcy/insolvency notice
- newsletter: Info mails, marketing, campaigns, webinars, or non-critical updates (not spam)
- Be conservative: Use general or unknown if uncertain - Be conservative: Use general or unknown if uncertain
Response format (JSON only, no other text): Response format (JSON only, no other text):

View File

@ -5,18 +5,16 @@ Based on OmniSync architecture adapted for BMC Hub
""" """
import logging import logging
import re
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime from datetime import datetime
from app.services.email_service import EmailService from app.services.email_service import EmailService
from app.services.email_analysis_service import EmailAnalysisService from app.services.email_analysis_service import EmailAnalysisService
from app.services.transcription_service import TranscriptionService
from app.services.simple_classifier import simple_classifier from app.services.simple_classifier import simple_classifier
from app.services.email_workflow_service import email_workflow_service from app.services.email_workflow_service import email_workflow_service
from app.services.email_activity_logger import email_activity_logger from app.services.email_activity_logger import email_activity_logger
from app.core.config import settings from app.core.config import settings
from app.core.database import execute_query, execute_update, execute_insert from app.core.database import execute_query, execute_update
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -27,7 +25,6 @@ class EmailProcessorService:
def __init__(self): def __init__(self):
self.email_service = EmailService() self.email_service = EmailService()
self.analysis_service = EmailAnalysisService() self.analysis_service = EmailAnalysisService()
self.transcription_service = TranscriptionService()
self.enabled = settings.EMAIL_TO_TICKET_ENABLED self.enabled = settings.EMAIL_TO_TICKET_ENABLED
self.rules_enabled = settings.EMAIL_RULES_ENABLED self.rules_enabled = settings.EMAIL_RULES_ENABLED
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
@ -81,13 +78,37 @@ class EmailProcessorService:
message_id=email_data.get('message_id', 'unknown') message_id=email_data.get('message_id', 'unknown')
) )
# Step 3-5: Process the single email # Step 3: Classify email with AI
result = await self.process_single_email(email_data) if settings.EMAIL_AI_ENABLED and settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
if result.get('classified'):
stats['classified'] += 1 stats['classified'] += 1
if result.get('rules_matched'):
# Step 4: Execute workflows based on classification
workflow_processed = False
if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') and settings.EMAIL_WORKFLOWS_ENABLED:
workflow_result = await email_workflow_service.execute_workflows(email_data)
if workflow_result.get('workflows_executed', 0) > 0:
logger.info(f"✅ Executed {workflow_result['workflows_executed']} workflow(s) for email {email_id}")
# Mark as workflow-processed to avoid duplicate rule execution
if workflow_result.get('workflows_succeeded', 0) > 0:
workflow_processed = True
email_data['_workflow_processed'] = True
# Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email
existing_execution = execute_query_single(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
(email_id,))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
else:
matched = await self._match_rules(email_data)
if matched:
stats['rules_matched'] += 1 stats['rules_matched'] += 1
elif workflow_processed:
logger.info(f"⏭️ Email {email_id} processed by workflow, skipping rules (coordination)")
except Exception as e: except Exception as e:
logger.error(f"❌ Error processing email: {e}") logger.error(f"❌ Error processing email: {e}")
@ -101,106 +122,21 @@ class EmailProcessorService:
stats['errors'] += 1 stats['errors'] += 1
return stats return stats
async def process_single_email(self, email_data: Dict) -> Dict:
"""
Process a single email: Classify -> Workflow -> Rules
Can be used by process_inbox (new emails) or bulk_reprocess (existing emails)
"""
email_id = email_data.get('id')
stats = {
'classified': False,
'workflows_executed': 0,
'rules_matched': False
}
try:
# Step 2.5: Detect and transcribe audio attachments
# This is done BEFORE classification so the AI can "read" the voice note
if settings.WHISPER_ENABLED:
await self._process_attachments_for_transcription(email_data)
# Step 3: Classify email (AI or Keyword)
if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data)
stats['classified'] = True
# Step 4: Execute workflows based on classification
workflow_processed = False
if hasattr(settings, 'EMAIL_WORKFLOWS_ENABLED') and settings.EMAIL_WORKFLOWS_ENABLED:
workflow_result = await email_workflow_service.execute_workflows(email_data)
executed_count = workflow_result.get('workflows_executed', 0)
stats['workflows_executed'] = executed_count
if executed_count > 0:
logger.info(f"✅ Executed {executed_count} workflow(s) for email {email_id}")
# Mark as workflow-processed to avoid duplicate rule execution
if workflow_result.get('workflows_succeeded', 0) > 0:
workflow_processed = True
email_data['_workflow_processed'] = True
# Definition: A processed email is one that is classified and workflow run
# Mark as 'processed' and move to 'Processed' folder
logger.info(f"✅ Auto-marking email {email_id} as processed (Workflow executed)")
execute_update(
"""UPDATE email_messages
SET status = 'processed',
folder = 'Processed',
processed_at = CURRENT_TIMESTAMP,
auto_processed = true
WHERE id = %s""",
(email_id,)
)
# Step 5: Match against rules (legacy support) - skip if workflow already processed
if self.rules_enabled and not workflow_processed:
# Check if workflow already processed this email (double check DB)
existing_execution = execute_query(
"SELECT id FROM email_workflow_executions WHERE email_id = %s AND status = 'completed' LIMIT 1",
(email_data['id'],))
if existing_execution:
logger.info(f"⏭️ Email {email_id} already processed by workflow, skipping rules")
else:
matched = await self._match_rules(email_data)
if matched:
stats['rules_matched'] = True
elif workflow_processed:
logger.info(f"⏭️ Email {email_id} processed by workflow, skipping rules (coordination)")
return stats
except Exception as e:
logger.error(f"❌ Error in process_single_email for {email_id}: {e}")
raise e
async def _classify_and_update(self, email_data: Dict): async def _classify_and_update(self, email_data: Dict):
"""Classify email and update database""" """Classify email and update database"""
try: try:
logger.info(f"🔍 _classify_and_update: ai_enabled={self.ai_enabled}, EMAIL_AI_ENABLED={settings.EMAIL_AI_ENABLED}") logger.info(f"🔍 _classify_and_update: ai_enabled={self.ai_enabled}, EMAIL_AI_ENABLED={settings.EMAIL_AI_ENABLED}")
# 1. Always start with Simple Keyword Classification (fast, free, deterministic) # Run classification (AI or simple keyword-based)
logger.info(f"🔍 Running keyword classification for email {email_data['id']}") if self.ai_enabled:
result = await self.analysis_service.classify_email(email_data)
else:
logger.info(f"🔍 Using simple keyword classifier for email {email_data['id']}")
result = simple_classifier.classify(email_data) result = simple_classifier.classify(email_data)
classification = result.get('classification', 'unknown') classification = result.get('classification', 'unknown')
confidence = result.get('confidence', 0.0) confidence = result.get('confidence', 0.0)
# 2. Use AI if keyword analysis is weak or inconclusive
# Trigger if: 'general' OR 'unknown' OR confidence is low (< 0.70)
should_use_ai = (classification in ['general', 'unknown'] or confidence < 0.70)
if should_use_ai and self.ai_enabled:
logger.info(f"🤖 Escalating to AI analysis (Reason: '{classification}' with confidence {confidence})")
ai_result = await self.analysis_service.classify_email(email_data)
# Update result if AI returns valid data
if ai_result:
result = ai_result
logger.info(f"✅ AI re-classified as '{result.get('classification')}'")
classification = result.get('classification', 'unknown')
confidence = result.get('confidence', 0.0)
# Update email record # Update email record
query = """ query = """
UPDATE email_messages UPDATE email_messages
@ -521,108 +457,6 @@ class EmailProcessorService:
""" """
execute_query(query, (rule_id,)) execute_query(query, (rule_id,))
async def _process_attachments_for_transcription(self, email_data: Dict) -> None:
"""
Scan attachments for audio files, transcribe them, and enrich email body.
Also creates 'conversations' record.
"""
attachments = email_data.get('attachments', [])
if not attachments:
return
import hashlib
from pathlib import Path
transcripts = []
for att in attachments:
filename = att.get('filename', '')
content = att.get('content')
# Simple check, TranscriptionService does the real check
ext = Path(filename).suffix.lower()
if ext in settings.WHISPER_SUPPORTED_FORMATS:
transcript = await self.transcription_service.transcribe_audio(filename, content)
if transcript:
transcripts.append(f"--- TRANSKRIBERET LYDFIL ({filename}) ---\n{transcript}\n----------------------------------")
# Create conversation record (ALWAYS for supported audio, even if transcription fails)
try:
# Reconstruct path - mirroring EmailService._save_attachments logic
md5_hash = hashlib.md5(content).hexdigest()
# Default path in EmailService is "uploads/email_attachments"
file_path = f"uploads/email_attachments/{md5_hash}_{filename}"
# Determine Title from Subject if possible
title = f"Email Attachment: {filename}"
subject = email_data.get('subject', '')
# Pattern: "Optagelse af samtale(n) mellem 204 og 209"
# Handles both "samtale" and "samtalen", case insensitive
match = re.search(r'Optagelse af samtalen?\s+mellem\s+(\S+)\s+og\s+(\S+)', subject, re.IGNORECASE)
if match:
num1 = match.group(1)
num2 = match.group(2)
title = f"Samtale: {num1}{num2}"
# Generate Summary
summary = None
try:
from app.services.ollama_service import ollama_service
if transcript:
logger.info("🧠 Generating conversation summary...")
summary = await ollama_service.generate_summary(transcript)
except Exception as e:
logger.error(f"⚠️ Failed to generate summary: {e}")
# Determine user_id (optional, maybe from sender if internal?)
# For now, create as system/unassigned
query = """
INSERT INTO conversations
(title, transcript, summary, audio_file_path, source, email_message_id, created_at)
VALUES (%s, %s, %s, %s, 'email', %s, CURRENT_TIMESTAMP)
RETURNING id
"""
conversation_id = execute_insert(query, (
title,
transcript,
summary,
file_path,
email_data.get('id')
))
# Try to link to customer if we already know them?
# ACTUALLY: We are BEFORE classification/domain matching.
# Ideally, we should link later.
# BUT, we can store the 'email_id' if we had a column.
# I didn't add 'email_id' to conversations table.
# I added customer_id and ticket_id.
# Since this runs BEFORE those links are established, the conversation will be orphaned initially.
# We could improve this by updating the conversation AFTER Step 5 (customer linking).
# Or, simplified: The transcribed text is in the email body.
# When the email is converted to a Ticket, the text follows.
# But the 'Conversation' record is separate.
logger.info(f"✅ Created conversation record {conversation_id} for {filename}")
except Exception as e:
logger.error(f"❌ Failed to create conversation record: {e}")
if transcripts:
# Append to body
full_transcript_text = "\n\n" + "\n\n".join(transcripts)
if 'body' in email_data:
email_data['body'] += full_transcript_text
# Also update body_text if it exists (often used for analysis)
if 'body_text' in email_data and email_data['body_text']:
email_data['body_text'] += full_transcript_text
logger.info(f"✅ Enriched email {email_data.get('id')} with {len(transcripts)} transcription(s)")
async def reprocess_email(self, email_id: int): async def reprocess_email(self, email_id: int):
"""Manually reprocess a single email""" """Manually reprocess a single email"""
try: try:
@ -636,33 +470,6 @@ class EmailProcessorService:
email_data = result[0] email_data = result[0]
# Fetch attachments from DB to allow transcription on reprocess
query_att = "SELECT * FROM email_attachments WHERE email_id = %s"
atts = execute_query(query_att, (email_id,))
loaded_atts = []
if atts:
from pathlib import Path
for att in atts:
# 'file_path' is in DB
fpath = att.get('file_path')
if fpath:
try:
# If path is relative to cwd
path_obj = Path(fpath)
if path_obj.exists():
att['content'] = path_obj.read_bytes()
loaded_atts.append(att)
logger.info(f"📎 Loaded attachment content for reprocess: {att['filename']}")
except Exception as e:
logger.error(f"❌ Could not verify/load attachment {fpath}: {e}")
email_data['attachments'] = loaded_atts
# Run Transcription (Step 2.5 equivalent)
if settings.WHISPER_ENABLED and loaded_atts:
await self._process_attachments_for_transcription(email_data)
# Reclassify (either AI or keyword-based) # Reclassify (either AI or keyword-based)
if settings.EMAIL_AUTO_CLASSIFY: if settings.EMAIL_AUTO_CLASSIFY:
await self._classify_and_update(email_data) await self._classify_and_update(email_data)

View File

@ -0,0 +1,77 @@
"""
Email Scheduler
Background job that runs every 5 minutes to fetch and process emails
Based on OmniSync scheduler with APScheduler
"""
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from datetime import datetime
from app.core.config import settings
from app.services.email_processor_service import EmailProcessorService
logger = logging.getLogger(__name__)
class EmailScheduler:
"""Background scheduler for email processing"""
def __init__(self):
self.scheduler = AsyncIOScheduler()
self.processor = EmailProcessorService()
self.enabled = settings.EMAIL_TO_TICKET_ENABLED
self.interval_minutes = settings.EMAIL_PROCESS_INTERVAL_MINUTES
def start(self):
"""Start the background scheduler"""
if not self.enabled:
logger.info("⏭️ Email scheduler disabled (EMAIL_TO_TICKET_ENABLED=false)")
return
logger.info(f"🚀 Starting email scheduler (interval: {self.interval_minutes} minutes)")
# Add job with interval trigger
self.scheduler.add_job(
func=self._process_emails_job,
trigger=IntervalTrigger(minutes=self.interval_minutes),
id='email_processor',
name='Email Processing Job',
max_instances=1, # Prevent overlapping runs
replace_existing=True
)
self.scheduler.start()
logger.info("✅ Email scheduler started successfully")
def stop(self):
"""Stop the scheduler"""
if self.scheduler.running:
self.scheduler.shutdown(wait=True)
logger.info("👋 Email scheduler stopped")
async def _process_emails_job(self):
"""Job function that processes emails"""
try:
logger.info("🔄 Email processing job started...")
start_time = datetime.now()
stats = await self.processor.process_inbox()
duration = (datetime.now() - start_time).total_seconds()
logger.info(f"✅ Email processing complete: {stats} (duration: {duration:.1f}s)")
except Exception as e:
logger.error(f"❌ Email processing job failed: {e}")
def run_manual(self):
"""Manually trigger email processing (for testing)"""
logger.info("🚀 Manual email processing triggered")
import asyncio
asyncio.create_task(self._process_emails_job())
# Global scheduler instance
email_scheduler = EmailScheduler()

View File

@ -297,22 +297,11 @@ class EmailService:
continue continue
# Skip text parts (body content) # Skip text parts (body content)
content_type = part.get_content_type() if part.get_content_type() in ['text/plain', 'text/html']:
if content_type in ['text/plain', 'text/html']:
continue continue
# Check if part has a filename (indicates attachment) # Check if part has a filename (indicates attachment)
filename = part.get_filename() filename = part.get_filename()
# FALLBACK: If no filename but content-type is audio, generate one
if not filename and content_type.startswith('audio/'):
ext = '.mp3'
if 'wav' in content_type: ext = '.wav'
elif 'ogg' in content_type: ext = '.ogg'
elif 'm4a' in content_type: ext = '.m4a'
filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found audio attachment without filename. Generated: {filename}")
if filename: if filename:
# Decode filename if needed # Decode filename if needed
filename = self._decode_header(filename) filename = self._decode_header(filename)
@ -423,26 +412,14 @@ class EmailService:
else: else:
content = b'' content = b''
# Handle missing filenames for audio (FALLBACK)
filename = att.get('name')
content_type = att.get('contentType', 'application/octet-stream')
if not filename and content_type.startswith('audio/'):
ext = '.mp3'
if 'wav' in content_type: ext = '.wav'
elif 'ogg' in content_type: ext = '.ogg'
elif 'm4a' in content_type: ext = '.m4a'
filename = f"audio_attachment{ext}"
logger.info(f"⚠️ Found (Graph) audio attachment without filename. Generated: {filename}")
attachments.append({ attachments.append({
'filename': filename or 'unknown', 'filename': att.get('name', 'unknown'),
'content': content, 'content': content,
'content_type': content_type, 'content_type': att.get('contentType', 'application/octet-stream'),
'size': att.get('size', len(content)) 'size': att.get('size', len(content))
}) })
logger.info(f"📎 Fetched attachment: {filename} ({att.get('size', 0)} bytes)") logger.info(f"📎 Fetched attachment: {att.get('name')} ({att.get('size', 0)} bytes)")
except Exception as e: except Exception as e:
logger.error(f"❌ Error fetching attachments for message {message_id}: {e}") logger.error(f"❌ Error fetching attachments for message {message_id}: {e}")
@ -600,255 +577,3 @@ class EmailService:
query = "UPDATE email_messages SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s" query = "UPDATE email_messages SET status = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s"
execute_query(query, (status, email_id)) execute_query(query, (status, email_id))
logger.info(f"✅ Updated email {email_id} status to: {status}") logger.info(f"✅ Updated email {email_id} status to: {status}")
def parse_eml_file(self, content: bytes) -> Optional[Dict]:
"""
Parse .eml file content into standardized email dictionary
Args:
content: Raw .eml file bytes
Returns:
Dictionary with email data or None if parsing fails
"""
try:
from email import policy
from email.parser import BytesParser
msg = BytesParser(policy=policy.default).parsebytes(content)
# Extract basic fields
message_id = msg.get("Message-ID", "").strip("<>")
if not message_id:
# Generate fallback message ID from date + subject
import hashlib
hash_str = f"{msg.get('Date', '')}{msg.get('Subject', '')}{msg.get('From', '')}"
message_id = f"uploaded-{hashlib.md5(hash_str.encode()).hexdigest()}@bmchub.local"
# Parse sender
from_header = msg.get("From", "")
sender_name, sender_email = self._parse_email_address(from_header)
# Parse recipient
to_header = msg.get("To", "")
recipient_name, recipient_email = self._parse_email_address(to_header)
# Parse CC
cc_header = msg.get("Cc", "")
# Parse date
date_str = msg.get("Date")
try:
if date_str:
from email.utils import parsedate_to_datetime
received_date = parsedate_to_datetime(date_str)
else:
received_date = datetime.now()
except:
received_date = datetime.now()
# Extract body
body_text = ""
body_html = ""
attachments = []
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
content_disposition = part.get_content_disposition()
# Get text body
if content_type == "text/plain" and content_disposition != "attachment":
try:
body = part.get_payload(decode=True)
if body:
body_text = body.decode('utf-8', errors='ignore')
except:
pass
# Get HTML body
elif content_type == "text/html" and content_disposition != "attachment":
try:
body = part.get_payload(decode=True)
if body:
body_html = body.decode('utf-8', errors='ignore')
except:
pass
# Get attachments
elif content_disposition == "attachment":
filename = part.get_filename()
if filename:
try:
content = part.get_payload(decode=True)
if content:
attachments.append({
"filename": filename,
"content_type": content_type,
"content": content,
"size_bytes": len(content)
})
except:
pass
else:
# Single part email
try:
body = msg.get_payload(decode=True)
if body:
content_type = msg.get_content_type()
if content_type == "text/html":
body_html = body.decode('utf-8', errors='ignore')
else:
body_text = body.decode('utf-8', errors='ignore')
except:
pass
return {
"message_id": message_id,
"subject": msg.get("Subject", "No Subject"),
"sender_name": sender_name,
"sender_email": sender_email,
"recipient_email": recipient_email,
"cc": cc_header,
"body_text": body_text,
"body_html": body_html,
"received_date": received_date,
"has_attachments": len(attachments) > 0,
"attachments": attachments,
"folder": "uploaded"
}
except Exception as e:
logger.error(f"❌ Failed to parse .eml file: {e}")
return None
def parse_msg_file(self, content: bytes) -> Optional[Dict]:
"""
Parse Outlook .msg file content into standardized email dictionary
Args:
content: Raw .msg file bytes
Returns:
Dictionary with email data or None if parsing fails
"""
try:
import extract_msg
import io
import hashlib
# Create BytesIO object from content
msg_io = io.BytesIO(content)
msg = extract_msg.Message(msg_io)
# Generate message ID if not present
message_id = msg.messageId
if not message_id:
hash_str = f"{msg.date}{msg.subject}{msg.sender}"
message_id = f"uploaded-{hashlib.md5(hash_str.encode()).hexdigest()}@bmchub.local"
else:
message_id = message_id.strip("<>")
# Parse date
try:
received_date = msg.date if msg.date else datetime.now()
except:
received_date = datetime.now()
# Extract attachments
attachments = []
for attachment in msg.attachments:
try:
attachments.append({
"filename": attachment.longFilename or attachment.shortFilename or "unknown",
"content_type": attachment.mimetype or "application/octet-stream",
"content": attachment.data,
"size_bytes": len(attachment.data) if attachment.data else 0
})
except:
pass
return {
"message_id": message_id,
"subject": msg.subject or "No Subject",
"sender_name": msg.sender or "",
"sender_email": msg.senderEmail or "",
"recipient_email": msg.to or "",
"cc": msg.cc or "",
"body_text": msg.body or "",
"body_html": msg.htmlBody or "",
"received_date": received_date,
"has_attachments": len(attachments) > 0,
"attachments": attachments,
"folder": "uploaded"
}
except Exception as e:
logger.error(f"❌ Failed to parse .msg file: {e}")
return None
async def save_uploaded_email(self, email_data: Dict) -> Optional[int]:
"""
Save uploaded email to database
Args:
email_data: Parsed email dictionary
Returns:
email_id if successful, None if duplicate or error
"""
try:
# Check if email already exists
check_query = "SELECT id FROM email_messages WHERE message_id = %s"
existing = execute_query(check_query, (email_data["message_id"],))
if existing:
logger.info(f"⏭️ Email already exists: {email_data['message_id']}")
return None
# Insert email
query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
status, import_method, created_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, CURRENT_TIMESTAMP)
RETURNING id
"""
result = execute_insert(query, (
email_data["message_id"],
email_data["subject"],
email_data["sender_email"],
email_data["sender_name"],
email_data.get("recipient_email", ""),
email_data.get("cc", ""),
email_data["body_text"],
email_data["body_html"],
email_data["received_date"],
email_data["folder"],
email_data["has_attachments"],
len(email_data.get("attachments", [])),
"new",
"manual_upload"
))
if not result:
logger.error("❌ Failed to insert email - no ID returned")
return None
email_id = result[0]["id"]
# Save attachments
if email_data.get("attachments"):
await self._save_attachments(email_id, email_data["attachments"])
logger.info(f"✅ Saved uploaded email: {email_data['subject'][:50]}... (ID: {email_id})")
return email_id
except Exception as e:
logger.error(f"❌ Failed to save uploaded email: {e}")
return None

View File

@ -51,6 +51,15 @@ class EmailWorkflowService:
logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})") logger.info(f"🔄 Finding workflows for classification: {classification} (confidence: {confidence})")
# Find matching workflows
workflows = await self._find_matching_workflows(email_data)
if not workflows:
logger.info(f"✅ No workflows match classification: {classification}")
return {'status': 'no_match', 'workflows_executed': 0}
logger.info(f"📋 Found {len(workflows)} matching workflow(s)")
results = { results = {
'status': 'executed', 'status': 'executed',
'workflows_executed': 0, 'workflows_executed': 0,
@ -59,29 +68,6 @@ class EmailWorkflowService:
'details': [] 'details': []
} }
# Special System Workflow: Bankruptcy Analysis
# Parses Statstidende emails for CVR numbers to link to customers
if classification == 'bankruptcy':
sys_result = await self._handle_bankruptcy_analysis(email_data)
results['details'].append(sys_result)
if sys_result['status'] == 'completed':
results['workflows_executed'] += 1
results['workflows_succeeded'] += 1
logger.info("✅ Bankruptcy system workflow executed successfully")
# Find matching workflows
workflows = await self._find_matching_workflows(email_data)
if not workflows and results['workflows_executed'] == 0:
logger.info(f"✅ No workflows match classification: {classification}")
return {'status': 'no_match', 'workflows_executed': 0}
logger.info(f"📋 Found {len(workflows)} matching workflow(s)")
# Initialize results if not already (moved up)
# results = { ... } (already initialized in my thought, but need to move init up)
# Execute workflows in priority order # Execute workflows in priority order
for workflow in workflows: for workflow in workflows:
result = await self._execute_workflow(workflow, email_data) result = await self._execute_workflow(workflow, email_data)
@ -101,81 +87,6 @@ class EmailWorkflowService:
logger.info(f"✅ Workflow execution complete: {results['workflows_succeeded']}/{results['workflows_executed']} succeeded") logger.info(f"✅ Workflow execution complete: {results['workflows_succeeded']}/{results['workflows_executed']} succeeded")
return results return results
async def _handle_bankruptcy_analysis(self, email_data: Dict) -> Dict:
"""
System workflow for bankruptcy emails (Statstidende).
Parses body for CVR numbers and links to customer if match found.
Returns: Execution result dict
"""
logger.info("🕵️ Running Bankruptcy Analysis on email")
# Combine subject, body and html for search
text_content = (
f"{email_data.get('subject', '')} "
f"{email_data.get('body_text', '')} "
f"{email_data.get('body_html', '')}"
)
# Regex for CVR numbers (8 digits, possibly preceded by 'CVR-nr.:')
# We look for explicit 'CVR-nr.: XXXXXXXX' pattern first as it's more reliable
cvr_matches = re.findall(r'CVR-nr\.?:?\s*(\d{8})', text_content, re.IGNORECASE)
if not cvr_matches:
logger.info("✅ No CVR numbers found in bankruptcy email")
return {'status': 'skipped', 'reason': 'no_cvr_found'}
unique_cvrs = list(set(cvr_matches))
logger.info(f"📋 Found CVRs in email: {unique_cvrs}")
if not unique_cvrs:
return {'status': 'skipped', 'reason': 'no_unique_cvr'}
# Check if any CVRs belong to our customers
# Safe parameterized query for variable list length
format_strings = ','.join(['%s'] * len(unique_cvrs))
query = f"""
SELECT id, name, cvr_number
FROM customers
WHERE cvr_number IN ({format_strings})
"""
matching_customers = execute_query(query, tuple(unique_cvrs))
if not matching_customers:
logger.info("✅ No matching customers found for bankruptcy CVRs - Marking as processed")
execute_update(
"""UPDATE email_messages
SET status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s""",
(email_data['id'],)
)
return {'status': 'completed', 'action': 'marked_processed_no_match'}
logger.warning(f"⚠️ FOUND BANKRUPTCY MATCHES: {[c['name'] for c in matching_customers]}")
# Link to the first customer found (limitation of 1:1 schema)
first_match = matching_customers[0]
execute_update(
"""UPDATE email_messages
SET customer_id = %s, status = 'processed', folder = 'Processed',
processed_at = CURRENT_TIMESTAMP, auto_processed = true
WHERE id = %s""",
(first_match['id'], email_data['id'])
)
logger.info(f"🔗 Linked bankruptcy email {email_data['id']} to customer {first_match['name']} ({first_match['id']}) and marked as processed")
if len(matching_customers) > 1:
logger.warning(f"❗ Email contained multiple customer matches! Only linked to first one.")
return {
'status': 'completed',
'action': 'linked_customer',
'customer_name': first_match['name']
}
async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]: async def _find_matching_workflows(self, email_data: Dict) -> List[Dict]:
"""Find all workflows that match this email""" """Find all workflows that match this email"""
classification = email_data.get('classification') classification = email_data.get('classification')
@ -193,7 +104,7 @@ class EmailWorkflowService:
ORDER BY priority ASC ORDER BY priority ASC
""" """
workflows = execute_query(query, (classification, confidence)) workflows = execute_query_single(query, (classification, confidence))
# Filter by additional patterns # Filter by additional patterns
matching = [] matching = []
@ -362,7 +273,6 @@ class EmailWorkflowService:
'link_to_customer': self._action_link_to_customer, 'link_to_customer': self._action_link_to_customer,
'extract_invoice_data': self._action_extract_invoice_data, 'extract_invoice_data': self._action_extract_invoice_data,
'extract_tracking_number': self._action_extract_tracking_number, 'extract_tracking_number': self._action_extract_tracking_number,
'regex_extract_and_link': self._action_regex_extract_and_link,
'send_slack_notification': self._action_send_slack_notification, 'send_slack_notification': self._action_send_slack_notification,
'send_email_notification': self._action_send_email_notification, 'send_email_notification': self._action_send_email_notification,
'mark_as_processed': self._action_mark_as_processed, 'mark_as_processed': self._action_mark_as_processed,
@ -393,70 +303,6 @@ class EmailWorkflowService:
# Action Handlers # Action Handlers
async def _action_regex_extract_and_link(self, params: Dict, email_data: Dict) -> Dict:
"""
Generic action to extract data via regex and link/update record
Params:
- regex_pattern: Pattern with one capture group (e.g. "CVR: (\d{8})")
- target_table: Table to search (e.g. "customers")
- target_column: Column to match value against (e.g. "cvr_number")
- link_column: Column in email_messages to update (e.g. "customer_id")
- value_column: Column in target table to retrieve (e.g. "id")
- on_match: "update_email" (default) or "none"
"""
regex_pattern = params.get('regex_pattern')
target_table = params.get('target_table')
target_column = params.get('target_column')
link_column = params.get('link_column', 'customer_id')
value_column = params.get('value_column', 'id')
if not all([regex_pattern, target_table, target_column]):
return {'status': 'failed', 'error': 'Missing required params: regex_pattern, target_table, target_column'}
# Combine text for search
text_content = (
f"{email_data.get('subject', '')} "
f"{email_data.get('body_text', '')} "
f"{email_data.get('body_html', '')}"
)
# 1. Run Regex
matches = re.findall(regex_pattern, text_content, re.IGNORECASE)
unique_matches = list(set(matches))
if not unique_matches:
return {'status': 'skipped', 'reason': 'no_regex_match', 'pattern': regex_pattern}
logger.info(f"🔍 Regex '{regex_pattern}' found matches: {unique_matches}")
# 2. Look up in Target Table
# Safety check: simplistic validation against SQL injection for table/column names is assumed
# (params should come from trustworthy configuration)
valid_tables = ['customers', 'vendors', 'users']
if target_table not in valid_tables:
return {'status': 'failed', 'error': f'Invalid target table: {target_table}'}
placeholders = ','.join(['%s'] * len(unique_matches))
query = f"SELECT {value_column}, {target_column} FROM {target_table} WHERE {target_column} IN ({placeholders})"
db_matches = execute_query(query, tuple(unique_matches))
if not db_matches:
return {'status': 'completed', 'action': 'no_db_match', 'found_values': unique_matches}
# 3. Link (Update Email)
match = db_matches[0] # Take first match
match_value = match[value_column]
if params.get('on_match', 'update_email') == 'update_email':
update_query = f"UPDATE email_messages SET {link_column} = %s WHERE id = %s"
execute_update(update_query, (match_value, email_data['id']))
logger.info(f"🔗 Linked email {email_data['id']} to {target_table}.{value_column}={match_value}")
return {'status': 'completed', 'action': 'linked', 'match_id': match_value}
return {'status': 'completed', 'action': 'found_only', 'match_id': match_value}
async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict: async def _action_create_ticket_system(self, params: Dict, email_data: Dict) -> Dict:
"""Create a ticket from email using new ticket system""" """Create a ticket from email using new ticket system"""
from app.ticket.backend.email_integration import EmailTicketIntegration from app.ticket.backend.email_integration import EmailTicketIntegration
@ -560,10 +406,9 @@ class EmailWorkflowService:
vendor_id = result['id'] vendor_id = result['id']
# Check if already linked to avoid duplicate updates # Check if already linked to avoid duplicate updates
result_vendor = execute_query( current_vendor = execute_query_single(
"SELECT supplier_id FROM email_messages WHERE id = %s", "SELECT supplier_id FROM email_messages WHERE id = %s",
(email_data['id'],)) (email_data['id'],))
current_vendor = result_vendor[0] if result_vendor else None
if current_vendor and current_vendor.get('supplier_id') == vendor_id: if current_vendor and current_vendor.get('supplier_id') == vendor_id:
logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update") logger.info(f"⏭️ Email already linked to vendor {vendor_id}, skipping duplicate update")
@ -612,7 +457,7 @@ class EmailWorkflowService:
vendor_id = email_data.get('supplier_id') vendor_id = email_data.get('supplier_id')
# Get PDF attachments from email # Get PDF attachments from email
attachments = execute_query( attachments = execute_query_single(
"""SELECT filename, file_path, size_bytes, content_type """SELECT filename, file_path, size_bytes, content_type
FROM email_attachments FROM email_attachments
WHERE email_id = %s AND content_type = 'application/pdf'""", WHERE email_id = %s AND content_type = 'application/pdf'""",

View File

@ -6,7 +6,6 @@ Handles supplier invoice extraction using Ollama LLM with CVR matching
import json import json
import hashlib import hashlib
import logging import logging
import os
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, List, Tuple from typing import Optional, Dict, List, Tuple
from datetime import datetime from datetime import datetime
@ -594,42 +593,6 @@ Output: {
logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}") logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}")
return None return None
async def generate_summary(self, text: str) -> str:
"""
Generate a short summary of the text using Ollama
"""
if not text:
return ""
system_prompt = "Du er en hjælpsom assistent, der laver korte, præcise resuméer på dansk."
user_prompt = f"Lav et kort resumé (max 50 ord) af følgende tekst:\n\n{text}"
try:
import aiohttp
logger.info(f"🧠 Generating summary with Ollama ({self.model})...")
async with aiohttp.ClientSession() as session:
payload = {
"model": self.model,
"prompt": system_prompt + "\n\n" + user_prompt,
"stream": False,
"options": {"temperature": 0.3}
}
async with session.post(f"{self.endpoint}/api/generate", json=payload, timeout=60.0) as response:
if response.status == 200:
data = await response.json()
summary = data.get("response", "").strip()
logger.info("✅ Summary generated")
return summary
else:
error_text = await response.text()
logger.error(f"❌ Ollama error: {error_text}")
return "Kunne ikke generere resumé (API fejl)."
except Exception as e:
logger.error(f"❌ Ollama summary failed: {e}")
return f"Ingen resumé (Fejl: {str(e)})"
# Global instance # Global instance
ollama_service = OllamaService() ollama_service = OllamaService()

View File

@ -35,22 +35,10 @@ class SimpleEmailClassifier:
'case_notification': [ 'case_notification': [
'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support' 'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support'
], ],
'recording': [
'lydbesked', 'optagelse', 'voice note', 'voicemail',
'telefonsvarer', 'samtale', 'recording', 'audio note'
],
'bankruptcy': [ 'bankruptcy': [
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency', 'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
'betalingsstandsning', 'administration' 'betalingsstandsning', 'administration'
], ],
'newsletter': [
'nyhedsbrev', 'newsletter', 'kampagne', 'campaign',
'tilbud', 'offer', 'webinar', 'invitation', 'event',
'update', 'opdatering', 'salg', 'sale', 'black friday',
'cyber monday', 'sommerudsalg', 'vinterudsalg', 'rabat',
'discount', 'no-reply', 'noreply', 'automatisk besked',
'auto-generated'
],
'spam': [ 'spam': [
'unsubscribe', 'click here', 'free offer', 'gratis tilbud', 'unsubscribe', 'click here', 'free offer', 'gratis tilbud',
'vind nu', 'win now', 'limited time' 'vind nu', 'win now', 'limited time'

View File

@ -1,315 +0,0 @@
"""
Subscription Billing Matrix Service
Generate a per-customer billing matrix showing:
- Rows: Products from e-conomic customer invoices
- Columns: Months
- Cells: Expected vs invoiced amounts, payment status, invoice references
Data source: e-conomic sales invoices only
"""
import logging
import json
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from collections import defaultdict
from app.services.economic_service import get_economic_service
from app.core.database import execute_query
logger = logging.getLogger(__name__)
class SubscriptionMatrixService:
"""Generate billing matrix for customer subscriptions"""
def __init__(self):
self.economic_service = get_economic_service()
async def generate_billing_matrix(
self,
customer_id: int,
months: int = 12
) -> Dict:
"""
Generate subscription billing matrix for a customer
Args:
customer_id: BMC Hub customer ID (local database)
months: Number of months to include (default 12)
Returns:
Dict with structure:
{
"customer_id": int,
"generated_at": str (ISO datetime),
"months_shown": int,
"products": [
{
"product_number": str,
"product_name": str,
"rows": [
{
"year_month": "2025-01",
"amount": float,
"status": "paid|invoiced|missing",
"invoice_number": str (optional),
"period_label": str,
"period_from": str (ISO date),
"period_to": str (ISO date)
}
],
"total_amount": float,
"total_paid": float
}
]
}
"""
try:
# Get customer's e-conomic number from local database
logger.info(f"🔍 [MATRIX] Starting matrix generation for customer {customer_id}")
customer = execute_query(
"SELECT economic_customer_number FROM customers WHERE id = %s",
(customer_id,)
)
logger.info(f"🔍 [MATRIX] Query result: {customer}")
if not customer or not customer[0].get('economic_customer_number'):
logger.warning(f"⚠️ Customer {customer_id} has no e-conomic number")
return {
"customer_id": customer_id,
"error": "No e-conomic customer number found",
"products": []
}
economic_customer_number = customer[0]['economic_customer_number']
logger.info(f"📊 Generating matrix for e-conomic customer {economic_customer_number}")
# Fetch invoices from e-conomic
logger.info(f"🔍 [MATRIX] About to call get_customer_invoices with customer {economic_customer_number}")
invoices = await self.economic_service.get_customer_invoices(
economic_customer_number,
include_lines=True
)
logger.info(f"🔍 [MATRIX] Returned {len(invoices)} invoices from e-conomic")
if not invoices:
logger.warning(f"⚠️ No invoices found for customer {economic_customer_number}")
return {
"customer_id": customer_id,
"generated_at": datetime.now().isoformat(),
"months_shown": months,
"products": []
}
logger.info(f"📊 Processing {len(invoices)} invoices")
# Debug: log first invoice structure
if invoices:
logger.debug(f"First invoice structure: {json.dumps(invoices[0], indent=2, default=str)[:500]}")
# Group invoices by product number
matrix_data = self._aggregate_by_product(invoices, months)
return {
"customer_id": customer_id,
"economic_customer_number": economic_customer_number,
"generated_at": datetime.now().isoformat(),
"months_shown": months,
"products": matrix_data
}
except Exception as e:
logger.error(f"❌ Error generating billing matrix: {e}")
import traceback
logger.error(traceback.format_exc())
return {
"customer_id": customer_id,
"error": str(e),
"products": []
}
def _aggregate_by_product(self, invoices: List[Dict], months: int) -> List[Dict]:
"""
Group invoice lines by product number and aggregate by month
Args:
invoices: List of e-conomic invoice objects
months: Number of months to include
Returns:
List of product records with aggregated monthly data
"""
# Structure: {product_number: {year_month: {amount, status, invoice_number, period_from, period_to}}}
product_matrix = defaultdict(dict)
product_names = {} # Cache product names
try:
for invoice in invoices:
invoice_number = invoice.get('bookedInvoiceNumber') or invoice.get('draftInvoiceNumber')
# Determine status based on invoice type/endpoint it came from
# Priority: use 'status' field, fallback to inferring from presence of certain fields
invoice_status = invoice.get('status', 'unknown')
if invoice_status == 'unknown':
# Infer status from invoice structure
if invoice.get('bookedInvoiceNumber'):
invoice_status = 'booked'
elif invoice.get('draftInvoiceNumber'):
invoice_status = 'draft'
invoice_date = invoice.get('date')
# Process invoice lines
lines = invoice.get('lines', [])
for line in lines:
product_number = line.get('product', {}).get('productNumber')
product_name = line.get('product', {}).get('name') or line.get('description')
if not product_number:
logger.debug(f"Skipping line without product number: {line}")
continue
# Cache product name
if product_number not in product_names:
product_names[product_number] = product_name or f"Product {product_number}"
# Extract period from line
period_from_str = line.get('period', {}).get('from')
period_to_str = line.get('period', {}).get('to')
# If no period on line, use invoice date as month
if not period_from_str:
if invoice_date:
period_from_str = invoice_date
# Assume monthly billing if no end date
period_from = datetime.fromisoformat(invoice_date.replace('Z', '+00:00'))
period_to = period_from + timedelta(days=31)
period_to_str = period_to.isoformat().split('T')[0]
else:
logger.warning(f"No period or date found for line in invoice {invoice_number}")
continue
# Parse dates
try:
period_from = datetime.fromisoformat(period_from_str.replace('Z', '+00:00'))
period_to = datetime.fromisoformat(period_to_str.replace('Z', '+00:00'))
except (ValueError, AttributeError) as e:
logger.warning(f"Could not parse dates {period_from_str} - {period_to_str}: {e}")
continue
# Get amount - use gross amount if net is 0 (line items may have gross only)
amount = float(line.get('netAmount', 0))
if amount == 0:
amount = float(line.get('grossAmount', 0))
if amount == 0:
# Try unit net price * quantity as fallback
unit_price = float(line.get('unitNetPrice', 0))
qty = float(line.get('quantity', 1))
amount = unit_price * qty
# Determine month key (use first month if multi-month)
year_month = period_from.strftime('%Y-%m')
# Calculate period label
period_label = self._format_period_label(period_from, period_to)
# Initialize cell if doesn't exist
if year_month not in product_matrix[product_number]:
product_matrix[product_number][year_month] = {
"amount": 0.0,
"status": "missing",
"invoice_number": None,
"period_from": None,
"period_to": None
}
# Update cell
cell = product_matrix[product_number][year_month]
cell["amount"] = amount # Take last amount (or sum if multiple?)
cell["status"] = self._determine_status(invoice_status)
cell["invoice_number"] = invoice_number
cell["period_from"] = period_from.isoformat().split('T')[0]
cell["period_to"] = period_to.isoformat().split('T')[0]
cell["period_label"] = period_label
except Exception as e:
logger.error(f"❌ Error aggregating data: {e}")
# Convert to output format
products = []
for product_number, months_data in sorted(product_matrix.items()):
rows = []
total_amount: float = 0.0
total_paid: float = 0.0
for year_month in sorted(months_data.keys()):
cell = months_data[year_month]
rows.append({
"year_month": year_month,
"amount": cell["amount"],
"status": cell["status"],
"invoice_number": cell["invoice_number"],
"period_label": cell["period_label"],
"period_from": cell["period_from"],
"period_to": cell["period_to"]
})
total_amount += cell["amount"]
if cell["status"] == "paid":
total_paid += cell["amount"]
products.append({
"product_number": product_number,
"product_name": product_names[product_number],
"rows": rows,
"total_amount": total_amount,
"total_paid": total_paid
})
return products
@staticmethod
def _format_period_label(period_from: datetime, period_to: datetime) -> str:
"""Format period as readable label (e.g., 'Jan', 'Jan-Mar', etc)"""
from_month = period_from.strftime('%b')
to_month = period_to.strftime('%b')
from_year = period_from.year
to_year = period_to.year
# Calculate number of months
months_diff = (period_to.year - period_from.year) * 12 + (period_to.month - period_from.month)
if months_diff == 0:
# Same month
return from_month
elif from_year == to_year:
# Same year, different months
return f"{from_month}-{to_month}"
else:
# Different years
return f"{from_month} {from_year} - {to_month} {to_year}"
@staticmethod
def _determine_status(invoice_status: str) -> str:
"""Map e-conomic invoice status to matrix status"""
status_map = {
"sent": "invoiced",
"paid": "paid",
"draft": "draft",
"credited": "credited",
"unpaid": "invoiced",
"booked": "invoiced"
}
result = status_map.get(invoice_status.lower() if invoice_status else 'unknown', invoice_status or 'unknown')
return result if result else "unknown"
# Singleton instance
_matrix_service_instance = None
def get_subscription_matrix_service() -> SubscriptionMatrixService:
"""Get singleton instance of SubscriptionMatrixService"""
global _matrix_service_instance
if _matrix_service_instance is None:
_matrix_service_instance = SubscriptionMatrixService()
return _matrix_service_instance

View File

@ -1,80 +0,0 @@
"""
Transcription Service
Handles communication with the external Whisper API for audio transcription.
"""
import logging
import aiohttp
import asyncio
from typing import Optional, Dict, Any, List
from pathlib import Path
import json
from app.core.config import settings
logger = logging.getLogger(__name__)
class TranscriptionService:
"""Service for transcribing audio files via external Whisper API"""
def __init__(self):
self.api_url = settings.WHISPER_API_URL
self.enabled = settings.WHISPER_ENABLED
self.timeout = settings.WHISPER_TIMEOUT
self.supported_formats = settings.WHISPER_SUPPORTED_FORMATS
async def transcribe_audio(self, filename: str, content: bytes) -> Optional[str]:
"""
Send audio content to Whisper API and return the transcript.
Args:
filename: Name of the file (used for format detection/logging)
content: Raw bytes of the audio file
Returns:
Transcribed text or None if failed
"""
if not self.enabled:
logger.debug("Whisper transcription is disabled in settings")
return None
# Basic extension check
ext = Path(filename).suffix.lower()
if ext not in self.supported_formats:
logger.debug(f"Skipping transcription for unsupported format: {filename}")
return None
logger.info(f"🎙️ Transcribing audio file: {filename} ({len(content)} bytes)")
try:
# Prepare the form data
# API expects: file=@filename
data = aiohttp.FormData()
data.add_field('file', content, filename=filename)
timeout = aiohttp.ClientTimeout(total=self.timeout)
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(self.api_url, data=data) as response:
if response.status != 200:
error_text = await response.text()
logger.error(f"❌ Whisper API error ({response.status}): {error_text}")
return None
result = await response.json()
# Expected format: {"results": [{"filename": "...", "transcript": "..."}]}
if 'results' in result and len(result['results']) > 0:
transcript = result['results'][0].get('transcript', '').strip()
logger.info(f"✅ Transcription successful for {filename}")
return transcript
else:
logger.warning(f"⚠️ Whisper API returned unexpected format: {result}")
return None
except asyncio.TimeoutError:
logger.error(f"❌ Whisper API timed out after {self.timeout} seconds for {filename}")
return None
except Exception as e:
logger.error(f"❌ Error during transcription of {filename}: {str(e)}")
return None

View File

@ -82,101 +82,6 @@ class VTigerService:
logger.error(f"❌ vTiger query error: {e}") logger.error(f"❌ vTiger query error: {e}")
return [] return []
async def get_account_by_id(self, account_id: str) -> Optional[Dict]:
"""
Fetch a single account by ID from vTiger
Args:
account_id: vTiger account ID (e.g., "3x760")
Returns:
Account record or None
"""
if not account_id:
logger.warning("⚠️ No account ID provided")
return None
try:
query = f"SELECT * FROM Accounts WHERE id='{account_id}' LIMIT 1;"
results = await self.query(query)
if results and len(results) > 0:
logger.info(f"✅ Found account {account_id}")
return results[0]
else:
logger.warning(f"⚠️ No account found with ID {account_id}")
return None
except Exception as e:
logger.error(f"❌ Error fetching account {account_id}: {e}")
return None
async def update_account(self, account_id: str, update_data: Dict) -> bool:
"""
Update an account in vTiger
Args:
account_id: vTiger account ID (e.g., "3x760")
update_data: Dictionary of fields to update
Returns:
True if successful, False otherwise
"""
if not self.rest_endpoint:
raise ValueError("VTIGER_URL not configured")
try:
# Fetch current account first - vTiger requires modifiedtime for updates
current_account = await self.get_account_by_id(account_id)
if not current_account:
logger.error(f"❌ Account {account_id} not found for update")
return False
auth = self._get_auth()
# Build payload with current data + updates
# Include essential fields for vTiger update validation
payload = {
'id': account_id,
'accountname': current_account.get('accountname'),
'assigned_user_id': current_account.get('assigned_user_id'),
'modifiedtime': current_account.get('modifiedtime'),
**update_data
}
logger.info(f"🔄 vTiger update payload: {payload}")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.rest_endpoint}/update",
json=payload,
auth=auth
) as response:
text = await response.text()
if response.status == 200:
import json
try:
data = json.loads(text)
if data.get('success'):
logger.info(f"✅ Updated vTiger account {account_id}")
return True
else:
logger.error(f"❌ vTiger update failed: {data.get('error')}")
logger.error(f"Full response: {text}")
return False
except json.JSONDecodeError:
logger.error(f"❌ Invalid JSON in update response: {text[:200]}")
return False
else:
logger.error(f"❌ vTiger update HTTP error {response.status}")
logger.error(f"Full response: {text[:2000]}")
return False
except Exception as e:
logger.error(f"❌ Error updating vTiger account {account_id}: {e}")
return False
async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]: async def get_customer_sales_orders(self, vtiger_account_id: str) -> List[Dict]:
""" """
Fetch sales orders for a customer from vTiger Fetch sales orders for a customer from vTiger

View File

@ -273,17 +273,18 @@ async def reset_user_password(user_id: int, new_password: str):
return {"message": "Password reset successfully"} return {"message": "Password reset successfully"}
# AI Prompts Management # AI Prompts Endpoint
@router.get("/ai-prompts", tags=["Settings"])
def _get_default_prompts(): async def get_ai_prompts():
"""Helper to get default system prompts""" """Get all AI prompts used in the system"""
from app.services.ollama_service import OllamaService from app.services.ollama_service import OllamaService
ollama_service = OllamaService() ollama_service = OllamaService()
return { prompts = {
"invoice_extraction": { "invoice_extraction": {
"name": "📄 Faktura Udtrækning (Invoice Parser)", "name": "Faktura Udtrækning (Invoice Extraction)",
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM. Håndterer danske nummerformater, datoer og linjegenkendelse.", "description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
"model": ollama_service.model, "model": ollama_service.model,
"endpoint": ollama_service.endpoint, "endpoint": ollama_service.endpoint,
"prompt": ollama_service._build_system_prompt(), "prompt": ollama_service._build_system_prompt(),
@ -292,209 +293,7 @@ def _get_default_prompts():
"top_p": 0.9, "top_p": 0.9,
"num_predict": 2000 "num_predict": 2000
} }
},
"ticket_classification": {
"name": "🎫 Ticket Klassificering (Auto-Triage)",
"description": "Klassificerer indkomne tickets baseret på emne og indhold. Tildeler kategori, prioritet og ansvarlig team.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en erfaren IT-supporter der skal klassificere indkomne support-sager.
Dine opgaver er:
1. Analyser emne og beskrivelse
2. Bestem Kategori: [Hardware, Software, Netværk, Adgang, Andet]
3. Bestem Prioritet: [Lav, Mellem, Høj, Kritisk]
4. Foreslå handlingsplan (kort punktform)
Output skal være gyldig JSON:
{
"category": "string",
"priority": "string",
"summary": "string",
"suggested_actions": ["string"]
}""",
"parameters": {
"temperature": 0.3,
"top_p": 0.95,
"num_predict": 1000
}
},
"ticket_summary": {
"name": "📝 Ticket Summering (Fakturagrundlag)",
"description": "Analyserer alle kommentarer og noter i en ticket for at lave et kort, præcist resumé til fakturaen eller kunden.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en administrativ assistent der skal gøre en it-sag klar til fakturering.
Opgave: Læs historikken igennem og skriv et kort resumé af det udførte arbejde.
- Fokusér løsningen, ikke problemet
- Brug professionelt sprog
- Undlad interne tekniske detaljer (medmindre relevant for faktura)
- Sprog: Dansk
- Længde: 2-3 sætninger
Input: [Liste af kommentarer]
Output: [Fakturastekst]""",
"parameters": {
"temperature": 0.2,
"top_p": 0.9,
"num_predict": 500
}
},
"kb_generation": {
"name": "📚 Vidensbank Generator (Solution to Article)",
"description": "Omdanner en løst ticket til en generel vejledning til vidensbanken.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er teknisk forfatter. Din opgave er at omskrive en konkret support-sag til en generel vejledning.
Regler:
1. Fjern alle kunde-specifikke data (navne, IP-adresser, passwords)
2. Strukturer som:
- Problem
- Årsag (hvis kendt)
- Løsning (Trin-for-trin guide)
3. Brug letforståeligt dansk
4. Formater med Markdown
Input: [Ticket Beskrivelse + Løsning]
Output: [Markdown Guide]""",
"parameters": {
"temperature": 0.4,
"top_p": 0.9,
"num_predict": 2000
}
},
"troubleshooting_assistant": {
"name": "🔧 Fejlsøgnings Copilot (Tech Helper)",
"description": "Fungerer som en senior-tekniker der giver sparring på en fejlbeskrivelse. Foreslår konkrete fejlsøgningstrin og kommandoer.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en Senior Systemadministrator med 20 års erfaring.
En junior-tekniker spørger om hjælp til et problem.
Din opgave:
1. Analyser symptomerne
2. List de 3 mest sandsynlige årsager
3. Foreslå en trin-for-trin fejlsøgningsplan (start med det mest sandsynlige)
4. Nævn relevante værktøjer eller kommandoer (Windows/Linux/Network)
Vær kortfattet, teknisk præcis og handlingsorienteret.
Sprog: Dansk (men engelske fagtermer er OK).
Input: [Fejlbeskrivelse]
Output: [Markdown Guide]""",
"parameters": {
"temperature": 0.3,
"top_p": 0.9,
"num_predict": 1500
}
},
"sentiment_analysis": {
"name": "🌡️ Sentiment Analyse (Kunde-Humør)",
"description": "Analyserer tonen i en kundehenvendelse for at vurdere hast, frustration og risiko. Bruges til prioritering.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Analyser tonen i følgende tekst fra en kunde.
Bestem følgende:
1. Sentiment: [Positiv, Neutral, Frustreret, Vred]
2. Hastegrad-indikatorer: Er der ord der indikerer panik eller kritisk hast?
3. Risikovurdering (0-10): Hvor stor risiko er der for at kunden forlader os? (10=Høj)
Returner resultatet som JSON format.
Input: [Kunde Tekst]
Output: { "sentiment": "...", "urgency": "...", "risk_score": 0 }""",
"parameters": {
"temperature": 0.1,
"top_p": 0.9,
"num_predict": 500
}
},
"meeting_action_items": {
"name": "📋 Mødenoter til Opgaver (Action Extraction)",
"description": "Scanner rå mødereferater eller notater og udtrækker konkrete 'Action Items', deadlines og ansvarlige personer.",
"model": ollama_service.model,
"endpoint": ollama_service.endpoint,
"prompt": """Du er en effektiv projektleder-assistent.
Din opgave er at scanne mødereferater og udtrække "Action Items".
For hver opgave skal du finde:
- Aktivitet (Hvad skal gøres?)
- Ansvarlig (Hvem?)
- Deadline (Hvornår? Hvis nævnt)
Ignorer løs snak og diskussioner. Fokusér kun beslutninger og opgaver der skal udføres.
Outputtet skal være en punktopstilling.
Input: [Mødenoter]
Output: [Liste af opgaver]""",
"parameters": {
"temperature": 0.2,
"top_p": 0.9,
"num_predict": 1000
} }
} }
}
@router.get("/ai-prompts", tags=["Settings"])
async def get_ai_prompts():
"""Get all AI prompts (defaults merged with custom overrides)"""
prompts = _get_default_prompts()
try:
# Check for custom overrides in DB
# Note: Table ai_prompts must rely on migration 066
rows = execute_query("SELECT key, prompt_text FROM ai_prompts")
if rows:
for row in rows:
if row['key'] in prompts:
prompts[row['key']]['prompt'] = row['prompt_text']
prompts[row['key']]['is_custom'] = True
except Exception as e:
logger.warning(f"Could not load custom ai prompts: {e}")
return prompts return prompts
class PromptUpdate(BaseModel):
prompt_text: str
@router.put("/ai-prompts/{key}", tags=["Settings"])
async def update_ai_prompt(key: str, update: PromptUpdate):
"""Override a system prompt with a custom one"""
defaults = _get_default_prompts()
if key not in defaults:
raise HTTPException(status_code=404, detail="Unknown prompt key")
try:
# Upsert
query = """
INSERT INTO ai_prompts (key, prompt_text, updated_at)
VALUES (%s, %s, CURRENT_TIMESTAMP)
ON CONFLICT (key)
DO UPDATE SET prompt_text = EXCLUDED.prompt_text, updated_at = CURRENT_TIMESTAMP
RETURNING key
"""
execute_query(query, (key, update.prompt_text))
return {"message": "Prompt updated", "key": key}
except Exception as e:
logger.error(f"Error saving prompt: {e}")
raise HTTPException(status_code=500, detail="Could not save prompt")
@router.delete("/ai-prompts/{key}", tags=["Settings"])
async def reset_ai_prompt(key: str):
"""Reset a prompt to its system default"""
try:
execute_query("DELETE FROM ai_prompts WHERE key = %s", (key,))
return {"message": "Prompt reset to default"}
except Exception as e:
logger.error(f"Error resetting prompt: {e}")
raise HTTPException(status_code=500, detail="Could not reset prompt")

View File

@ -980,92 +980,41 @@ async function loadAIPrompts() {
const prompts = await response.json(); const prompts = await response.json();
const container = document.getElementById('aiPromptsContent'); const container = document.getElementById('aiPromptsContent');
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
const accordionHtml = ` <div class="card mb-4">
<div class="accordion" id="aiPromptsAccordion"> <div class="card-header bg-light">
${Object.entries(prompts).map(([key, prompt], index) => ` <div class="d-flex justify-content-between align-items-center">
<div class="accordion-item"> <div>
<h2 class="accordion-header" id="heading_${key}"> <h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
<button class="accordion-button ${index !== 0 ? 'collapsed' : ''}" type="button" <small class="text-muted">${escapeHtml(prompt.description)}</small>
data-bs-toggle="collapse" data-bs-target="#collapse_${key}"
aria-expanded="${index === 0 ? 'true' : 'false'}" aria-controls="collapse_${key}">
<div class="d-flex w-100 justify-content-between align-items-center pe-3">
<div class="d-flex flex-column align-items-start">
<span class="fw-bold">
${escapeHtml(prompt.name)}
${prompt.is_custom ? '<span class="badge bg-warning text-dark ms-2" style="font-size: 0.65rem;">Ændret</span>' : ''}
</span>
<small class="text-muted fw-normal">${escapeHtml(prompt.description)}</small>
</div>
</div> </div>
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
<i class="bi bi-clipboard me-1"></i>Kopier
</button> </button>
</h2> </div>
<div id="collapse_${key}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}" </div>
aria-labelledby="heading_${key}" data-bs-parent="#aiPromptsAccordion"> <div class="card-body">
<div class="accordion-body bg-light">
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 border-0 shadow-sm"> <small class="text-muted">Model:</small>
<div class="card-body py-2"> <div><code>${escapeHtml(prompt.model)}</code></div>
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Model</small>
<div class="font-monospace text-primary">${escapeHtml(prompt.model)}</div>
</div>
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 border-0 shadow-sm"> <small class="text-muted">Endpoint:</small>
<div class="card-body py-2"> <div><code>${escapeHtml(prompt.endpoint)}</code></div>
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Endpoint</small>
<div class="font-monospace text-truncate" title="${escapeHtml(prompt.endpoint)}">${escapeHtml(prompt.endpoint)}</div>
</div>
</div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 border-0 shadow-sm"> <small class="text-muted">Parametre:</small>
<div class="card-body py-2"> <div><code>${JSON.stringify(prompt.parameters)}</code></div>
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Parametre</small> </div>
<div class="font-monospace small text-truncate" title='${JSON.stringify(prompt.parameters)}'>${JSON.stringify(prompt.parameters)}</div> </div>
<div>
<small class="text-muted fw-bold d-block mb-2">System Prompt:</small>
<pre id="prompt_${key}" class="border rounded p-3 bg-light" style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap;">${escapeHtml(prompt.prompt)}</pre>
</div> </div>
</div> </div>
</div> </div>
</div> `).join('');
<div class="card border-0 shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center py-2">
<span class="fw-bold small text-uppercase text-muted"><i class="bi bi-terminal me-2"></i>System Client Prompt</span>
<div class="btn-group btn-group-sm">
${prompt.is_custom ? `
<button class="btn btn-outline-danger" onclick="resetPrompt('${key}')" title="Nulstil til standard">
<i class="bi bi-arrow-counterclockwise"></i> Nulstil
</button>` : ''}
<button class="btn btn-outline-primary" onclick="editPrompt('${key}')" id="editBtn_${key}" title="Rediger Prompt">
<i class="bi bi-pencil"></i> Rediger
</button>
<button class="btn btn-outline-secondary" onclick="copyPrompt('${key}')" title="Kopier til udklipsholder">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
<div class="card-body p-0 position-relative">
<pre id="prompt_${key}" class="m-0 p-3 bg-dark text-light rounded-bottom"
style="max-height: 400px; overflow-y: auto; font-size: 0.85rem; white-space: pre-wrap; border-radius: 0;">${escapeHtml(prompt.prompt)}</pre>
<textarea id="edit_prompt_${key}" class="form-control d-none p-3 bg-white text-dark rounded-bottom"
style="height: 300px; font-family: monospace; font-size: 0.85rem; border-radius: 0;">${escapeHtml(prompt.prompt)}</textarea>
<div id="editActions_${key}" class="position-absolute bottom-0 end-0 p-3 d-none">
<button class="btn btn-sm btn-secondary me-1" onclick="cancelEdit('${key}')">Annuller</button>
<button class="btn btn-sm btn-success" onclick="savePrompt('${key}')"><i class="bi bi-check-lg"></i> Gem</button>
</div>
</div>
</div>
</div>
</div>
</div>
`).join('')}
</div>
`;
container.innerHTML = accordionHtml;
} catch (error) { } catch (error) {
console.error('Error loading AI prompts:', error); console.error('Error loading AI prompts:', error);
@ -1074,79 +1023,6 @@ async function loadAIPrompts() {
} }
} }
function editPrompt(key) {
document.getElementById(`prompt_${key}`).classList.add('d-none');
document.getElementById(`edit_prompt_${key}`).classList.remove('d-none');
document.getElementById(`editActions_${key}`).classList.remove('d-none');
document.getElementById(`editBtn_${key}`).disabled = true;
}
function cancelEdit(key) {
document.getElementById(`prompt_${key}`).classList.remove('d-none');
document.getElementById(`edit_prompt_${key}`).classList.add('d-none');
document.getElementById(`editActions_${key}`).classList.add('d-none');
document.getElementById(`editBtn_${key}`).disabled = false;
// Reset value
document.getElementById(`edit_prompt_${key}`).value = document.getElementById(`prompt_${key}`).textContent;
}
async function savePrompt(key) {
const newText = document.getElementById(`edit_prompt_${key}`).value;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_text: newText })
});
if (!response.ok) throw new Error('Failed to update prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error saving prompt:', error);
alert('Kunne ikke gemme prompt');
}
}
async function resetPrompt(key) {
if (!confirm('Er du sikker på at du vil nulstille denne prompt til standard?')) return;
try {
const response = await fetch(`/api/v1/ai-prompts/${key}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to reset prompt');
// Reload to show update
await loadAIPrompts();
// Re-open accordion
setTimeout(() => {
const collapse = document.getElementById(`collapse_${key}`);
if (collapse) {
new bootstrap.Collapse(collapse, { toggle: false }).show();
}
}, 100);
} catch (error) {
console.error('Error resetting prompt:', error);
alert('Kunne ikke nulstille prompt');
}
}
function copyPrompt(key) { function copyPrompt(key) {
const promptElement = document.getElementById(`prompt_${key}`); const promptElement = document.getElementById(`prompt_${key}`);
const text = promptElement.textContent; const text = promptElement.textContent;

View File

@ -234,7 +234,6 @@
<li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/ticket/dashboard"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li> <li><a class="dropdown-item py-2" href="/ticket/tickets"><i class="bi bi-ticket-detailed me-2"></i>Alle Tickets</a></li>
<li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li> <li><a class="dropdown-item py-2" href="/ticket/worklog/review"><i class="bi bi-clock-history me-2"></i>Godkend Worklog</a></li>
<li><a class="dropdown-item py-2" href="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li> <li><a class="dropdown-item py-2" href="#">Ny Ticket</a></li>
<li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li> <li><a class="dropdown-item py-2" href="/prepaid-cards"><i class="bi bi-credit-card-2-front me-2"></i>Prepaid Cards</a></li>
@ -251,8 +250,6 @@
<li><a class="dropdown-item py-2" href="#">Ordre</a></li> <li><a class="dropdown-item py-2" href="#">Ordre</a></li>
<li><a class="dropdown-item py-2" href="#">Produkter</a></li> <li><a class="dropdown-item py-2" href="#">Produkter</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="/webshop"><i class="bi bi-shop me-2"></i>Webshop Administration</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item py-2" href="#">Pipeline</a></li> <li><a class="dropdown-item py-2" href="#">Pipeline</a></li>
</ul> </ul>
</li> </li>
@ -262,7 +259,7 @@
</a> </a>
<ul class="dropdown-menu mt-2"> <ul class="dropdown-menu mt-2">
<li><a class="dropdown-item py-2" href="#">Fakturaer</a></li> <li><a class="dropdown-item py-2" href="#">Fakturaer</a></li>
<li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Leverandør fakturaer</a></li> <li><a class="dropdown-item py-2" href="/billing/supplier-invoices"><i class="bi bi-receipt me-2"></i>Kassekladde</a></li>
<li><a class="dropdown-item py-2" href="#">Abonnementer</a></li> <li><a class="dropdown-item py-2" href="#">Abonnementer</a></li>
<li><a class="dropdown-item py-2" href="#">Betalinger</a></li> <li><a class="dropdown-item py-2" href="#">Betalinger</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
@ -273,7 +270,6 @@
</a> </a>
<ul class="dropdown-menu" data-submenu="timetracking"> <ul class="dropdown-menu" data-submenu="timetracking">
<li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li> <li><a class="dropdown-item py-2" href="/timetracking"><i class="bi bi-speedometer2 me-2"></i>Dashboard</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/registrations"><i class="bi bi-list-columns-reverse me-2"></i>Registreringer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/wizard"><i class="bi bi-magic me-2"></i>Godkend Timer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li> <li><a class="dropdown-item py-2" href="/timetracking/orders"><i class="bi bi-receipt me-2"></i>Ordrer</a></li>
<li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li> <li><a class="dropdown-item py-2" href="/timetracking/customers"><i class="bi bi-people me-2"></i>Kunder</a></li>

View File

@ -4,8 +4,7 @@ Klippekort (Prepaid Time Card) Service
Business logic for prepaid time cards: purchase, balance, deduction. Business logic for prepaid time cards: purchase, balance, deduction.
NOTE: As of migration 065, customers can have multiple active cards simultaneously. CONSTRAINT: Only 1 active card per customer (enforced by database UNIQUE index).
When multiple active cards exist, operations default to the card with earliest expiry.
""" """
import logging import logging
@ -39,7 +38,8 @@ class KlippekortService:
""" """
Purchase a new prepaid card Purchase a new prepaid card
Note: As of migration 065, customers can have multiple active cards simultaneously. CONSTRAINT: Only 1 active card allowed per customer.
This will fail if customer already has an active card.
Args: Args:
card_data: Card purchase data card_data: Card purchase data
@ -47,9 +47,26 @@ class KlippekortService:
Returns: Returns:
Created card dict Created card dict
Raises:
ValueError: If customer already has active card
""" """
from psycopg2.extras import Json from psycopg2.extras import Json
# Check if customer already has an active card
existing = execute_query_single(
"""
SELECT id, card_number FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
(card_data.customer_id,))
if existing:
raise ValueError(
f"Customer {card_data.customer_id} already has an active card: {existing['card_number']}. "
"Please deactivate or deplete the existing card before purchasing a new one."
)
logger.info(f"💳 Purchasing prepaid card for customer {card_data.customer_id}: {card_data.purchased_hours}h") logger.info(f"💳 Purchasing prepaid card for customer {card_data.customer_id}: {card_data.purchased_hours}h")
# Insert card (trigger will auto-generate card_number if NULL) # Insert card (trigger will auto-generate card_number if NULL)
@ -115,31 +132,19 @@ class KlippekortService:
"SELECT * FROM tticket_prepaid_balances WHERE id = %s", "SELECT * FROM tticket_prepaid_balances WHERE id = %s",
(card_id,)) (card_id,))
@staticmethod
def get_active_cards_for_customer(customer_id: int) -> List[Dict[str, Any]]:
"""
Get all active prepaid cards for customer (sorted by expiry)
Returns empty list if no active cards exist.
"""
cards = execute_query(
"""
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC
""",
(customer_id,))
return cards or []
@staticmethod @staticmethod
def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]: def get_active_card_for_customer(customer_id: int) -> Optional[Dict[str, Any]]:
""" """
Get active prepaid card for customer (defaults to earliest expiry if multiple) Get active prepaid card for customer
Returns None if no active card exists. Returns None if no active card exists.
""" """
cards = KlippekortService.get_active_cards_for_customer(customer_id) return execute_query_single(
return cards[0] if cards else None """
SELECT * FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
""",
(customer_id,))
@staticmethod @staticmethod
def check_balance(customer_id: int) -> Dict[str, Any]: def check_balance(customer_id: int) -> Dict[str, Any]:

View File

@ -60,7 +60,6 @@ class BillingMethod(str, Enum):
INVOICE = "invoice" INVOICE = "invoice"
INTERNAL = "internal" INTERNAL = "internal"
WARRANTY = "warranty" WARRANTY = "warranty"
UNKNOWN = "unknown"
class WorklogStatus(str, Enum): class WorklogStatus(str, Enum):
@ -89,14 +88,6 @@ class TransactionType(str, Enum):
CANCELLATION = "cancellation" CANCELLATION = "cancellation"
class TicketType(str, Enum):
"""Ticket kategorisering"""
INCIDENT = "incident" # Fejl der skal fixes (Høj urgens)
REQUEST = "request" # Bestilling / Ønske (Planlægges)
PROBLEM = "problem" # Root cause (Fejlfinding)
PROJECT = "project" # Større projektarbejde
# ============================================================================ # ============================================================================
# TICKET MODELS # TICKET MODELS
# ============================================================================ # ============================================================================
@ -107,8 +98,6 @@ class TTicketBase(BaseModel):
description: Optional[str] = None description: Optional[str] = None
status: TicketStatus = Field(default=TicketStatus.OPEN) status: TicketStatus = Field(default=TicketStatus.OPEN)
priority: TicketPriority = Field(default=TicketPriority.NORMAL) priority: TicketPriority = Field(default=TicketPriority.NORMAL)
ticket_type: TicketType = Field(default=TicketType.INCIDENT, description="Type af sag")
internal_note: Optional[str] = Field(default=None, description="Intern note der vises prominent til medarbejdere")
category: Optional[str] = Field(None, max_length=100) category: Optional[str] = Field(None, max_length=100)
customer_id: Optional[int] = Field(None, description="Reference til customers.id") customer_id: Optional[int] = Field(None, description="Reference til customers.id")
contact_id: Optional[int] = Field(None, description="Reference til contacts.id") contact_id: Optional[int] = Field(None, description="Reference til contacts.id")
@ -234,7 +223,6 @@ class TTicketWorklogBase(BaseModel):
work_type: WorkType = Field(default=WorkType.SUPPORT) work_type: WorkType = Field(default=WorkType.SUPPORT)
description: Optional[str] = None description: Optional[str] = None
billing_method: BillingMethod = Field(default=BillingMethod.INVOICE) billing_method: BillingMethod = Field(default=BillingMethod.INVOICE)
is_internal: bool = Field(default=False, description="Skjul for kunde (vises ikke på faktura/portal)")
@field_validator('hours') @field_validator('hours')
@classmethod @classmethod
@ -263,7 +251,6 @@ class TTicketWorklogUpdate(BaseModel):
billing_method: Optional[BillingMethod] = None billing_method: Optional[BillingMethod] = None
status: Optional[WorklogStatus] = None status: Optional[WorklogStatus] = None
prepaid_card_id: Optional[int] = None prepaid_card_id: Optional[int] = None
is_internal: Optional[bool] = None
class TTicketWorklog(TTicketWorklogBase): class TTicketWorklog(TTicketWorklogBase):

View File

@ -514,61 +514,15 @@ async def create_worklog(
Create worklog entry for ticket Create worklog entry for ticket
Creates time entry in draft status. Creates time entry in draft status.
If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active.
""" """
try: try:
from psycopg2.extras import Json from psycopg2.extras import Json
# Handle prepaid card selection/validation
prepaid_card_id = worklog_data.prepaid_card_id
if worklog_data.billing_method.value == 'prepaid_card':
# Get customer_id from ticket
ticket = execute_query_single(
"SELECT customer_id FROM tticket_tickets WHERE id = %s",
(ticket_id,))
if not ticket:
raise HTTPException(status_code=404, detail="Ticket not found")
customer_id = ticket['customer_id']
# Get active prepaid cards for customer
active_cards = execute_query(
"""SELECT id, remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
(customer_id,))
if not active_cards:
raise HTTPException(
status_code=400,
detail="Kunden har ingen aktive klippekort")
if len(active_cards) == 1:
# Auto-select if only 1 active
if not prepaid_card_id:
prepaid_card_id = active_cards[0]['id']
logger.info(f"🎫 Auto-selected prepaid card {prepaid_card_id} (only active card)")
else:
# Multiple active cards: require explicit selection
if not prepaid_card_id:
raise HTTPException(
status_code=400,
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
# Validate selected card is active and belongs to customer
selected = next((c for c in active_cards if c['id'] == prepaid_card_id), None)
if not selected:
raise HTTPException(
status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
worklog_id = execute_insert( worklog_id = execute_insert(
""" """
INSERT INTO tticket_worklog INSERT INTO tticket_worklog
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal) (ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", """,
( (
ticket_id, ticket_id,
@ -579,8 +533,7 @@ async def create_worklog(
worklog_data.billing_method.value, worklog_data.billing_method.value,
'draft', 'draft',
user_id or worklog_data.user_id, user_id or worklog_data.user_id,
prepaid_card_id, worklog_data.prepaid_card_id
worklog_data.is_internal
) )
) )
@ -591,14 +544,10 @@ async def create_worklog(
entity_id=worklog_id, entity_id=worklog_id,
user_id=user_id, user_id=user_id,
action="created", action="created",
details={ details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
"hours": float(worklog_data.hours),
"work_type": worklog_data.work_type.value,
"is_internal": worklog_data.is_internal
}
) )
worklog = execute_query_single( worklog = execute_query(
"SELECT * FROM tticket_worklog WHERE id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,)) (worklog_id,))
@ -620,12 +569,11 @@ async def update_worklog(
Update worklog entry (partial update) Update worklog entry (partial update)
Only draft entries can be fully edited. Only draft entries can be fully edited.
If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active.
""" """
try: try:
# Get current worklog # Get current worklog
current = execute_query_single( current = execute_query_single(
"SELECT w.*, t.customer_id FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.id = %s", "SELECT * FROM tticket_worklog WHERE id = %s",
(worklog_id,)) (worklog_id,))
if not current: if not current:
@ -637,43 +585,6 @@ async def update_worklog(
update_dict = update_data.model_dump(exclude_unset=True) update_dict = update_data.model_dump(exclude_unset=True)
# Handle prepaid card selection/validation if billing_method is being set to prepaid_card
if 'billing_method' in update_dict and update_dict['billing_method'] == 'prepaid_card':
customer_id = current['customer_id']
# Get active prepaid cards for customer
active_cards = execute_query(
"""SELECT id, remaining_hours, expires_at
FROM tticket_prepaid_cards
WHERE customer_id = %s AND status = 'active'
ORDER BY expires_at ASC NULLS LAST, created_at ASC""",
(customer_id,))
if not active_cards:
raise HTTPException(
status_code=400,
detail="Kunden har ingen aktive klippekort")
if len(active_cards) == 1:
# Auto-select if only 1 active and not explicitly provided
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
update_dict['prepaid_card_id'] = active_cards[0]['id']
logger.info(f"🎫 Auto-selected prepaid card {update_dict['prepaid_card_id']} (only active card)")
else:
# Multiple active cards: require explicit selection
if 'prepaid_card_id' not in update_dict or not update_dict['prepaid_card_id']:
raise HTTPException(
status_code=400,
detail=f"Kunden har {len(active_cards)} aktive klippekort. Vælg et konkret kort.")
# Validate selected card if provided
if 'prepaid_card_id' in update_dict and update_dict['prepaid_card_id']:
selected = next((c for c in active_cards if c['id'] == update_dict['prepaid_card_id']), None)
if not selected:
raise HTTPException(
status_code=400,
detail="Valgt klippekort er ikke aktivt eller tilhører ikke kunden")
for field, value in update_dict.items(): for field, value in update_dict.items():
if hasattr(value, 'value'): if hasattr(value, 'value'):
value = value.value value = value.value

View File

@ -89,8 +89,8 @@ class TicketService:
INSERT INTO tticket_tickets ( INSERT INTO tticket_tickets (
ticket_number, subject, description, status, priority, category, ticket_number, subject, description, status, priority, category,
customer_id, contact_id, assigned_to_user_id, created_by_user_id, customer_id, contact_id, assigned_to_user_id, created_by_user_id,
source, tags, custom_fields, ticket_type, internal_note source, tags, custom_fields
) VALUES (%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
""", """,
( (
@ -106,9 +106,7 @@ class TicketService:
user_id or ticket_data.created_by_user_id, user_id or ticket_data.created_by_user_id,
ticket_data.source.value, ticket_data.source.value,
ticket_data.tags or [], # PostgreSQL array ticket_data.tags or [], # PostgreSQL array
Json(ticket_data.custom_fields or {}), # PostgreSQL JSONB Json(ticket_data.custom_fields or {}) # PostgreSQL JSONB
ticket_data.ticket_type.value,
ticket_data.internal_note
) )
) )

View File

@ -5,17 +5,13 @@
{% block extra_css %} {% block extra_css %}
<style> <style>
.ticket-header { .ticket-header {
background: white; background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
padding: 2rem; padding: 2rem;
border-radius: var(--border-radius); border-radius: var(--border-radius);
color: white;
margin-bottom: 2rem; margin-bottom: 2rem;
border-left: 6px solid var(--accent);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
} }
.ticket-header.priority-urgent { border-left-color: #dc3545; }
.ticket-header.priority-high { border-left-color: #fd7e14; }
.ticket-number { .ticket-number {
font-family: 'Monaco', 'Courier New', monospace; font-family: 'Monaco', 'Courier New', monospace;
font-size: 1rem; font-size: 1rem;
@ -292,69 +288,41 @@
{% block content %} {% block content %}
<div class="container-fluid px-4"> <div class="container-fluid px-4">
<!-- Ticket Header --> <!-- Ticket Header -->
<div class="ticket-header priority-{{ ticket.priority }}"> <div class="ticket-header">
<div class="d-flex justify-content-between align-items-start"> <div class="ticket-number">{{ ticket.ticket_number }}</div>
<div> <div class="ticket-title">{{ ticket.subject }}</div>
<div class="d-flex align-items-center gap-2 mb-2 text-muted small"> <div class="mt-3">
<span class="ticket-number font-monospace">{{ ticket.ticket_number }}</span> <span class="badge badge-status-{{ ticket.status }}">
<span></span> {{ ticket.status.replace('_', ' ').title() }}
<span class="fw-bold text-uppercase" style="letter-spacing: 0.5px;">{{ ticket.ticket_type|default('Incident') }}</span>
<!-- SLA Timer Mockup -->
<span class="badge bg-light text-danger border border-danger ms-2">
<i class="bi bi-hourglass-split"></i> Deadline: 14:00
</span> </span>
</div>
<div class="d-flex flex-wrap align-items-baseline gap-3">
<h1 class="ticket-title mt-0 text-dark mb-0">{{ ticket.subject }}</h1>
<h3 class="h4 text-muted fw-normal mb-0">
<a href="/customers/{{ ticket.customer_id }}" class="text-decoration-none text-muted hover-primary">
@ {{ ticket.customer_name }}
</a>
</h3>
</div>
</div>
<!-- Quick Status -->
<div class="d-flex align-items-center gap-2">
<select class="form-select" style="width: auto; font-weight: 500;"
onchange="updateStatus(this.value)" id="quickStatus">
<option value="open" {% if ticket.status == 'open' %}selected{% endif %}>Åben</option>
<option value="in_progress" {% if ticket.status == 'in_progress' %}selected{% endif %}>Igangværende</option>
<option value="waiting_customer" {% if ticket.status == 'waiting_customer' %}selected{% endif %}>Afventer Kunde</option>
<option value="waiting_internal" {% if ticket.status == 'waiting_internal' %}selected{% endif %}>Afventer Internt</option>
<option value="resolved" {% if ticket.status == 'resolved' %}selected{% endif %}>Løst</option>
<option value="closed" {% if ticket.status == 'closed' %}selected{% endif %}>Lukket</option>
</select>
</div>
</div>
<div class="mt-3 d-flex gap-2 align-items-center flex-wrap">
<!-- Priority Badge -->
<span class="badge badge-priority-{{ ticket.priority }}"> <span class="badge badge-priority-{{ ticket.priority }}">
{{ ticket.priority.title() }} Priority {{ ticket.priority.title() }} Priority
</span> </span>
</div>
<!-- Tags --> <div class="tags-container" id="ticketTags">
<div class="tags-container d-inline-flex m-0" id="ticketTags"></div> <!-- Tags loaded via JavaScript -->
<button class="btn btn-sm btn-light text-muted" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)"> </div>
<i class="bi bi-plus-circle"></i> <button class="add-tag-btn mt-2" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
<i class="bi bi-plus-circle"></i> Tilføj Tag (⌥⇧T)
</button> </button>
</div> </div>
<!-- Internal Note Alert --> <!-- Action Buttons -->
{% if ticket.internal_note %} <div class="action-buttons mb-4">
<div class="alert alert-warning mt-3 mb-0 d-flex align-items-start border-warning" style="background-color: #fff3cd;"> <a href="/api/v1/ticket/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
<i class="bi bi-shield-lock-fill me-2 fs-5 text-warning"></i> <i class="bi bi-pencil"></i> Rediger
<div> </a>
<strong><i class="bi bi-eye-slash"></i> Internt Notat:</strong> <button class="btn btn-outline-secondary" onclick="addComment()">
<span style="white-space: pre-wrap;">{{ ticket.internal_note }}</span> <i class="bi bi-chat"></i> Tilføj Kommentar
</div> </button>
</div> <button class="btn btn-outline-secondary" onclick="addWorklog()">
{% endif %} <i class="bi bi-clock"></i> Log Tid
</button>
<a href="/ticket/tickets" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div> </div>
<!-- Action Buttons Removed (Moved to specific sections) -->
<div class="row"> <div class="row">
<!-- Main Content --> <!-- Main Content -->
<div class="col-lg-8"> <div class="col-lg-8">
@ -376,23 +344,6 @@
<div class="section-title"> <div class="section-title">
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }}) <i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
</div> </div>
<!-- Quick Comment Input -->
<div class="mb-4 p-3 bg-light rounded-3 border">
<textarea id="quickCommentText" class="form-control border-0 bg-white mb-2 shadow-sm" rows="2" placeholder="Skriv en kommentar... (Ctrl+Enter for at sende)"></textarea>
<div class="d-flex justify-content-between align-items-center">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="quickCommentInternal">
<label class="form-check-label small text-muted fw-bold" for="quickCommentInternal">
<i class="bi bi-shield-lock-fill text-warning"></i> Internt Notat
</label>
</div>
<button class="btn btn-primary btn-sm px-4 rounded-pill" onclick="submitQuickComment()">
Send <i class="bi bi-send-fill ms-1"></i>
</button>
</div>
</div>
{% if comments %} {% if comments %}
{% for comment in comments %} {% for comment in comments %}
<div class="comment {% if comment.internal_note %}internal{% endif %}"> <div class="comment {% if comment.internal_note %}internal{% endif %}">
@ -425,14 +376,8 @@
<!-- Worklog --> <!-- Worklog -->
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="section-title">
<div class="section-title mb-0"> <i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
<i class="bi bi-clock-history"></i> Worklog
<span class="badge bg-light text-dark border ms-2" id="totalHoursBadge">...</span>
</div>
<button class="btn btn-primary btn-sm rounded-pill" onclick="showWorklogModal()">
<i class="bi bi-plus-lg"></i> Log Tid
</button>
</div> </div>
{% if worklog %} {% if worklog %}
<div class="table-responsive"> <div class="table-responsive">
@ -495,32 +440,38 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="col-lg-4"> <div class="col-lg-4">
<!-- Metadata Card (Consolidated) --> <!-- Ticket Info -->
<div class="card mb-3"> <div class="card">
<div class="card-header bg-white py-3 border-bottom"> <div class="card-body">
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-info-circle me-2"></i>Detaljer</h6> <div class="section-title">
</div> <i class="bi bi-info-circle"></i> Ticket Information
<div class="list-group list-group-flush small">
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Ansvarlig</span>
<span class="badge bg-light text-dark border">{{ ticket.assigned_to_name or 'Ubesat' }}</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Oprettet</span>
<span class="font-monospace">{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}</span>
</div>
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3">
<span class="text-muted">Opdateret</span>
<span class="font-monospace">{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}</span>
</div>
{% if ticket.resolved_at %}
<div class="list-group-item d-flex justify-content-between align-items-center px-3 py-3 bg-light">
<span class="text-success fw-bold">Løst</span>
<span class="font-monospace">{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}</span>
</div> </div>
<div class="info-item mb-3">
<label>Kunde</label>
<div class="value">
{% if ticket.customer_name %}
<a href="/customers/{{ ticket.customer_id }}" style="text-decoration: none; color: var(--accent);">
{{ ticket.customer_name }}
</a>
{% else %}
<span class="text-muted">Ikke angivet</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div class="info-item mb-3">
<label>Tildelt til</label>
<div class="value">
{{ ticket.assigned_to_name or 'Ikke tildelt' }}
</div>
</div>
<div class="info-item mb-3">
<label>Oprettet</label>
<div class="value">
{{ ticket.created_at.strftime('%d-%m-%Y %H:%M') if ticket.created_at else '-' }}
</div>
</div>
</div>
</div>
<!-- Contacts --> <!-- Contacts -->
<div class="card"> <div class="card">
@ -539,9 +490,47 @@
</div> </div>
</div> </div>
<!-- Back to Ticket Info continuation -->
<div class="card">
<div class="card-body">
<div class="info-item mb-3">
<label>Senest opdateret</label>
<div class="value">
{{ ticket.updated_at.strftime('%d-%m-%Y %H:%M') if ticket.updated_at else '-' }}
</div>
</div>
{% if ticket.resolved_at %}
<div class="info-item mb-3">
<label>Løst</label>
<div class="value">
{{ ticket.resolved_at.strftime('%d-%m-%Y %H:%M') }}
</div>
</div>
{% endif %}
{% if ticket.first_response_at %}
<div class="info-item mb-3">
<label>Første svar</label>
<div class="value">
{{ ticket.first_response_at.strftime('%d-%m-%Y %H:%M') }}
</div>
</div>
{% endif %}
</div>
</div>
<!-- Tags -->
{% if ticket.tags %}
<div class="card">
<div class="card-body">
<div class="section-title">
<i class="bi bi-tags"></i> Tags
</div>
{% for tag in ticket.tags %}
<span class="badge bg-secondary me-1 mb-1">#{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@ -549,262 +538,15 @@
{% block extra_js %} {% block extra_js %}
<script> <script>
// ============================================ // Add comment (placeholder - integrate with API)
// QUICK COMMENT & STATUS function addComment() {
// ============================================ alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
async function updateStatus(newStatus) {
try {
// Determine API endpoint and method
// Using generic update for now, ideally use specific status endpoint if workflow requires
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
if (!response.ok) throw new Error('Failed to update status');
// Show success feedback
const select = document.getElementById('quickStatus');
// Optional: Flash success or reload.
// Reload is safer to update all timestamps and UI states
window.location.reload();
} catch (error) {
console.error('Error updating status:', error);
alert('Fejl ved opdatering af status');
// Revert select if possible
window.location.reload();
}
} }
async function submitQuickComment() { // Add worklog (placeholder - integrate with API)
const textarea = document.getElementById('quickCommentText'); function addWorklog() {
const internalCheck = document.getElementById('quickCommentInternal'); alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
const text = textarea.value.trim();
const isInternal = internalCheck.checked;
if (!text) return;
try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
comment_text: text,
is_internal: isInternal,
ticket_id: {{ ticket.id }}
})
});
if (!response.ok) throw new Error('Failed to post comment');
// Clear input
textarea.value = '';
// Reload page to show new comment (simpler than DOM manipulation for complex layouts)
window.location.reload();
} catch (error) {
console.error('Error posting comment:', error);
alert('Kunne ikke sende kommentar');
} }
}
// Handle Ctrl+Enter in comment box
document.getElementById('quickCommentText')?.addEventListener('keydown', function(e) {
if (e.ctrlKey && e.key === 'Enter') {
submitQuickComment();
}
});
// ============================================
// WORKLOG MANAGEMENT
// ============================================
async function showWorklogModal() {
const today = new Date().toISOString().split('T')[0];
// Fetch Prepaid Cards for this customer
let prepaidOptions = '';
let activePrepaidCards = [];
try {
const response = await fetch('/api/v1/prepaid-cards?status=active&customer_id={{ ticket.customer_id }}');
if (response.ok) {
const cards = await response.json();
activePrepaidCards = cards || [];
if (activePrepaidCards.length > 0) {
const cardOpts = activePrepaidCards.map(c => {
const remaining = parseFloat(c.remaining_hours).toFixed(2);
const expiryText = c.expires_at ? ` • Udløber ${new Date(c.expires_at).toLocaleDateString('da-DK')}` : '';
return `<option value="card_${c.id}">💳 Klippekort #${c.id} (${remaining}t tilbage${expiryText})</option>`;
}).join('');
prepaidOptions = `<optgroup label="Klippekort">${cardOpts}</optgroup>`;
}
}
} catch (e) {
console.error("Failed to load prepaid cards", e);
}
// Store for use in submitWorklog
window._activePrepaidCards = activePrepaidCards;
const modalHtml = `
<div class="modal fade" id="worklogModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-clock-history"></i> Registrer Tid</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-3">
<div class="col-6">
<label class="form-label">Dato *</label>
<input type="date" class="form-control" id="worklogDate" value="${today}" required>
</div>
<div class="col-6">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="worklogHours" min="0" placeholder="tt" step="1">
<span class="input-group-text">:</span>
<input type="number" class="form-control" id="worklogMinutes" min="0" placeholder="mm" step="1">
</div>
<div class="form-text text-end" id="worklogTotalCalc" style="font-size: 0.8rem;">Total: 0.00 timer</div>
</div>
<div class="col-6">
<label class="form-label">Type</label>
<select class="form-select" id="worklogType">
<option value="support" selected>Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-6">
<label class="form-label">Afregning</label>
<select class="form-select" id="worklogBilling">
<option value="invoice" selected>Faktura</option>
${prepaidOptions}
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
<option value="unknown">❓ Ved ikke (Send til godkendelse)</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="worklogDesc" rows="3" placeholder="Hvad er der brugt tid på?"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="worklogInternal">
<label class="form-check-label text-muted" for="worklogInternal">
Skjul for kunde (Intern registrering)
</label>
</div>
</div>
</div>
</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" onclick="submitWorklog()">
<i class="bi bi-save"></i> Gem Tid
</button>
</div>
</div>
</div>
</div>
`;
// Clean up old
const oldModal = document.getElementById('worklogModal');
if(oldModal) oldModal.remove();
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modal = new bootstrap.Modal(document.getElementById('worklogModal'));
modal.show();
// Setup listeners for live calculation
const calcTotal = () => {
const h = parseInt(document.getElementById('worklogHours').value) || 0;
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
const total = h + (m / 60);
document.getElementById('worklogTotalCalc').innerText = `Total: ${total.toFixed(2)} timer`;
};
document.getElementById('worklogHours').addEventListener('input', calcTotal);
document.getElementById('worklogMinutes').addEventListener('input', calcTotal);
// Focus hours (skipping date usually)
setTimeout(() => document.getElementById('worklogHours').focus(), 500);
}
async function submitWorklog() {
const date = document.getElementById('worklogDate').value;
// Calculate hours from split fields
const h = parseInt(document.getElementById('worklogHours').value) || 0;
const m = parseInt(document.getElementById('worklogMinutes').value) || 0;
const hours = h + (m / 60);
const type = document.getElementById('worklogType').value;
let billing = document.getElementById('worklogBilling').value;
const desc = document.getElementById('worklogDesc').value;
const isInternal = document.getElementById('worklogInternal').checked;
let prepaidCardId = null;
if(!date || hours <= 0) {
alert("Udfyld venligst dato og tid (timer/minutter)");
return;
}
// Handle prepaid card selection
if(billing.startsWith('card_')) {
prepaidCardId = parseInt(billing.replace('card_', ''));
billing = 'prepaid_card'; // Reset to enum value
} else if(billing === 'prepaid_card') {
// User selected generic "Klippekort" (shouldn't happen with new UI, but handle it)
// Backend will auto-select if only 1 active, or error if >1
prepaidCardId = null;
}
try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/worklog', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
ticket_id: {{ ticket.id }},
work_date: date,
hours: hours,
work_type: type,
billing_method: billing,
description: desc,
is_internal: isInternal,
prepaid_card_id: prepaidCardId
})
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Fejl ved oprettelse');
}
// Reload to show
window.location.reload();
} catch (e) {
console.error(e);
alert("Kunne ikke gemme tidsregistrering: " + e.message);
}
}
// ============================================
// TAGS MANAGEMENT
// ============================================
// Load and render ticket tags // Load and render ticket tags
async function loadTicketTags() { async function loadTicketTags() {
@ -814,7 +556,6 @@
const tags = await response.json(); const tags = await response.json();
const container = document.getElementById('ticketTags'); const container = document.getElementById('ticketTags');
if (!container) return; // Guard clause
if (tags.length === 0) { if (tags.length === 0) {
container.innerHTML = '<small class="text-muted"><i class="bi bi-tags"></i> Ingen tags endnu</small>'; container.innerHTML = '<small class="text-muted"><i class="bi bi-tags"></i> Ingen tags endnu</small>';
@ -856,13 +597,9 @@
} }
// ============================================ // ============================================
// CONTACTS MANAGEMENT (SEARCHABLE) // CONTACTS MANAGEMENT
// ============================================ // ============================================
let allContactsCache = [];
let customersCache = [];
let selectedContactId = null;
async function loadContacts() { async function loadContacts() {
try { try {
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts'); const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
@ -870,7 +607,6 @@
const data = await response.json(); const data = await response.json();
const container = document.getElementById('contactsList'); const container = document.getElementById('contactsList');
if (!container) return;
if (!data.contacts || data.contacts.length === 0) { if (!data.contacts || data.contacts.length === 0) {
container.innerHTML = ` container.innerHTML = `
@ -934,25 +670,20 @@
} }
async function showAddContactModal() { async function showAddContactModal() {
// Load contacts if not cached // Fetch all contacts for selection
if (allContactsCache.length === 0) {
try { try {
const response = await fetch('/api/v1/contacts?limit=1000'); const response = await fetch('/api/v1/contacts?limit=1000');
const data = await response.json(); if (!response.ok) throw new Error('Failed to load contacts');
allContactsCache = data.contacts || [];
} catch (e) {
console.error("Failed to load contacts for modal", e);
alert("Kunne ikke hente kontaktliste");
return;
}
}
// Check existing contacts const data = await response.json();
const contacts = data.contacts || [];
// Check if this ticket has any contacts yet
const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts'); const ticketContactsResp = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
const ticketContacts = await ticketContactsResp.json(); const ticketContacts = await ticketContactsResp.json();
const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0; const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0;
// Define Modal HTML // Create modal content
const modalHtml = ` const modalHtml = `
<div class="modal fade" id="addContactModal" tabindex="-1"> <div class="modal fade" id="addContactModal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
@ -962,82 +693,20 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Search Stage -->
<div id="contactSearchStage">
<label class="form-label">Find kontakt</label>
<input type="text" class="form-control mb-2" id="contactSearchInput"
placeholder="Søg navn eller email..." autocomplete="off">
<div class="list-group" id="contactSearchResults" style="max-height: 250px; overflow-y: auto;">
<!-- Results will appear here -->
<div class="text-center text-muted py-3 small">Begynd at skrive for at søge...</div>
</div>
<div class="mt-2 text-end">
<small class="text-muted">Finder du ikke kontakten? <a href="#" onclick="showCreateStage(); return false;">Smart Opret</a></small>
</div>
</div>
<!-- Create Stage (Hidden) -->
<div id="contactCreateStage" style="display:none;" class="animate__animated animate__fadeIn">
<h6 class="border-bottom pb-2 mb-3 text-primary"><i class="bi bi-person-plus"></i> Hurtig oprettelse</h6>
<div class="row g-2 mb-2">
<div class="col-6">
<label class="form-label small">Fornavn *</label>
<input type="text" class="form-control" id="newContactFirstName" required>
</div>
<div class="col-6">
<label class="form-label small">Efternavn</label>
<input type="text" class="form-control" id="newContactLastName">
</div>
</div>
<div class="mb-2">
<label class="form-label small">Email</label>
<input type="email" class="form-control" id="newContactEmail">
</div>
<div class="mb-2">
<label class="form-label small">Telefon</label>
<input type="tel" class="form-control" id="newContactPhone">
</div>
<div class="mb-2 position-relative">
<label class="form-label small">Firma</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="newContactCompanySearch" placeholder="Søg firma..." autocomplete="off">
<input type="hidden" id="newContactCompanyId" value="{{ ticket.customer_id }}">
<button class="btn btn-outline-secondary" type="button" onclick="clearCompanySelection()">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div id="companySearchResults" class="list-group position-absolute w-100 shadow-sm" style="display:none; z-index: 1050; max-height: 200px; overflow-y: auto;"></div>
<div class="form-text small" id="selectedCompanyName">Valgt: {{ ticket.customer_name }}</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label small">Titel</label> <label class="form-label">Kontakt *</label>
<input type="text" class="form-control" id="newContactTitle"> <select class="form-select" id="contactSelect" required>
<option value="">Vælg kontakt...</option>
${contacts.map(c => `
<option value="${c.id}">${c.first_name} ${c.last_name} ${c.email ? '(' + c.email + ')' : ''}</option>
`).join('')}
</select>
</div> </div>
<div class="d-flex justify-content-between pt-2 border-top">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="cancelCreate()">Annuller</button>
<button type="button" class="btn btn-sm btn-success text-white" onclick="createContactSmart()">
<i class="bi bi-check-lg"></i> Opret & Vælg
</button>
</div>
</div>
<!-- Selected Stage (Hidden initially) -->
<div id="contactSelectedStage" style="display:none;" class="animate__animated animate__fadeIn">
<input type="hidden" id="selectedContactId">
<div class="alert alert-primary d-flex justify-content-between align-items-center mb-3">
<div>
<i class="bi bi-person-check-fill me-2"></i>
<strong id="selectedContactName">Name</strong>
</div>
<button class="btn btn-sm btn-outline-primary bg-white" onclick="resetContactSelection()">Skift</button>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Rolle *</label> <label class="form-label">Rolle *</label>
<div id="firstContactNotice" class="alert alert-info mb-2" style="display: none;">
<i class="bi bi-star"></i> <strong>Første kontakt</strong> - Rollen sættes automatisk til "Primær kontakt"
</div>
<select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required> <select class="form-select" id="roleSelect" onchange="toggleCustomRole()" required>
<optgroup label="Standard roller"> <optgroup label="Standard roller">
<option value="primary">⭐ Primær kontakt</option> <option value="primary">⭐ Primær kontakt</option>
@ -1058,22 +727,20 @@
</optgroup> </optgroup>
</select> </select>
</div> </div>
<div class="mb-3" id="customRoleDiv" style="display: none;"> <div class="mb-3" id="customRoleDiv" style="display: none;">
<label class="form-label">Custom Rolle</label> <label class="form-label">Custom Rolle</label>
<input type="text" class="form-control" id="customRoleInput" placeholder="f.eks. projektleder"> <input type="text" class="form-control" id="customRoleInput"
placeholder="f.eks. 'bygningsingeniør' eller 'projektleder'">
<small class="text-muted">Brug lowercase og underscore i stedet for mellemrum (f.eks. bygnings_ingeniør)</small>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Noter (valgfri)</label> <label class="form-label">Noter (valgfri)</label>
<textarea class="form-control" id="contactNotes" rows="2" placeholder="Noter om rollen..."></textarea> <textarea class="form-control" id="contactNotes" rows="2" placeholder="Evt. noter om kontaktens rolle..."></textarea>
</div> </div>
</div> </div>
</div>
<div class="modal-footer"> <div class="modal-footer">
<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>
<button type="button" class="btn btn-primary" id="btnAddContactConfirm" onclick="addContact()" disabled> <button type="button" class="btn btn-primary" onclick="addContact()">
<i class="bi bi-plus-circle"></i> Tilføj <i class="bi bi-plus-circle"></i> Tilføj
</button> </button>
</div> </div>
@ -1082,210 +749,26 @@
</div> </div>
`; `;
// Clean up old // Remove old modal if exists
const oldModal = document.getElementById('addContactModal'); const oldModal = document.getElementById('addContactModal');
if (oldModal) oldModal.remove(); if (oldModal) oldModal.remove();
// Append and show new modal
document.body.insertAdjacentHTML('beforeend', modalHtml); document.body.insertAdjacentHTML('beforeend', modalHtml);
const modalEl = document.getElementById('addContactModal'); const modal = new bootstrap.Modal(document.getElementById('addContactModal'));
const modal = new bootstrap.Modal(modalEl);
// Setup Search Listener // Show notice and disable role selector if first contact
const input = document.getElementById('contactSearchInput');
input.addEventListener('input', (e) => filterContacts(e.target.value));
// Setup First Contact Logic
if (isFirstContact) { if (isFirstContact) {
document.getElementById('firstContactNotice').style.display = 'block';
document.getElementById('roleSelect').value = 'primary'; document.getElementById('roleSelect').value = 'primary';
// Note: User still needs to select a contact first document.getElementById('roleSelect').disabled = true;
} }
modal.show(); modal.show();
// Focus input } catch (error) {
setTimeout(() => input.focus(), 500); console.error('Error:', error);
alert('Fejl ved indlæsning af kontakter');
} }
async function loadCustomers() {
if(customersCache.length > 0) return;
try {
const response = await fetch('/api/v1/customers?limit=100');
const data = await response.json();
customersCache = data.customers || data;
} catch(e) { console.error("Failed to load customers", e); }
}
function showCreateStage() {
document.getElementById('contactSearchStage').style.display = 'none';
document.getElementById('contactCreateStage').style.display = 'block';
document.getElementById('newContactFirstName').focus();
loadCustomers();
// Setup company search
const input = document.getElementById('newContactCompanySearch');
input.addEventListener('input', (e) => filterCustomers(e.target.value));
input.addEventListener('focus', () => {
if(input.value.length === 0) filterCustomers('');
});
// Hide results on blur with delay to allow clicking
input.addEventListener('blur', () => {
setTimeout(() => document.getElementById('companySearchResults').style.display = 'none', 200);
});
}
function filterCustomers(query) {
const resultsDiv = document.getElementById('companySearchResults');
const term = query.toLowerCase();
const matches = customersCache.filter(c =>
c.name.toLowerCase().includes(term) ||
(c.cvr_number && c.cvr_number.includes(term))
).slice(0, 10);
if(matches.length === 0) {
resultsDiv.style.display = 'none';
return;
}
resultsDiv.innerHTML = matches.map(c => `
<a href="#" class="list-group-item list-group-item-action small py-1" onclick="selectCompany(${c.id}, '${c.name}'); return false;">
${c.name} <span class="text-muted ms-1">(${c.cvr_number || '-'})</span>
</a>
`).join('');
resultsDiv.style.display = 'block';
}
function selectCompany(id, name) {
document.getElementById('newContactCompanyId').value = id;
document.getElementById('selectedCompanyName').innerText = 'Valgt: ' + name;
document.getElementById('newContactCompanySearch').value = '';
document.getElementById('companySearchResults').style.display = 'none';
}
function clearCompanySelection() {
document.getElementById('newContactCompanyId').value = '';
document.getElementById('selectedCompanyName').innerText = 'Valgt: (Ingen / Privat)';
document.getElementById('newContactCompanySearch').value = '';
}
function cancelCreate() {
document.getElementById('contactCreateStage').style.display = 'none';
document.getElementById('contactSearchStage').style.display = 'block';
}
async function createContactSmart() {
const first = document.getElementById('newContactFirstName').value.trim();
const last = document.getElementById('newContactLastName').value.trim();
const email = document.getElementById('newContactEmail').value.trim();
const phone = document.getElementById('newContactPhone').value.trim();
const title = document.getElementById('newContactTitle').value.trim();
const companyId = document.getElementById('newContactCompanyId').value;
if(!first) {
alert("Fornavn er påkrævet");
return;
}
try {
const payload = {
first_name: first,
last_name: last,
email: email || null,
phone: phone || null,
title: title || null
};
if(companyId) {
payload.company_id = parseInt(companyId);
}
const response = await fetch('/api/v1/contacts', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Fejl ved oprettelse');
}
const newContact = await response.json();
// Add to cache
allContactsCache.push(newContact);
// Hide create stage
document.getElementById('contactCreateStage').style.display = 'none';
// Select the new contact
selectContact(newContact.id, `${newContact.first_name} ${newContact.last_name}`, newContact.email);
} catch (e) {
console.error(e);
alert("Kunne ikke oprette kontakt: " + e.message);
}
}
function filterContacts(query) {
const resultsDiv = document.getElementById('contactSearchResults');
if(!query || query.length < 1) {
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Indtast navn, email eller firma...</div>';
return;
}
const term = query.toLowerCase();
const matches = allContactsCache.filter(c =>
(c.first_name + ' ' + c.last_name).toLowerCase().includes(term) ||
(c.email || '').toLowerCase().includes(term) ||
(c.company_names && c.company_names.some(comp => comp.toLowerCase().includes(term)))
).slice(0, 10); // Limit results
if(matches.length === 0) {
resultsDiv.innerHTML = '<div class="text-center text-muted py-3 small">Ingen kontakter fundet</div>';
return;
}
resultsDiv.innerHTML = matches.map(c => {
const companies = (c.company_names && c.company_names.length > 0)
? `<div class="small text-muted mt-1"><i class="bi bi-building"></i> ${c.company_names.join(', ')}</div>`
: '';
return `
<a href="#" class="list-group-item list-group-item-action contact-result-item"
onclick="selectContact(${c.id}, '${c.first_name} ${c.last_name}', '${c.email||''}')">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${c.first_name} ${c.last_name}</strong>
<div class="small text-muted">${c.email || ''}</div>
${companies}
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
</a>
`}).join('');
}
function selectContact(id, name, email) {
selectedContactId = id;
document.getElementById('selectedContactId').value = id;
document.getElementById('selectedContactName').innerText = name;
// Switch stages
document.getElementById('contactSearchStage').style.display = 'none';
document.getElementById('contactSelectedStage').style.display = 'block';
// Enable save
document.getElementById('btnAddContactConfirm').disabled = false;
}
function resetContactSelection() {
selectedContactId = null;
document.getElementById('contactSearchStage').style.display = 'block';
document.getElementById('contactSelectedStage').style.display = 'none';
document.getElementById('btnAddContactConfirm').disabled = true;
document.getElementById('contactSearchInput').focus();
} }
function toggleCustomRole() { function toggleCustomRole() {
@ -1303,15 +786,16 @@
} }
async function addContact() { async function addContact() {
if (!selectedContactId) { const contactId = document.getElementById('contactSelect').value;
let role = document.getElementById('roleSelect').value;
const notes = document.getElementById('contactNotes').value;
if (!contactId) {
alert('Vælg venligst en kontakt'); alert('Vælg venligst en kontakt');
return; return;
} }
let role = document.getElementById('roleSelect').value; // If custom role selected, use the custom input
const notes = document.getElementById('contactNotes').value;
// Custom role logic
if (role === '_custom') { if (role === '_custom') {
const customRole = document.getElementById('customRoleInput').value.trim(); const customRole = document.getElementById('customRoleInput').value.trim();
if (!customRole) { if (!customRole) {
@ -1322,7 +806,7 @@
} }
try { try {
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}&role=${role}${notes ? '&notes=' + encodeURIComponent(notes) : ''}`; const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${contactId}&role=${role}${notes ? '&notes=' + encodeURIComponent(notes) : ''}`;
const response = await fetch(url, { method: 'POST' }); const response = await fetch(url, { method: 'POST' });
if (!response.ok) { if (!response.ok) {
@ -1377,15 +861,9 @@
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadTicketTags(); loadTicketTags();
loadContacts(); loadContacts();
// Initialize tooltips/popovers if any
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
})
}); });
// Global Tag Picker Override // Override global tag picker to auto-reload after adding
if (window.tagPicker) { if (window.tagPicker) {
const originalShow = window.tagPicker.show.bind(window.tagPicker); const originalShow = window.tagPicker.show.bind(window.tagPicker);
window.showTagPicker = function(entityType, entityId, onSelect) { window.showTagPicker = function(entityType, entityId, onSelect) {

View File

@ -1,9 +1,104 @@
{% extends "shared/frontend/base.html" %} <!DOCTYPE html>
<html lang="da">
{% block title %}Worklog Godkendelse - BMC Hub{% endblock %} <head>
<meta charset="UTF-8">
{% block extra_css %} <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Worklog Godkendelse - BMC Hub</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style> <style>
:root {
--bg-body: #f8f9fa;
--bg-card: #ffffff;
--text-primary: #2c3e50;
--text-secondary: #6c757d;
--accent: #0f4c75;
--accent-light: #eef2f5;
--border-radius: 12px;
--success: #28a745;
--danger: #dc3545;
--warning: #ffc107;
}
[data-theme="dark"] {
--bg-body: #1a1d23;
--bg-card: #252a31;
--text-primary: #e4e6eb;
--text-secondary: #b0b3b8;
--accent: #4a9eff;
--accent-light: #2d3748;
--success: #48bb78;
--danger: #f56565;
--warning: #ed8936;
}
body {
background-color: var(--bg-body);
color: var(--text-primary);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
padding-top: 80px;
transition: background-color 0.3s, color 0.3s;
}
.navbar {
background: var(--bg-card);
box-shadow: 0 2px 15px rgba(0,0,0,0.08);
padding: 1rem 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
transition: background-color 0.3s;
}
.navbar-brand {
font-weight: 700;
color: var(--accent);
font-size: 1.25rem;
}
.nav-link {
color: var(--text-secondary);
padding: 0.6rem 1.2rem !important;
border-radius: var(--border-radius);
transition: all 0.2s;
font-weight: 500;
margin: 0 0.2rem;
}
.nav-link:hover, .nav-link.active {
background-color: var(--accent-light);
color: var(--accent);
}
.card {
border: none;
border-radius: var(--border-radius);
box-shadow: 0 2px 15px rgba(0,0,0,0.05);
background: var(--bg-card);
margin-bottom: 1.5rem;
transition: background-color 0.3s;
}
.stats-row {
margin-bottom: 2rem;
}
.stat-card {
text-align: center;
padding: 1.5rem;
}
.stat-card h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
margin-bottom: 0.5rem;
}
.stat-card p {
color: var(--text-secondary);
font-size: 0.9rem;
margin: 0;
}
.worklog-table { .worklog-table {
background: var(--bg-card); background: var(--bg-card);
} }
@ -89,6 +184,22 @@
transform: translateY(-1px); transform: translateY(-1px);
} }
.theme-toggle {
cursor: pointer;
padding: 0.5rem 1rem;
border-radius: var(--border-radius);
background: var(--accent-light);
color: var(--accent);
transition: all 0.2s;
border: none;
font-size: 1.2rem;
}
.theme-toggle:hover {
background: var(--accent);
color: white;
}
.filter-bar { .filter-bar {
background: var(--bg-card); background: var(--bg-card);
padding: 1.5rem; padding: 1.5rem;
@ -142,41 +253,74 @@
white-space: nowrap; white-space: nowrap;
} }
</style> </style>
{% endblock %} </head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg fixed-top">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<i class="bi bi-boxes"></i> BMC Hub
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" href="/ticket/dashboard">
<i class="bi bi-speedometer2"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api/v1/ticket/tickets">
<i class="bi bi-ticket-detailed"></i> Tickets
</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/ticket/worklog/review">
<i class="bi bi-clock-history"></i> Worklog Godkendelse
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/api/v1/prepaid-cards">
<i class="bi bi-credit-card"></i> Klippekort
</a>
</li>
</ul>
<button class="theme-toggle" onclick="toggleTheme()" title="Toggle Dark Mode">
<i class="bi bi-moon-stars-fill"></i>
</button>
</div>
</div>
</nav>
{% block content %} <div class="container-fluid px-4">
<!-- Page Header --> <!-- Page Header -->
<div class="row mb-4 align-items-center"> <div class="row mb-4">
<div class="col"> <div class="col">
<h1 class="mb-2"> <h1 class="mb-2">
<i class="bi bi-clock-history"></i> Worklog Godkendelse <i class="bi bi-clock-history"></i> Worklog Godkendelse
</h1> </h1>
<p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p> <p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p>
</div> </div>
<div class="col-auto">
<a href="/timetracking/wizard2{% if selected_customer_id %}?hub_id={{ selected_customer_id }}{% endif %}"
class="btn btn-primary btn-lg shadow-sm">
<i class="bi bi-magic me-2"></i> Åbn Billing Wizard
</a>
</div>
</div> </div>
<!-- Statistics Row --> <!-- Statistics Row -->
<div class="row stats-row"> <div class="row stats-row">
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4"> <div class="card stat-card">
<h3>{{ total_entries }}</h3> <h3>{{ total_entries }}</h3>
<p>Entries til godkendelse</p> <p>Entries til godkendelse</p>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4"> <div class="card stat-card">
<h3>{{ "%.2f"|format(total_hours) }}t</h3> <h3>{{ "%.2f"|format(total_hours) }}t</h3>
<p>Total timer</p> <p>Total timer</p>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<div class="card stat-card p-4"> <div class="card stat-card">
<h3>{{ "%.2f"|format(total_billable_hours) }}t</h3> <h3>{{ "%.2f"|format(total_billable_hours) }}t</h3>
<p>Fakturerbare timer</p> <p>Fakturerbare timer</p>
</div> </div>
@ -236,7 +380,7 @@
</thead> </thead>
<tbody> <tbody>
{% for worklog in worklogs %} {% for worklog in worklogs %}
<tr class="worklog-row {% if worklog.billing_method == 'unknown' %}table-warning{% endif %}"> <tr class="worklog-row">
<td> <td>
<span class="ticket-number">{{ worklog.ticket_number }}</span> <span class="ticket-number">{{ worklog.ticket_number }}</span>
<br> <br>
@ -276,12 +420,6 @@
{% if worklog.card_number %} {% if worklog.card_number %}
<br><small class="text-muted">{{ worklog.card_number }}</small> <br><small class="text-muted">{{ worklog.card_number }}</small>
{% endif %} {% endif %}
{% elif worklog.billing_method == 'unknown' %}
<span class="badge bg-warning text-dark">
<i class="bi bi-question-circle"></i> Ved ikke
</span>
{% else %}
<span class="badge bg-secondary">{{ worklog.billing_method }}</span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
@ -297,31 +435,15 @@
<div class="btn-group-actions"> <div class="btn-group-actions">
<form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;"> <form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;">
<input type="hidden" name="redirect_to" value="{{ request.url }}"> <input type="hidden" name="redirect_to" value="{{ request.url }}">
<button type="submit" class="btn btn-approve btn-sm" title="Godkend"> <button type="submit" class="btn btn-approve btn-sm">
<i class="bi bi-check-circle"></i> <i class="bi bi-check-circle"></i> Godkend
</button> </button>
</form> </form>
<button
type="button"
class="btn btn-outline-primary btn-sm ms-1"
title="Rediger"
data-id="{{ worklog.id }}"
data-customer-id="{{ worklog.customer_id }}"
data-hours="{{ worklog.hours }}"
data-type="{{ worklog.work_type }}"
data-billing="{{ worklog.billing_method }}"
data-prepaid-card-id="{{ worklog.prepaid_card_id or '' }}"
data-desc="{{ worklog.description }}"
data-internal="{{ 'true' if worklog.is_internal else 'false' }}"
onclick="openEditModal(this)">
<i class="bi bi-pencil"></i>
</button>
<button <button
type="button" type="button"
class="btn btn-reject btn-sm ms-1" class="btn btn-reject btn-sm ms-1"
title="Afvis"
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')"> onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
<i class="bi bi-x-circle"></i> <i class="bi bi-x-circle"></i> Afvis
</button> </button>
</div> </div>
{% else %} {% else %}
@ -343,76 +465,6 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
<!-- Edit Modal -->
<div class="modal fade" id="editModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content" style="background: var(--bg-card); color: var(--text-primary);">
<div class="modal-header" style="border-bottom: 1px solid var(--accent-light);">
<h5 class="modal-title">Rediger Worklog Entry</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editId">
<div class="row g-3">
<div class="col-12">
<label class="form-label">Tid brugt *</label>
<div class="input-group">
<input type="number" class="form-control" id="editHours" min="0" placeholder="tt" step="1"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<span class="input-group-text" style="background: var(--accent-light); border-color: var(--accent-light); color: var(--text-primary);">:</span>
<input type="number" class="form-control" id="editMinutes" min="0" max="59" placeholder="mm" step="1"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
</div>
<div class="form-text text-end" id="editTotalCalc">Total: 0.00 timer</div>
</div>
<div class="col-12">
<label class="form-label">Type</label>
<select class="form-select" id="editType" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<option value="support">Support</option>
<option value="troubleshooting">Fejlsøgning</option>
<option value="development">Udvikling</option>
<option value="on_site">Kørsel / On-site</option>
<option value="meeting">Møde</option>
<option value="other">Andet</option>
</select>
</div>
<div class="col-12">
<label class="form-label">Afregning</label>
<select class="form-select" id="editBilling" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);" onchange="handleBillingMethodChange()">
<option value="invoice">Faktura</option>
<option value="prepaid_card">Klippekort</option>
<option value="internal">Internt / Ingen faktura</option>
<option value="warranty">Garanti / Reklamation</option>
<option value="unknown">❓ Ved ikke</option>
</select>
</div>
<div class="col-12" id="prepaidCardSelectContainer" style="display: none;">
<label class="form-label">Vælg Klippekort *</label>
<select class="form-select" id="editPrepaidCard" style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);">
<option value="">-- Henter klippekort --</option>
</select>
<div class="form-text">Vælg hvilket klippekort der skal bruges</div>
</div>
<div class="col-12">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="editDesc" rows="3"
style="background: var(--bg-body); color: var(--text-primary); border-color: var(--accent-light);"></textarea>
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editInternal">
<label class="form-check-label" for="editInternal">Skjul for kunde (Intern registrering)</label>
</div>
</div>
</div>
</div>
<div class="modal-footer" style="border-top: 1px solid var(--accent-light);">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="saveWorklog()">Gem Ændringer</button>
</div>
</div>
</div>
</div> </div>
<!-- Reject Modal --> <!-- Reject Modal -->
@ -447,10 +499,37 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
{% block extra_js %} <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script> <script>
// Theme Toggle
function toggleTheme() {
const html = document.documentElement;
const currentTheme = html.getAttribute('data-theme');
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
html.setAttribute('data-theme', newTheme);
localStorage.setItem('theme', newTheme);
// Update icon
const icon = document.querySelector('.theme-toggle i');
if (newTheme === 'dark') {
icon.className = 'bi bi-sun-fill';
} else {
icon.className = 'bi bi-moon-stars-fill';
}
}
// Load saved theme
document.addEventListener('DOMContentLoaded', function() {
const savedTheme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', savedTheme);
const icon = document.querySelector('.theme-toggle i');
if (savedTheme === 'dark') {
icon.className = 'bi bi-sun-fill';
}
});
// Reject worklog with modal // Reject worklog with modal
function rejectWorklog(worklogId, redirectUrl) { function rejectWorklog(worklogId, redirectUrl) {
const form = document.getElementById('rejectForm'); const form = document.getElementById('rejectForm');
@ -476,153 +555,6 @@
document.body.appendChild(badge); document.body.appendChild(badge);
} }
}, 60000); }, 60000);
// ==========================================
// EDIT FUNCTIONALITY
// ==========================================
async function openEditModal(btn) {
const id = btn.getAttribute('data-id');
const customerId = btn.getAttribute('data-customer-id');
const hoursDec = parseFloat(btn.getAttribute('data-hours'));
const type = btn.getAttribute('data-type');
const billing = btn.getAttribute('data-billing');
const prepaidCardId = btn.getAttribute('data-prepaid-card-id');
const desc = btn.getAttribute('data-desc');
const isInternal = btn.getAttribute('data-internal') === 'true';
document.getElementById('editId').value = id;
// Store customer_id for later use
window._editCustomerId = customerId;
window._editPrepaidCardId = prepaidCardId;
// Decimal to HH:MM logic
const h = Math.floor(hoursDec);
const m = Math.round((hoursDec - h) * 60);
document.getElementById('editHours').value = h;
document.getElementById('editMinutes').value = m;
document.getElementById('editTotalCalc').innerText = `Total: ${hoursDec.toFixed(2)} timer`;
document.getElementById('editType').value = type;
document.getElementById('editBilling').value = billing;
document.getElementById('editDesc').value = desc;
// Handle billing method if it was unknown/invalid before, default to invoice
if(!['invoice','prepaid_card','internal','warranty','unknown'].includes(billing)) {
document.getElementById('editBilling').value = 'invoice';
} else {
document.getElementById('editBilling').value = billing;
}
document.getElementById('editInternal').checked = isInternal;
// Load prepaid cards if billing method is prepaid_card
if(billing === 'prepaid_card') {
await loadPrepaidCardsForEdit(customerId, prepaidCardId);
} else {
document.getElementById('prepaidCardSelectContainer').style.display = 'none';
}
new bootstrap.Modal(document.getElementById('editModal')).show();
}
async function handleBillingMethodChange() {
const billing = document.getElementById('editBilling').value;
const customerId = window._editCustomerId;
if(billing === 'prepaid_card' && customerId) {
await loadPrepaidCardsForEdit(customerId, null);
} else {
document.getElementById('prepaidCardSelectContainer').style.display = 'none';
}
}
async function loadPrepaidCardsForEdit(customerId, selectedCardId) {
const container = document.getElementById('prepaidCardSelectContainer');
const select = document.getElementById('editPrepaidCard');
try {
const response = await fetch(`/api/v1/prepaid/prepaid-cards?status=active&customer_id=${customerId}`);
if(response.ok) {
const cards = await response.json();
if(cards && cards.length > 0) {
const options = cards.map(c => {
const remaining = parseFloat(c.remaining_hours).toFixed(2);
const expiryText = c.expires_at ? ` • Udløber ${new Date(c.expires_at).toLocaleDateString('da-DK')}` : '';
const selected = (selectedCardId && c.id == selectedCardId) ? 'selected' : '';
return `<option value="${c.id}" ${selected}>💳 Klippekort #${c.id} (${remaining}t tilbage${expiryText})</option>`;
}).join('');
select.innerHTML = options;
container.style.display = 'block';
} else {
select.innerHTML = '<option value="">Ingen aktive klippekort fundet</option>';
container.style.display = 'block';
}
} else {
select.innerHTML = '<option value="">Fejl ved hentning af klippekort</option>';
container.style.display = 'block';
}
} catch(e) {
console.error('Failed to load prepaid cards', e);
select.innerHTML = '<option value="">Fejl ved hentning af klippekort</option>';
container.style.display = 'block';
}
}
const calcEdit = () => {
const h = parseInt(document.getElementById('editHours').value) || 0;
const m = parseInt(document.getElementById('editMinutes').value) || 0;
const total = h + (m / 60);
document.getElementById('editTotalCalc').innerText = `Total: ${total.toFixed(2)} timer`;
};
document.getElementById('editHours').addEventListener('input', calcEdit);
document.getElementById('editMinutes').addEventListener('input', calcEdit);
async function saveWorklog() {
const id = document.getElementById('editId').value;
const h = parseInt(document.getElementById('editHours').value) || 0;
const m = parseInt(document.getElementById('editMinutes').value) || 0;
const totalHours = h + (m / 60);
const billingMethod = document.getElementById('editBilling').value;
const payload = {
hours: totalHours,
work_type: document.getElementById('editType').value,
billing_method: billingMethod,
description: document.getElementById('editDesc').value,
is_internal: document.getElementById('editInternal').checked
};
// Include prepaid_card_id if billing method is prepaid_card
if(billingMethod === 'prepaid_card') {
const prepaidCardId = document.getElementById('editPrepaidCard').value;
if(prepaidCardId) {
payload.prepaid_card_id = parseInt(prepaidCardId);
} else {
alert('⚠️ Vælg venligst et klippekort');
return;
}
}
try {
const response = await fetch(`/api/v1/ticket/worklog/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if(!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to update');
}
location.reload();
} catch(e) {
alert("Fejl ved opdatering: " + e.message);
}
}
</script> </script>
{% endblock %} </body>
</html>

View File

@ -266,41 +266,6 @@ class EconomicExportService:
customer_number = customer_data['economic_customer_number'] customer_number = customer_data['economic_customer_number']
# 🔍 VALIDATE: Check if customer exists in e-conomic
logger.info(f"🔍 Validating customer {customer_number} exists in e-conomic...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/customers/{customer_number}",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 404:
raise HTTPException(
status_code=400,
detail=f"Kunde '{order['customer_name']}' med e-conomic nummer '{customer_number}' findes ikke i e-conomic. Kontroller kundenummeret i Customers modulet."
)
elif response.status != 200:
logger.warning(f"⚠️ Could not validate customer: {response.status}")
# Continue anyway - might be network issue
# 🔍 VALIDATE: Check if layout exists in e-conomic
layout_number = settings.TIMETRACKING_ECONOMIC_LAYOUT
logger.info(f"🔍 Validating layout {layout_number} exists in e-conomic...")
async with aiohttp.ClientSession() as session:
async with session.get(
f"{self.api_url}/layouts/{layout_number}",
headers=self._get_headers(),
timeout=aiohttp.ClientTimeout(total=10)
) as response:
if response.status == 404:
raise HTTPException(
status_code=400,
detail=f"Layout nummer '{layout_number}' findes ikke i e-conomic. Opdater TIMETRACKING_ECONOMIC_LAYOUT i .env filen med et gyldigt layout nummer fra e-conomic."
)
elif response.status != 200:
logger.warning(f"⚠️ Could not validate layout: {response.status}")
# Continue anyway - might be network issue
# Build e-conomic draft order payload # Build e-conomic draft order payload
economic_payload = { economic_payload = {
"date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']), "date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']),
@ -447,17 +412,16 @@ class EconomicExportService:
(economic_draft_id, economic_order_number, user_id, request.order_id) (economic_draft_id, economic_order_number, user_id, request.order_id)
) )
# Marker time entries som billed og opdater billed_via_thehub_id # Marker time entries som billed
execute_update( execute_update(
"""UPDATE tmodule_times """UPDATE tmodule_times
SET status = 'billed', SET status = 'billed'
billed_via_thehub_id = %s
WHERE id IN ( WHERE id IN (
SELECT UNNEST(time_entry_ids) SELECT UNNEST(time_entry_ids)
FROM tmodule_order_lines FROM tmodule_order_lines
WHERE order_id = %s WHERE order_id = %s
)""", )""",
(request.order_id, request.order_id) (request.order_id,)
) )
# Hent vTiger IDs for tidsregistreringerne # Hent vTiger IDs for tidsregistreringerne

View File

@ -138,10 +138,8 @@ class TModuleTime(TModuleTimeBase):
rounded_to: Optional[Decimal] = None rounded_to: Optional[Decimal] = None
approval_note: Optional[str] = None approval_note: Optional[str] = None
billable: bool = True billable: bool = True
is_travel: bool = False
approved_at: Optional[datetime] = None approved_at: Optional[datetime] = None
approved_by: Optional[int] = None approved_by: Optional[int] = None
billed_via_thehub_id: Optional[int] = Field(None, description="Hub order ID this time was billed through")
sync_hash: Optional[str] = None sync_hash: Optional[str] = None
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
@ -275,7 +273,6 @@ class TModuleOrderDetails(BaseModel):
class TModuleApprovalStats(BaseModel): class TModuleApprovalStats(BaseModel):
"""Approval statistics per customer (from view)""" """Approval statistics per customer (from view)"""
customer_id: int customer_id: int
hub_customer_id: Optional[int] = None
customer_name: str customer_name: str
customer_vtiger_id: str customer_vtiger_id: str
uses_time_card: bool = False uses_time_card: bool = False
@ -317,13 +314,6 @@ class TModuleWizardProgress(BaseModel):
return v return v
class TModuleWizardEditRequest(BaseModel):
"""Request model for editing a time entry via Wizard"""
description: Optional[str] = None
original_hours: Optional[Decimal] = None # Editing raw hours before approval
billing_method: Optional[str] = None # For Hub Worklogs (invoice, prepaid, etc)
billable: Optional[bool] = None # For Module Times
class TModuleWizardNextEntry(BaseModel): class TModuleWizardNextEntry(BaseModel):
"""Next entry for wizard approval""" """Next entry for wizard approval"""
has_next: bool has_next: bool

View File

@ -100,9 +100,6 @@ class OrderService:
c.vtiger_id as case_vtiger_id, c.vtiger_id as case_vtiger_id,
COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number, COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number,
c.vtiger_data->>'ticket_title' as vtiger_title, c.vtiger_data->>'ticket_title' as vtiger_title,
c.priority as case_priority,
c.status as case_status,
c.module_type as case_type,
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_cases c ON t.case_id = c.id
@ -139,9 +136,6 @@ class OrderService:
'case_vtiger_id': time_entry.get('case_vtiger_id'), 'case_vtiger_id': time_entry.get('case_vtiger_id'),
'case_number': time_entry.get('case_number'), # Fra vtiger_data 'case_number': time_entry.get('case_number'), # Fra vtiger_data
'case_title': case_title, # Case titel fra vTiger 'case_title': case_title, # Case titel fra vTiger
'case_priority': time_entry.get('case_priority'), # Prioritet
'case_status': time_entry.get('case_status'), # Status
'case_type': time_entry.get('case_type'), # Brand/Type (module_type)
'contact_name': time_entry.get('contact_name'), 'contact_name': time_entry.get('contact_name'),
'worked_date': time_entry.get('worked_date'), # Seneste dato 'worked_date': time_entry.get('worked_date'), # Seneste dato
'is_travel': False, # Marker hvis nogen entry er rejse 'is_travel': False, # Marker hvis nogen entry er rejse
@ -199,30 +193,11 @@ class OrderService:
# Sidste fallback hvis intet andet # Sidste fallback hvis intet andet
case_title = "Support arbejde" case_title = "Support arbejde"
# Build description med case nummer, titel, dato, type, prioritet # Build description med case nummer prefix
description_parts = []
# Case nummer og titel
if case_number: if case_number:
description_parts.append(f"{case_number} - {case_title}") description = f"{case_number} - {case_title}"
else: else:
description_parts.append(case_title) description = case_title
# Dato
if group.get('worked_date'):
date_str = group['worked_date'].strftime('%d.%m.%Y')
description_parts.append(f"Dato: {date_str}")
# Brand/Type (module_type)
if group.get('case_type'):
description_parts.append(f"Type: {group['case_type']}")
# Prioritet
if group.get('case_priority'):
description_parts.append(f"Prioritet: {group['case_priority']}")
# Join all parts with newlines for multi-line description
description = "\n".join(description_parts)
# Calculate line total # Calculate line total
line_total = case_hours * hourly_rate line_total = case_hours * hourly_rate
@ -304,8 +279,22 @@ class OrderService:
logger.info(f"✅ Created {len(created_lines)} order lines") logger.info(f"✅ Created {len(created_lines)} order lines")
# NOTE: Time entries remain 'approved' status until exported to e-conomic # Update time entries to 'billed' status
# They will be updated to 'billed' with billed_via_thehub_id in economic_export.py time_entry_ids = [
entry_id
for line in order_lines
for entry_id in line.time_entry_ids
]
if time_entry_ids:
placeholders = ','.join(['%s'] * len(time_entry_ids))
execute_update(
f"""UPDATE tmodule_times
SET status = 'billed'
WHERE id IN ({placeholders})""",
time_entry_ids
)
logger.info(f"✅ Marked {len(time_entry_ids)} time entries as billed")
# Log order creation # Log order creation
audit.log_order_created( audit.log_order_created(

View File

@ -8,7 +8,6 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
import logging import logging
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Body from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -35,7 +34,6 @@ from app.timetracking.backend.wizard import wizard
from app.timetracking.backend.order_service import order_service from app.timetracking.backend.order_service import order_service
from app.timetracking.backend.economic_export import economic_service from app.timetracking.backend.economic_export import economic_service
from app.timetracking.backend.audit import audit from app.timetracking.backend.audit import audit
from app.services.customer_consistency import CustomerConsistencyService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -398,44 +396,6 @@ async def approve_time_entry(
from app.core.config import settings from app.core.config import settings
from decimal import Decimal from decimal import Decimal
# SPECIAL HANDLER FOR HUB WORKLOGS (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
logger.info(f"🔄 Approving Hub Worklog {worklog_id}")
w_entry = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w_entry:
raise HTTPException(status_code=404, detail="Worklog not found")
billable_hours = request.get('billable_hours')
approved_hours = Decimal(str(billable_hours)) if billable_hours is not None else Decimal(str(w_entry['hours']))
is_billable = request.get('billable', True)
new_billing = 'invoice' if is_billable else 'internal'
execute_query("""
UPDATE tticket_worklog
SET hours = %s, billing_method = %s, status = 'billable'
WHERE id = %s
""", (approved_hours, new_billing, worklog_id))
return {
"id": time_id,
"worked_date": w_entry['work_date'],
"original_hours": w_entry['hours'],
"status": "approved",
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w_entry['description'],
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w_entry['created_at'],
"last_synced_at": datetime.now(),
"approved_hours": approved_hours
}
# Hent timelog # Hent timelog
query = """ query = """
SELECT t.*, c.title as case_title, c.status as case_status, SELECT t.*, c.title as case_title, c.status as case_status,
@ -476,18 +436,23 @@ async def approve_time_entry(
approved_hours = Decimal(str(billable_hours)) approved_hours = Decimal(str(billable_hours))
rounded_to = None rounded_to = None
# Note: hourly_rate is stored on customer level (tmodule_customers.hourly_rate), not on time entries # Opdater med hourly_rate hvis angivet
# Frontend sends it for calculation display but we don't store it per time entry hourly_rate = request.get('hourly_rate')
if hourly_rate is not None:
execute_update(
"UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s",
(Decimal(str(hourly_rate)), time_id)
)
# Godkend med alle felter # Godkend med alle felter
logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}, billable={request.get('billable', True)}") logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}")
approval = TModuleTimeApproval( approval = TModuleTimeApproval(
time_id=time_id, time_id=time_id,
approved_hours=approved_hours, approved_hours=approved_hours,
rounded_to=rounded_to, rounded_to=rounded_to,
approval_note=request.get('approval_note'), approval_note=request.get('approval_note'),
billable=request.get('billable', True), # Accept from request, default til fakturerbar billable=True, # Default til fakturerbar
is_travel=request.get('is_travel', False) is_travel=request.get('is_travel', False)
) )
@ -503,144 +468,16 @@ async def approve_time_entry(
@router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) @router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def reject_time_entry( async def reject_time_entry(
time_id: int, time_id: int,
request: Dict[str, Any] = Body(None), # Allow body
reason: Optional[str] = None, reason: Optional[str] = None,
user_id: Optional[int] = None user_id: Optional[int] = None
): ):
"""Afvis en tidsregistrering""" """Afvis en tidsregistrering"""
try: try:
# Handle body extraction if reason is missing from query
if not reason and request and 'rejection_note' in request:
reason = request['rejection_note']
if time_id < 0:
worklog_id = abs(time_id)
# Retrieve to confirm existence
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Entry not found")
execute_query("UPDATE tticket_worklog SET status = 'rejected' WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"status": "rejected",
"original_hours": w['hours'],
"worked_date": w['work_date'],
# Mock fields for schema validation
"customer_id": 0,
"case_id": 0,
"description": w.get('description', ''),
"case_title": "Ticket Worklog",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": False
}
return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id) return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id)
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
from app.timetracking.backend.models import TModuleWizardEditRequest
@router.patch("/wizard/entry/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def update_entry_details(
time_id: int,
request: TModuleWizardEditRequest
):
"""
Opdater detaljer en tidsregistrering (før godkendelse).
Tillader ændring af beskrivelse, antal timer og faktureringsmetode.
"""
try:
from decimal import Decimal
# 1. Handling Hub Worklogs (Negative IDs)
if time_id < 0:
worklog_id = abs(time_id)
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
if not w:
raise HTTPException(status_code=404, detail="Worklog not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("hours = %s")
params.append(request.original_hours)
if request.billing_method is not None:
updates.append("billing_method = %s")
params.append(request.billing_method)
if updates:
params.append(worklog_id)
execute_query(f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s", tuple(params))
w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,))
return {
"id": time_id,
"worked_date": w['work_date'],
"original_hours": w['hours'],
"status": "pending", # Always return as pending/draft context here
"description": w['description'],
"customer_id": 0,
"case_id": 0,
"case_title": "Updated",
"customer_name": "Hub Customer",
"created_at": w['created_at'],
"last_synced_at": datetime.now(),
"billable": True
}
# 2. Handling Module Times (Positive IDs)
else:
t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,))
if not t:
raise HTTPException(status_code=404, detail="Time entry not found")
updates = []
params = []
if request.description is not None:
updates.append("description = %s")
params.append(request.description)
if request.original_hours is not None:
updates.append("original_hours = %s")
params.append(request.original_hours)
if request.billable is not None:
updates.append("billable = %s")
params.append(request.billable)
if updates:
params.append(time_id)
execute_query(f"UPDATE tmodule_times SET {', '.join(updates)} WHERE id = %s", tuple(params))
# Fetch fresh with context for response
query = """
SELECT t.*, c.title as case_title, c.status as case_status,
cust.name as customer_name
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.id = %s
"""
return execute_query_single(query, (time_id,))
except Exception as e:
logger.error(f"❌ Failed to update entry {time_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) @router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
async def reset_to_pending( async def reset_to_pending(
time_id: int, time_id: int,
@ -729,152 +566,6 @@ async def get_customer_progress(customer_id: int):
# ORDER ENDPOINTS # ORDER ENDPOINTS
# ============================================================================ # ============================================================================
@router.get("/customers/{customer_id}/data-consistency", tags=["Customers", "Data Consistency"])
async def check_tmodule_customer_data_consistency(customer_id: int):
"""
🔍 Check data consistency across Hub, vTiger, and e-conomic for tmodule_customer.
Before creating order, verify customer data is in sync across all systems.
Maps tmodule_customers.hub_customer_id to the consistency service.
Returns discrepancies found between the three systems.
"""
try:
from app.core.config import settings
if not settings.AUTO_CHECK_CONSISTENCY:
return {
"enabled": False,
"message": "Data consistency checking is disabled"
}
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
return {
"enabled": True,
"customer_id": customer_id,
"discrepancy_count": 0,
"discrepancies": {},
"systems_available": {
"hub": False,
"vtiger": bool(tmodule_customer.get('vtiger_id')),
"economic": False
},
"message": "Customer not linked to Hub - cannot check consistency"
}
# Use Hub customer ID for consistency check
consistency_service = CustomerConsistencyService()
# Fetch data from all systems
all_data = await consistency_service.fetch_all_data(hub_customer_id)
# Compare data
discrepancies = consistency_service.compare_data(all_data)
# Count actual discrepancies
discrepancy_count = sum(
1 for field_data in discrepancies.values()
if field_data['discrepancy']
)
return {
"enabled": True,
"customer_id": customer_id,
"hub_customer_id": hub_customer_id,
"discrepancy_count": discrepancy_count,
"discrepancies": discrepancies,
"systems_available": {
"hub": True,
"vtiger": all_data.get('vtiger') is not None,
"economic": all_data.get('economic') is not None
}
}
except Exception as e:
logger.error(f"❌ Failed to check consistency for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/customers/{customer_id}/sync-field", tags=["Customers", "Data Consistency"])
async def sync_tmodule_customer_field(
customer_id: int,
field_name: str = Body(..., description="Hub field name to sync"),
source_system: str = Body(..., description="Source system: hub, vtiger, or economic"),
source_value: str = Body(..., description="The correct value to sync")
):
"""
🔄 Sync a single field across all systems for tmodule_customer.
Takes the correct value from one system and updates the others.
Maps tmodule_customers.hub_customer_id to the consistency service.
"""
try:
from app.core.config import settings
# Validate source system
if source_system not in ['hub', 'vtiger', 'economic']:
raise HTTPException(
status_code=400,
detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic"
)
# Get tmodule_customer and find linked hub customer
tmodule_customer = execute_query_single(
"SELECT * FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if not tmodule_customer:
raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers")
# Get linked hub customer ID
hub_customer_id = tmodule_customer.get('hub_customer_id')
if not hub_customer_id:
raise HTTPException(
status_code=400,
detail="Customer not linked to Hub - cannot sync fields"
)
consistency_service = CustomerConsistencyService()
# Perform sync on the linked Hub customer
results = await consistency_service.sync_field(
customer_id=hub_customer_id,
field_name=field_name,
source_system=source_system,
source_value=source_value
)
logger.info(f"✅ Field '{field_name}' synced from {source_system}: {results}")
return {
"success": True,
"field": field_name,
"source": source_system,
"value": source_value,
"results": results
}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"❌ Failed to sync field for tmodule_customer {customer_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) @router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"])
async def generate_order(customer_id: int, user_id: Optional[int] = None): async def generate_order(customer_id: int, user_id: Optional[int] = None):
""" """
@ -1396,23 +1087,11 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.vtiger_id AS case_vtiger_id, c.vtiger_id AS case_vtiger_id,
c.description AS case_description, c.description AS case_description,
c.priority AS case_priority,
c.module_type AS case_type,
cust.name AS customer_name cust.name AS customer_name
FROM tmodule_times t FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.customer_id = %s WHERE t.customer_id = %s
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
""" """
params = [customer_id] params = [customer_id]
@ -1425,78 +1104,6 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
times = execute_query(query, tuple(params)) times = execute_query(query, tuple(params))
# 🔗 Combine with Hub Worklogs (tticket_worklog)
# Only if we can find the linked Hub Customer ID
try:
cust_res = execute_query_single(
"SELECT hub_customer_id, name FROM tmodule_customers WHERE id = %s",
(customer_id,)
)
if cust_res and cust_res.get('hub_customer_id'):
hub_id = cust_res['hub_customer_id']
hub_name = cust_res['name']
# Fetch worklogs
w_query = """
SELECT
(w.id * -1) as id,
w.work_date as worked_date,
w.hours as original_hours,
w.description,
CASE
WHEN w.status = 'draft' THEN 'pending'
ELSE w.status
END as status,
-- Ticket info as Case info
t.subject as case_title,
t.ticket_number as case_vtiger_id,
t.description as case_description,
CASE
WHEN t.priority = 'urgent' THEN 'Høj'
ELSE 'Normal'
END as case_priority,
w.work_type as case_type,
-- Customer info
%s as customer_name,
%s as customer_id,
-- Logic
CASE
WHEN w.billing_method IN ('internal', 'warranty') THEN false
ELSE true
END as billable,
false as is_travel,
-- Extra context for frontend flags if needed
w.billing_method as _billing_method
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE t.customer_id = %s
"""
w_params = [hub_name, customer_id, hub_id]
if status:
if status == 'pending':
w_query += " AND w.status = 'draft'"
else:
w_query += " AND w.status = %s"
w_params.append(status)
w_times = execute_query(w_query, tuple(w_params))
if w_times:
times.extend(w_times)
# Re-sort combined list
times.sort(key=lambda x: (x.get('worked_date') or '', x.get('id')), reverse=True)
except Exception as e:
logger.error(f"⚠️ Failed to fetch hub worklogs for wizard: {e}")
# Continue with just tmodule times
return {"times": times, "total": len(times)} return {"times": times, "total": len(times)}
except Exception as e: except Exception as e:
@ -1504,62 +1111,6 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/times", tags=["Times"])
async def list_time_entries(
limit: int = 100,
offset: int = 0,
status: Optional[str] = None,
customer_id: Optional[int] = None,
user_name: Optional[str] = None,
search: Optional[str] = None
):
"""
Hent liste af tidsregistreringer med filtre.
"""
try:
query = """
SELECT t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.priority AS case_priority,
cust.name AS customer_name
FROM tmodule_times t
LEFT JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE 1=1
"""
params = []
if status:
query += " AND t.status = %s"
params.append(status)
if customer_id:
query += " AND t.customer_id = %s"
params.append(customer_id)
if user_name:
query += " AND t.user_name ILIKE %s"
params.append(f"%{user_name}%")
if search:
query += """ AND (
t.description ILIKE %s OR
cust.name ILIKE %s OR
c.title ILIKE %s
)"""
wildcard = f"%{search}%"
params.extend([wildcard, wildcard, wildcard])
query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s"
params.extend([limit, offset])
times = execute_query(query, tuple(params))
return {"times": times}
except Exception as e:
logger.error(f"Error listing times: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/times/{time_id}", tags=["Times"]) @router.get("/times/{time_id}", tags=["Times"])
async def get_time_entry(time_id: int): async def get_time_entry(time_id: int):
""" """

View File

@ -670,18 +670,17 @@ class TimeTrackingVTigerService:
- timelognumber: Unique ID (TL1234) - timelognumber: Unique ID (TL1234)
- duration: Time in seconds - duration: Time in seconds
- relatedto: Reference to Case/Account - relatedto: Reference to Case/Account
- isbillable: '1' = yes, '0' = no - is_billable: '1' = yes, '0' = no
- cf_timelog_invoiced: '1' = has been invoiced - cf_timelog_invoiced: '1' = has been invoiced
- billed_via_thehub_id: Hub order ID (in vTiger custom field)
We only sync entries where: We only sync entries where:
- relatedto is not empty (linked to a Case or Account) - relatedto is not empty (linked to a Case or Account)
- Has valid duration > 0 - Has valid duration > 0
- isbillable = '1' (only billable entries)
- cf_timelog_invoiced = '0' or NULL (not yet invoiced in vTiger) NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger,
- billed_via_thehub_id = '0' or NULL (not yet billed via Hub) so we sync all timelogs and let the approval workflow decide what to bill.
""" """
logger.info(f"🔍 Syncing billable, uninvoiced timelogs from vTiger...") logger.info(f"🔍 Syncing all timelogs from vTiger with valid relatedto...")
stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0}
@ -717,49 +716,12 @@ class TimeTrackingVTigerService:
logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger") logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger")
# Filter timelogs based on requirements: # We don't filter here - the existing code already filters by:
# 1. isbillable = '1' (only billable) # 1. duration > 0
# 2. cf_timelog_invoiced = '0' or NULL (not invoiced in vTiger) # 2. relatedto not empty
# 3. billed_via_thehub_id = '0' or NULL (not billed via Hub) # These filters happen in the processing loop below
# 4. cf_timelog_rounduptimespent = '0' or NULL (not manually invoiced in vTiger)
filtered_timelogs = []
filter_stats = {"not_billable": 0, "already_invoiced": 0, "billed_via_hub": 0, "manually_invoiced": 0}
for tl in all_timelogs: timelogs = all_timelogs[:limit] # Trim to requested limit
isbillable = tl.get('isbillable', '0')
invoiced = tl.get('cf_timelog_invoiced', '0') or '0'
billed_via_hub = tl.get('billed_via_thehub_id', '0') or '0'
billed_via_hub_vtiger = tl.get('cf_timelog_billedviathehubid', '0') or '0'
roundup_spent = tl.get('cf_timelog_rounduptimespent', '0') or '0'
# Debug log first 3 entries to see actual values
if len(all_timelogs) <= 3 or len(filtered_timelogs) == 0:
logger.info(f"🔍 DEBUG Timelog {tl.get('id')}: isbillable={isbillable}, cf_timelog_invoiced={invoiced}, billed_via_thehub_id={billed_via_hub}, billed_via_hub_vtiger={billed_via_hub_vtiger}, roundup_spent={roundup_spent}")
# Track why we skip
if isbillable != '1':
filter_stats["not_billable"] += 1
continue
if invoiced not in ('0', '', None):
filter_stats["already_invoiced"] += 1
continue
if billed_via_hub not in ('0', '', None):
filter_stats["billed_via_hub"] += 1
continue
if billed_via_hub_vtiger not in ('0', '', None):
filter_stats["billed_via_hub"] += 1
continue
if roundup_spent not in ('0', '', None):
filter_stats["manually_invoiced"] += 1
continue
# Only include if billable AND not invoiced AND not billed via Hub AND not manually invoiced
filtered_timelogs.append(tl)
logger.info(f"🔍 Filtered to {len(filtered_timelogs)} billable, uninvoiced timelogs")
logger.info(f" Skipped: {filter_stats['not_billable']} not billable, {filter_stats['already_invoiced']} already invoiced, {filter_stats['billed_via_hub']} billed via Hub, {filter_stats['manually_invoiced']} manually invoiced")
timelogs = filtered_timelogs[:limit] # Trim to requested limit
logger.info(f"📊 Processing {len(timelogs)} timelogs...") logger.info(f"📊 Processing {len(timelogs)} timelogs...")
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls) # NOTE: retrieve API is too slow for batch operations (1500+ individual calls)
@ -833,15 +795,13 @@ class TimeTrackingVTigerService:
stats["skipped"] += 1 stats["skipped"] += 1
continue continue
# Update only if NOT yet approved AND NOT yet billed # Update only if NOT yet approved
result = execute_update( result = execute_update(
"""UPDATE tmodule_times """UPDATE tmodule_times
SET description = %s, original_hours = %s, worked_date = %s, SET description = %s, original_hours = %s, worked_date = %s,
user_name = %s, billable = %s, vtiger_data = %s::jsonb, user_name = %s, billable = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s WHERE vtiger_id = %s AND status = 'pending'""",
AND status = 'pending'
AND billed_via_thehub_id IS NULL""",
( (
timelog.get('name', ''), timelog.get('name', ''),
hours, hours,
@ -857,7 +817,7 @@ class TimeTrackingVTigerService:
if result > 0: if result > 0:
stats["updated"] += 1 stats["updated"] += 1
else: else:
logger.debug(f"⏭️ Time entry {vtiger_id} already approved or billed") logger.debug(f"⏭️ Time entry {vtiger_id} already approved")
stats["skipped"] += 1 stats["skipped"] += 1
else: else:
# Insert new # Insert new

View File

@ -49,70 +49,12 @@ class WizardService:
@staticmethod @staticmethod
def get_all_customers_stats() -> list[TModuleApprovalStats]: def get_all_customers_stats() -> list[TModuleApprovalStats]:
"""Hent approval statistics for alle kunder (inkl. Hub Worklogs)""" """Hent approval statistics for alle kunder"""
try: try:
# 1. Get base stats from module view query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
query = """
SELECT s.*, c.hub_customer_id
FROM tmodule_approval_stats s
LEFT JOIN tmodule_customers c ON s.customer_id = c.id
ORDER BY s.customer_name
"""
results = execute_query(query) results = execute_query(query)
stats_map = {row['customer_id']: dict(row) for row in results}
# 2. Get pending count from Hub Worklogs return [TModuleApprovalStats(**row) for row in results]
# Filter logic: status='draft' in Hub = 'pending' in Wizard
hub_query = """
SELECT
mc.id as tmodule_customer_id,
mc.name as customer_name,
mc.vtiger_id as customer_vtiger_id,
mc.uses_time_card,
mc.hub_customer_id,
count(*) as pending_count,
sum(w.hours) as pending_hours
FROM tticket_worklog w
JOIN tticket_tickets t ON w.ticket_id = t.id
JOIN tmodule_customers mc ON mc.hub_customer_id = t.customer_id
WHERE w.status = 'draft'
GROUP BY mc.id, mc.name, mc.vtiger_id, mc.uses_time_card, mc.hub_customer_id
"""
hub_results = execute_query(hub_query)
# 3. Merge stats
for row in hub_results:
tm_id = row['tmodule_customer_id']
if tm_id in stats_map:
# Update existing
stats_map[tm_id]['pending_count'] += row['pending_count']
stats_map[tm_id]['total_entries'] += row['pending_count']
# Optional: Add to total_original_hours if desired
else:
# New entry for customer only present in Hub worklogs
stats_map[tm_id] = {
"customer_id": tm_id,
"hub_customer_id": row['hub_customer_id'],
"customer_name": row['customer_name'],
"customer_vtiger_id": row['customer_vtiger_id'] or '',
"uses_time_card": row['uses_time_card'],
"total_entries": row['pending_count'],
"pending_count": row['pending_count'],
"approved_count": 0,
"rejected_count": 0,
"billed_count": 0,
"total_original_hours": 0, # Could use row['pending_hours']
"total_approved_hours": 0,
"latest_work_date": None,
"last_sync": None
}
return [TModuleApprovalStats(**s) for s in sorted(stats_map.values(), key=lambda x: x['customer_name'])]
except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}")
return []
except Exception as e: except Exception as e:
logger.error(f"❌ Error getting all customer stats: {e}") logger.error(f"❌ Error getting all customer stats: {e}")
@ -230,13 +172,6 @@ class WizardService:
detail=f"Time entry already {entry['status']}" detail=f"Time entry already {entry['status']}"
) )
# Check if already billed
if entry.get('billed_via_thehub_id') is not None:
raise HTTPException(
status_code=400,
detail="Cannot approve time entry that has already been billed"
)
# Update entry # Update entry
logger.info(f"🔄 Updating time entry {approval.time_id} in database") logger.info(f"🔄 Updating time entry {approval.time_id} in database")
update_query = """ update_query = """
@ -250,7 +185,6 @@ class WizardService:
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
approved_by = %s approved_by = %s
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update( execute_update(
@ -331,13 +265,6 @@ class WizardService:
detail=f"Time entry already {entry['status']}" detail=f"Time entry already {entry['status']}"
) )
# Check if already billed
if entry.get('billed_via_thehub_id') is not None:
raise HTTPException(
status_code=400,
detail="Cannot reject time entry that has already been billed"
)
# Update to rejected # Update to rejected
update_query = """ update_query = """
UPDATE tmodule_times UPDATE tmodule_times
@ -347,7 +274,6 @@ class WizardService:
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
approved_by = %s approved_by = %s
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update(update_query, (reason, user_id, time_id)) execute_update(update_query, (reason, user_id, time_id))
@ -415,13 +341,6 @@ class WizardService:
detail="Cannot reset billed entries" detail="Cannot reset billed entries"
) )
# Check if already billed via Hub order
if entry.get('billed_via_thehub_id') is not None:
raise HTTPException(
status_code=400,
detail="Cannot reset time entry that has been billed through Hub order"
)
# Reset to pending - clear all approval data # Reset to pending - clear all approval data
update_query = """ update_query = """
UPDATE tmodule_times UPDATE tmodule_times
@ -433,7 +352,6 @@ class WizardService:
approved_at = NULL, approved_at = NULL,
approved_by = NULL approved_by = NULL
WHERE id = %s WHERE id = %s
AND billed_via_thehub_id IS NULL
""" """
execute_update(update_query, (reason, time_id)) execute_update(update_query, (reason, time_id))
@ -572,9 +490,7 @@ class WizardService:
SELECT c.id, c.title SELECT c.id, c.title
FROM tmodule_times t FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_cases c ON t.case_id = c.id
WHERE t.customer_id = %s WHERE t.customer_id = %s AND t.status = 'pending'
AND t.status = 'pending'
AND t.billed_via_thehub_id IS NULL
ORDER BY t.worked_date ORDER BY t.worked_date
LIMIT 1 LIMIT 1
""" """
@ -642,13 +558,6 @@ class WizardService:
AND t.status = 'pending' AND t.status = 'pending'
AND t.billable = true AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
AND cust.uses_time_card = false AND cust.uses_time_card = false
ORDER BY t.worked_date, t.id ORDER BY t.worked_date, t.id
""" """
@ -676,13 +585,6 @@ class WizardService:
AND t.status = 'pending' AND t.status = 'pending'
AND t.billable = true AND t.billable = true
AND t.vtiger_data->>'cf_timelog_invoiced' = '0' AND t.vtiger_data->>'cf_timelog_invoiced' = '0'
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
ORDER BY t.worked_date, t.id ORDER BY t.worked_date, t.id
""" """

View File

@ -267,58 +267,10 @@
</div> </div>
</div> </div>
<!-- Data Consistency Comparison Modal -->
<div class="modal fade" id="consistencyModal" tabindex="-1" aria-labelledby="consistencyModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="consistencyModalLabel">
<i class="bi bi-diagram-3 me-2"></i>Sammenlign Kundedata
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-info">
<i class="bi bi-info-circle me-2"></i>
<strong>Vejledning:</strong> Vælg den korrekte værdi for hvert felt med uoverensstemmelser.
Når du klikker "Synkroniser Valgte", vil de valgte værdier blive opdateret i alle systemer før ordren oprettes.
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 20%;">Felt</th>
<th style="width: 20%;">BMC Hub</th>
<th style="width: 20%;">vTiger</th>
<th style="width: 20%;">e-conomic</th>
<th style="width: 20%;">Vælg Korrekt</th>
</tr>
</thead>
<tbody id="consistencyTableBody">
<!-- Populated by JavaScript -->
</tbody>
</table>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" onclick="skipConsistencyCheck()">
<i class="bi bi-x-circle me-2"></i>Spring Over
</button>
<button type="button" class="btn btn-primary" onclick="syncSelectedFields()">
<i class="bi bi-arrow-repeat me-2"></i>Synkroniser Valgte
</button>
</div>
</div>
</div>
</div>
<script> <script>
let allCustomers = []; let allCustomers = [];
let defaultRate = 850.00; // Fallback værdi let defaultRate = 850.00; // Fallback værdi
let selectedCustomers = new Set(); // Track selected customer IDs let selectedCustomers = new Set(); // Track selected customer IDs
let consistencyData = null; // Store consistency check data
let pendingOrderCustomerId = null; // Store customer ID for pending order
// Load customers on page load // Load customers on page load
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
@ -724,7 +676,6 @@
async function createOrderForCustomer(customerId, customerName) { async function createOrderForCustomer(customerId, customerName) {
currentOrderCustomerId = customerId; currentOrderCustomerId = customerId;
pendingOrderCustomerId = customerId;
document.getElementById('order-customer-name').textContent = customerName; document.getElementById('order-customer-name').textContent = customerName;
document.getElementById('order-loading').classList.remove('d-none'); document.getElementById('order-loading').classList.remove('d-none');
document.getElementById('order-content').classList.add('d-none'); document.getElementById('order-content').classList.add('d-none');
@ -736,43 +687,6 @@
modal.show(); modal.show();
try { try {
// 🔍 STEP 1: Check data consistency first
const consistencyResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/data-consistency`);
const consistency = await consistencyResponse.json();
// If consistency check is enabled and there are discrepancies, show them first
if (consistency.enabled && consistency.discrepancy_count > 0) {
consistencyData = consistency;
modal.hide(); // Hide order modal
showConsistencyModal(); // Show consistency modal
return; // Wait for user to sync fields
}
// STEP 2: If no discrepancies (or check disabled), proceed with order creation
await loadOrderPreview(customerId, customerName);
} catch (error) {
console.error('Error checking consistency or loading order preview:', error);
document.getElementById('order-loading').classList.add('d-none');
showToast('Fejl ved indlæsning: ' + error.message, 'danger');
modal.hide();
}
}
async function loadOrderPreview(customerId, customerName) {
// This is the original order preview logic extracted into a separate function
try {
// Show order modal if not already showing
const orderModal = bootstrap.Modal.getInstance(document.getElementById('createOrderModal')) ||
new bootstrap.Modal(document.getElementById('createOrderModal'));
// Reset states
document.getElementById('order-loading').classList.remove('d-none');
document.getElementById('order-content').classList.add('d-none');
document.getElementById('order-empty').classList.add('d-none');
document.getElementById('order-creating').classList.add('d-none');
document.getElementById('confirm-create-order').disabled = true;
// Fetch customer's approved time entries // Fetch customer's approved time entries
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`); const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
if (!response.ok) throw new Error('Failed to load time entries'); if (!response.ok) throw new Error('Failed to load time entries');
@ -1050,185 +964,6 @@
showToast(`Fejl ved opdatering: ${error.message}`, 'danger'); showToast(`Fejl ved opdatering: ${error.message}`, 'danger');
} }
} }
// Data Consistency Functions
function showConsistencyModal() {
if (!consistencyData) {
console.error('No consistency data available');
return;
}
const tbody = document.getElementById('consistencyTableBody');
tbody.innerHTML = '';
// Field labels in Danish
const fieldLabels = {
'name': 'Navn',
'cvr_number': 'CVR Nummer',
'address': 'Adresse',
'city': 'By',
'postal_code': 'Postnummer',
'country': 'Land',
'phone': 'Telefon',
'mobile_phone': 'Mobil',
'email': 'Email',
'website': 'Hjemmeside',
'invoice_email': 'Faktura Email'
};
// Only show fields with discrepancies
for (const [fieldName, fieldData] of Object.entries(consistencyData.discrepancies)) {
if (!fieldData.discrepancy) continue;
const row = document.createElement('tr');
row.className = 'table-warning';
// Field name
const fieldCell = document.createElement('td');
fieldCell.innerHTML = `<strong>${fieldLabels[fieldName] || fieldName}</strong>`;
row.appendChild(fieldCell);
// Hub value
const hubCell = document.createElement('td');
hubCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="hub_${fieldName}" value="hub" data-value="${fieldData.hub || ''}">
<label class="form-check-label" for="hub_${fieldName}">
${fieldData.hub || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
row.appendChild(hubCell);
// vTiger value
const vtigerCell = document.createElement('td');
if (consistencyData.systems_available.vtiger) {
vtigerCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="vtiger_${fieldName}" value="vtiger" data-value="${fieldData.vtiger || ''}">
<label class="form-check-label" for="vtiger_${fieldName}">
${fieldData.vtiger || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
vtigerCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(vtigerCell);
// e-conomic value
const economicCell = document.createElement('td');
if (consistencyData.systems_available.economic) {
economicCell.innerHTML = `
<div class="form-check">
<input class="form-check-input" type="radio" name="field_${fieldName}"
id="economic_${fieldName}" value="economic" data-value="${fieldData.economic || ''}">
<label class="form-check-label" for="economic_${fieldName}">
${fieldData.economic || '<em class="text-muted">Tom</em>'}
</label>
</div>
`;
} else {
economicCell.innerHTML = '<em class="text-muted">Ikke tilgængelig</em>';
}
row.appendChild(economicCell);
// Action cell (which system to use)
const actionCell = document.createElement('td');
actionCell.innerHTML = '<span class="text-muted">← Vælg</span>';
row.appendChild(actionCell);
tbody.appendChild(row);
}
const modal = new bootstrap.Modal(document.getElementById('consistencyModal'));
modal.show();
}
async function syncSelectedFields() {
const selections = [];
// Gather all selected values
const radioButtons = document.querySelectorAll('#consistencyTableBody input[type="radio"]:checked');
if (radioButtons.length === 0) {
alert('Vælg venligst mindst ét felt at synkronisere, eller klik "Spring Over"');
return;
}
radioButtons.forEach(radio => {
const fieldName = radio.name.replace('field_', '');
const sourceSystem = radio.value;
const sourceValue = radio.dataset.value;
selections.push({
field_name: fieldName,
source_system: sourceSystem,
source_value: sourceValue
});
});
// Confirm action
if (!confirm(`Du er ved at synkronisere ${selections.length} felt(er) på tværs af alle systemer. Fortsæt?`)) {
return;
}
// Sync each field
let successCount = 0;
let failCount = 0;
for (const selection of selections) {
try {
const response = await fetch(
`/api/v1/timetracking/customers/${pendingOrderCustomerId}/sync-field`,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(selection)
}
);
if (response.ok) {
successCount++;
} else {
failCount++;
console.error(`Failed to sync ${selection.field_name}`);
}
} catch (error) {
failCount++;
console.error(`Error syncing ${selection.field_name}:`, error);
}
}
// Close consistency modal
const consistencyModal = bootstrap.Modal.getInstance(document.getElementById('consistencyModal'));
consistencyModal.hide();
// Show result
if (failCount === 0) {
showToast(`✓ ${successCount} felt(er) synkroniseret succesfuldt!`, 'success');
} else {
showToast(`⚠️ ${successCount} felt(er) synkroniseret, ${failCount} fejlede`, 'warning');
}
// Now proceed with order creation - reopen order modal and load preview
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
document.getElementById('order-customer-name').textContent = customerName;
orderModal.show();
await loadOrderPreview(pendingOrderCustomerId, customerName);
}
function skipConsistencyCheck() {
// User chose to skip consistency check and proceed with order anyway
const customerName = allCustomers.find(c => c.id === pendingOrderCustomerId)?.name || 'Kunde';
const orderModal = new bootstrap.Modal(document.getElementById('createOrderModal'));
document.getElementById('order-customer-name').textContent = customerName;
orderModal.show();
loadOrderPreview(pendingOrderCustomerId, customerName);
}
</script> </script>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -1,273 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Tidsregistreringer - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Clean Filters */
.filter-card {
background: white;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.03);
}
/* Table Styling */
.registrations-table {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
overflow: hidden;
border: 1px solid #e2e8f0;
}
.registrations-table th {
background-color: #f8f9fa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
padding: 1rem;
border-bottom: 2px solid #e9ecef;
color: #64748b;
}
.registrations-table td {
vertical-align: middle;
padding: 1rem;
border-bottom: 1px solid #f1f5f9;
font-size: 0.9rem;
}
.registrations-table tr:hover {
background-color: #f8fafc;
}
.status-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
text-transform: uppercase;
font-weight: 600;
}
.status-pending { background: #fff3cd; color: #856404; }
.status-approved { background: #d1e7dd; color: #0f5132; }
.status-rejected { background: #f8d7da; color: #842029; }
.status-billed { background: #cfe2ff; color: #084298; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-1">Tidsregistreringer</h1>
<p class="text-muted mb-0">Søg og filtrer i alle registreringer</p>
</div>
<button class="btn btn-outline-primary" onclick="loadData()">
<i class="bi bi-arrow-clockwise"></i> Opdater
</button>
</div>
<!-- Filters -->
<div class="filter-card mb-4">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Søgning</label>
<div class="input-group">
<span class="input-group-text bg-white"><i class="bi bi-search"></i></span>
<input type="text" id="filter-search" class="form-control" placeholder="Kunde, beskrivelse, case..." onkeyup="debounceLoad()">
</div>
</div>
<div class="col-md-2">
<label class="form-label small text-muted text-uppercase fw-bold">Status</label>
<select id="filter-status" class="form-select" onchange="loadData()">
<option value="">Alle</option>
<option value="pending">Afventer</option>
<option value="approved">Godkendt</option>
<option value="billed">Faktureret</option>
<option value="rejected">Afvist</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small text-muted text-uppercase fw-bold">Tekniker</label>
<input type="text" id="filter-user" class="form-control" placeholder="Navn..." onkeyup="debounceLoad()">
</div>
</div>
</div>
<!-- Table -->
<div class="registrations-table">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th>Dato</th>
<th style="width: 20%;">Kunde</th>
<th style="width: 25%;">Beskrivelse / Case</th>
<th>Tekniker</th>
<th class="text-center">Timer</th>
<th class="text-center">Fakt.</th>
<th>Status</th>
<th class="text-end">Handlinger</th>
</tr>
</thead>
<tbody id="table-body">
<tr><td colspan="8" class="text-center py-5 text-muted">Henter data...</td></tr>
</tbody>
</table>
</div>
<!-- Pagination logic could go here -->
</div>
</div>
<!-- Details Modal -->
<div class="modal fade" id="detailsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Detaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="details-content">
<!-- Content -->
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script>
let debounceTimer;
document.addEventListener('DOMContentLoaded', () => {
loadData();
});
function debounceLoad() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadData, 500);
}
async function loadData() {
const tbody = document.getElementById('table-body');
const search = document.getElementById('filter-search').value;
const status = document.getElementById('filter-status').value;
const user = document.getElementById('filter-user').value;
// Build URL
const params = new URLSearchParams({
limit: 100, // Hardcoded limit for now
offset: 0
});
if (search) params.append('search', search);
if (status) params.append('status', status);
if (user) params.append('user_name', user);
try {
const response = await fetch(`/api/v1/timetracking/times?${params.toString()}`);
const data = await response.json();
if (data.times.length === 0) {
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5">Ingen resultater fundet</td></tr>`;
return;
}
tbody.innerHTML = data.times.map(t => `
<tr>
<td>
<div class="fw-bold">${formatDate(t.worked_date)}</div>
<div class="small text-muted">${t.created_at ? new Date(t.created_at).toLocaleTimeString().slice(0,5) : ''}</div>
</td>
<td>
<div class="fw-bold text-dark">${t.customer_name || 'Ukendt'}</div>
</td>
<td>
<div class="small fw-bold text-primary mb-1">
${t.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${t.case_vtiger_id.replace('39x','')}" target="_blank">${t.case_title || 'Ingen Case'}</a>` : (t.case_title || 'Ingen Case')}
</div>
<div class="text-secondary small" style="max-height: 3em; overflow: hidden; text-overflow: ellipsis;">
${t.description || '-'}
</div>
</td>
<td>
${t.user_name || '-'}
</td>
<td class="text-center">
<span class="badge bg-light text-dark border">${parseFloat(t.original_hours).toFixed(2)}</span>
</td>
<td class="text-center">
${t.billable ? '<i class="bi bi-check-circle-fill text-success"></i>' : '<i class="bi bi-dash-circle text-muted"></i>'}
</td>
<td>
<span class="status-badge status-${t.status}">${getStatusLabel(t.status)}</span>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" onclick="showDetails(${t.id})">
<i class="bi bi-eye"></i>
</button>
<a href="/timetracking/wizard2?customer_id=${t.customer_id}&time_id=${t.id}" class="btn btn-sm btn-outline-primary" title="Gå til godkendelse">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
`).join('');
} catch (error) {
console.error(error);
tbody.innerHTML = `<tr><td colspan="8" class="text-center py-5 text-danger">Fejl ved hentning af data</td></tr>`;
}
}
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString('da-DK');
}
function getStatusLabel(status) {
const labels = {
'pending': 'Afventer',
'approved': 'Godkendt',
'rejected': 'Afvist',
'billed': 'Faktureret'
};
return labels[status] || status;
}
async function showDetails(id) {
const modal = new bootstrap.Modal(document.getElementById('detailsModal'));
const content = document.getElementById('details-content');
content.innerHTML = '<div class="text-center"><div class="spinner-border"></div></div>';
modal.show();
try {
const res = await fetch(`/api/v1/timetracking/times/${id}`);
const t = await res.json();
content.innerHTML = `
<table class="table table-bordered">
<tr><th>ID</th><td>${t.id}</td></tr>
<tr><th>Kunde</th><td>${t.customer_name}</td></tr>
<tr><th>Case</th><td>${t.case_title}</td></tr>
<tr><th>Beskrivelse</th><td>${t.description}</td></tr>
<tr><th>Timer</th><td>${t.original_hours}</td></tr>
<tr><th>Status</th><td>${t.status}</td></tr>
<tr><th>Raw Data</th><td><pre class="bg-light p-2 small">${JSON.stringify(t, null, 2)}</pre></td></tr>
</table>
`;
} catch (e) {
content.innerHTML = `<div class="alert alert-danger">Fejl: ${e.message}</div>`;
}
}
</script>
{% endblock %}

View File

@ -28,18 +28,6 @@ async def timetracking_wizard(request: Request):
return templates.TemplateResponse("timetracking/frontend/wizard.html", {"request": request}) return templates.TemplateResponse("timetracking/frontend/wizard.html", {"request": request})
@router.get("/timetracking/wizard2", response_class=HTMLResponse, name="timetracking_wizard_v2")
async def timetracking_wizard_v2(request: Request):
"""Time Tracking Wizard V2 - simplified approval"""
return templates.TemplateResponse("timetracking/frontend/wizard2.html", {"request": request})
@router.get("/timetracking/registrations", response_class=HTMLResponse, name="timetracking_registrations")
async def timetracking_registrations(request: Request):
"""Time Tracking Registrations - list view"""
return templates.TemplateResponse("timetracking/frontend/registrations.html", {"request": request})
@router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers") @router.get("/timetracking/customers", response_class=HTMLResponse, name="timetracking_customers")
async def timetracking_customers(request: Request): async def timetracking_customers(request: Request):
"""Time Tracking Customers - manage hourly rates""" """Time Tracking Customers - manage hourly rates"""

View File

@ -725,12 +725,6 @@
<i class="bi bi-car-front"></i> Indeholder kørsel <i class="bi bi-car-front"></i> Indeholder kørsel
</label> </label>
</div> </div>
<div class="form-check mt-2">
<input class="form-check-input" type="checkbox" id="billable-${e.id}" ${e.billable !== false ? 'checked' : ''}>
<label class="form-check-label" for="billable-${e.id}">
<i class="bi bi-cash-coin"></i> Fakturerbar
</label>
</div>
</div> </div>
<div class="mt-3"> <div class="mt-3">
@ -1162,10 +1156,6 @@
const travelCheckbox = document.getElementById(`travel-${entryId}`); const travelCheckbox = document.getElementById(`travel-${entryId}`);
const isTravel = travelCheckbox ? travelCheckbox.checked : false; const isTravel = travelCheckbox ? travelCheckbox.checked : false;
// Get billable checkbox state
const billableCheckbox = document.getElementById(`billable-${entryId}`);
const isBillable = billableCheckbox ? billableCheckbox.checked : true;
// Get approval note // Get approval note
const approvalNoteField = document.getElementById(`approval-note-${entryId}`); const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : ''; const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
@ -1180,7 +1170,6 @@
billable_hours: billableHours, billable_hours: billableHours,
hourly_rate: hourlyRate, hourly_rate: hourlyRate,
is_travel: isTravel, is_travel: isTravel,
billable: isBillable,
approval_note: approvalNote || null approval_note: approvalNote || null
}) })
}); });

View File

@ -1,924 +0,0 @@
{% extends "shared/frontend/base.html" %}
{% block title %}Godkend Tider V2 - BMC Hub{% endblock %}
{% block extra_css %}
<style>
/* Clean Table Design */
.approval-table {
background: white;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
overflow: hidden;
margin-bottom: 3rem !important; /* Increased spacing */
border: 1px solid #e2e8f0;
}
.approval-table th {
background-color: #f8f9fa;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
padding: 1rem;
border-bottom: 2px solid #e9ecef;
color: #64748b;
}
.approval-table td {
vertical-align: top; /* Align to top for better readability with long descriptions */
padding: 1.25rem 1rem;
border-bottom: 1px solid #f1f5f9;
}
.approval-table tbody tr:last-child td {
border-bottom: none;
}
.case-group-header {
background-color: #f1f5f9 !important; /* Lighter background */
border-left: 6px solid var(--accent); /* Thicker accent */
padding: 1.5rem !important;
border-bottom: 1px solid #e2e8f0;
}
.case-title {
font-weight: 800;
color: #1e293b;
font-size: 1.25rem;
letter-spacing: -0.5px;
}
.case-description-box {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 1rem;
margin-top: 0.75rem;
font-size: 0.9rem;
color: #475569;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.case-meta {
font-size: 0.85rem;
color: var(--text-secondary);
}
.entry-row:hover {
background-color: #f8fafc;
}
/* Input controls */
.hours-input {
width: 80px;
text-align: center;
font-weight: 600;
}
.description-cell {
max-width: 400px;
position: relative;
}
.description-text {
white-space: pre-wrap;
font-size: 0.95rem;
}
/* Floating Action Bar */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: white;
padding: 1rem;
box-shadow: 0 -4px 10px rgba(0,0,0,0.1);
z-index: 1000;
transform: translateY(100%);
transition: transform 0.3s ease-in-out;
}
.action-bar.visible {
transform: translateY(0);
}
/* Status Badges */
.badge-soft-warning {
background-color: rgba(255, 193, 7, 0.15);
color: #856404;
}
.badge-soft-success {
background-color: rgba(40, 167, 69, 0.15);
color: #155724;
}
/* Billable Toggle */
.billable-toggle {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
}
.billable-toggle.active {
opacity: 1;
color: var(--success);
}
.billable-toggle:not(.active) {
color: var(--secondary);
}
/* Travel Toggle */
.travel-toggle {
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
color: #6c757d;
}
.travel-toggle.active {
opacity: 1;
color: #fd7e14; /* Orange for travel */
}
/* Animation */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-in {
animation: fadeIn 0.4s ease-out forwards;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid py-4 px-4 m-0" style="max-width: 1600px; margin: 0 auto;">
<!-- Header Area -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="mb-1">
<i class="bi bi-check-all text-primary"></i> Godkend Tider (V2)
</h1>
<p class="text-muted mb-0">Hurtig godkendelse af tidsregistreringer pr. kunde</p>
</div>
<div>
<div class="d-flex gap-2">
<select id="customer-select" class="form-select" style="min-width: 300px;" onchange="changeCustomer(this.value)">
<option value="">Vælg kunde...</option>
</select>
<a href="/timetracking" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Tilbage
</a>
</div>
</div>
</div>
<!-- Loading State -->
<div id="loading-container" class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 text-muted">Henter tidsregistreringer...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="d-none text-center py-5">
<div class="display-1 text-muted mb-3"><i class="bi bi-check-circle"></i></div>
<h3>Alt godkendt!</h3>
<p class="text-muted">Ingen afventende tidsregistreringer for denne kunde.</p>
<button class="btn btn-outline-primary mt-2" onclick="loadCustomerList()">Opdater liste</button>
</div>
<!-- Main Content -->
<div id="main-content" class="d-none animate-in">
<!-- Summary Card -->
<div class="card mb-4 border-0 shadow-sm bg-primary text-white">
<div class="card-body p-4">
<div class="row align-items-center">
<div class="col-md-6">
<h2 id="customer-name" class="fw-bold mb-1">-</h2>
<div class="d-flex gap-3 text-white-50">
<span><i class="bi bi-tag"></i> Timepris: <span id="hourly-rate" class="fw-bold text-white">-</span> DKK</span>
<span><i class="bi bi-clock"></i> Afventer: <span id="pending-count" class="fw-bold text-white">-</span> stk</span>
</div>
</div>
<div class="col-md-6 text-end">
<div class="display-6 fw-bold"><span id="total-value">0,00</span> DKK</div>
<div class="text-white-50">Total værdi til godkendelse</div>
</div>
</div>
</div>
</div>
<!-- Group Actions -->
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="expandAll()">Fold alt ud</button>
<button class="btn btn-outline-secondary btn-sm" onclick="collapseAll()">Fold alt sammen</button>
</div>
<div class="d-flex gap-2">
<button class="btn btn-danger" onclick="rejectSelected()">
<i class="bi bi-x-circle"></i> Afvis Valgte
</button>
<button class="btn btn-success" onclick="approveAll()">
<i class="bi bi-check-circle"></i> Godkend Alle
</button>
</div>
</div>
<!-- Approval Table -->
<div id="entries-container">
<!-- Populated by JS -->
</div>
</div>
</div>
<!-- Sticky Action Bar -->
<div id="selection-bar" class="action-bar d-flex justify-content-between align-items-center">
<div>
<span class="fw-bold"><span id="selected-count">0</span> valgte</span>
<span class="text-muted mx-2">|</span>
<span class="text-primary fw-bold"><span id="selected-value">0,00</span> DKK</span>
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary" onclick="clearSelection()">Annuller</button>
<button class="btn btn-danger" onclick="rejectSelected()">Afvis</button>
<button class="btn btn-success" onclick="approveSelected()">Godkend</button>
</div>
</div>
<!-- Edit Modal -->
<div class="modal fade" id="editEntryModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Rediger Worklog</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="editEntryId">
<div class="mb-3">
<label class="form-label">Beskrivelse</label>
<textarea class="form-control" id="editDescription" rows="4"></textarea>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Timer</label>
<input type="number" class="form-control" id="editHours" step="0.25" min="0">
</div>
</div>
<!-- Hub Worklog Specific Fields -->
<div id="hubFields" class="d-none p-3 bg-light rounded mb-3">
<label class="form-label fw-bold">Hub Billing settings</label>
<select class="form-select" id="editBillingMethod">
<option value="invoice">Faktura</option>
<option value="internal">Intern / Ingen faktura</option>
<option value="warranty">Garanti</option>
<option value="unknown">❓ Ved ikke</option>
</select>
<div class="form-text mt-2">
<i class="bi bi-info-circle"></i> <strong>Note:</strong> Klippekort skal vælges direkte i ticket-detaljerne eller worklog review.
</div>
</div>
<!-- Module Time Specific Fields -->
<div id="moduleFields" class="d-none">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="editBillable">
<label class="form-check-label" for="editBillable">Fakturerbar</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" onclick="saveEntryChanges()">Gem ændringer</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
let currentCustomerId = new URLSearchParams(window.location.search).get('customer_id');
let currentTimeId = new URLSearchParams(window.location.search).get('time_id');
let currentCustomerData = null;
let customerList = [];
let pendingEntries = [];
let selectedEntries = new Set();
// Config
const DEFAULT_RATE = 1200;
document.addEventListener('DOMContentLoaded', async () => {
await loadCustomerList();
// Support linking via Hub Customer ID
if (!currentCustomerId) {
const params = new URLSearchParams(window.location.search);
const hubId = params.get('hub_id');
if (hubId) {
// Determine mapped customer from loaded list
const found = customerList.find(c => c.hub_customer_id == hubId);
if (found) {
currentCustomerId = found.customer_id;
window.history.replaceState({}, '', `?customer_id=${currentCustomerId}`);
// Update dropdown
const select = document.getElementById('customer-select');
if(select) select.value = currentCustomerId;
loadCustomerEntries(currentCustomerId);
}
}
} else {
loadCustomerEntries(currentCustomerId);
}
});
async function loadCustomerList() {
try {
// Fetch stats to know which customers have pending entries
const response = await fetch('/api/v1/timetracking/wizard/stats');
const stats = await response.json();
customerList = stats.filter(c => c.pending_count > 0);
const select = document.getElementById('customer-select');
select.innerHTML = '<option value="">Vælg kunde...</option>';
customerList.forEach(c => {
const option = document.createElement('option');
option.value = c.customer_id;
option.textContent = `${c.customer_name} (${c.pending_count})`;
if (parseInt(currentCustomerId) === c.customer_id) {
option.selected = true;
}
select.appendChild(option);
});
if (!currentCustomerId && customerList.length > 0) {
// Determine auto-select logic?
// For now, let user pick
} else if (!currentCustomerId) {
document.getElementById('loading-container').innerHTML = `
<div class="mt-5">
<i class="bi bi-check-circle-fill text-success display-1"></i>
<h3 class="mt-3">Alt er ajour!</h3>
<p class="text-muted">Ingen kunder afventer godkendelse lige nu.</p>
</div>
`;
}
} catch (error) {
console.error('Error loading customers:', error);
}
}
function changeCustomer(customerId) {
if (!customerId) return;
window.history.pushState({}, '', `?customer_id=${customerId}`);
currentCustomerId = customerId;
loadCustomerEntries(customerId);
}
async function loadCustomerEntries(customerId) {
document.getElementById('loading-container').classList.remove('d-none');
document.getElementById('main-content').classList.add('d-none');
document.getElementById('empty-state').classList.add('d-none');
try {
// First get customer details for rate
const custResponse = await fetch('/api/v1/timetracking/wizard/stats');
const allStats = await custResponse.json();
currentCustomerData = allStats.find(c => c.customer_id == customerId);
if (!currentCustomerData) {
// Might happen if there are no pending stats but we force-navigated?
// Fallback fetch
}
// Fetch entries. Since we don't have a direct "get all pending for customer" endpoint,
// we might need to iterate or create a new endpoint.
// But wait, the existing wizard.html fetches entries ONE BY ONE or by case.
// We need a way to get ALL pending entries for a customer.
// Let's use the router endpoint: /api/v1/timetracking/customers/{id}/times (but filter for pending)
const timesResponse = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
const timesData = await timesResponse.json();
// Filter only pending
// The endpoint returns ALL times. We filter in JS for now.
pendingEntries = timesData.times.filter(t => t.status === 'pending');
if (pendingEntries.length === 0) {
document.getElementById('loading-container').classList.add('d-none');
document.getElementById('empty-state').classList.remove('d-none');
return;
}
// Organize by Case
renderEntries();
updateSummary();
document.getElementById('loading-container').classList.add('d-none');
document.getElementById('main-content').classList.remove('d-none');
} catch (error) {
console.error('Error loading entries:', error);
document.getElementById('loading-container').innerHTML =
`<div class="alert alert-danger">Fejl ved indlæsning: ${error.message}</div>`;
}
}
function renderEntries() {
const container = document.getElementById('entries-container');
container.innerHTML = '';
// Group by Case ID
const groups = {};
pendingEntries.forEach(entry => {
const caseId = entry.case_id || 'no_case';
if (!groups[caseId]) {
groups[caseId] = {
title: entry.case_title || 'Ingen Case / Diverse',
meta: entry, // store for header info
entries: []
};
}
groups[caseId].entries.push(entry);
});
// Render each group
Object.entries(groups).forEach(([caseId, group]) => {
const groupDiv = document.createElement('div');
groupDiv.className = 'approval-table mb-4 animate-in';
// Header
const meta = group.meta;
const caseInfo = [];
if (meta.case_type) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_type}</span>`);
if (meta.case_priority) caseInfo.push(`<span class="badge bg-light text-dark border">${meta.case_priority}</span>`);
const contactName = getContactName(meta); // Helper needed?
const headerHtml = `
<div class="case-group-header p-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="case-title d-flex align-items-center gap-2">
${caseId === 'no_case' ? '<i class="bi bi-person-workspace text-secondary"></i>' : '<i class="bi bi-folder-fill text-primary"></i>'}
${meta.case_vtiger_id ? `<a href="https://bmcnetworks.od2.vtiger.com/index.php?module=HelpDesk&view=Detail&record=${meta.case_vtiger_id.replace('39x', '')}" target="_blank" class="text-decoration-none text-dark">${group.title} <i class="bi bi-box-arrow-up-right small ms-1"></i></a>` : `<span>${group.title}</span>`}
<span class="badge bg-white text-dark border ms-2">${meta.case_type || 'Support'}</span>
<span class="badge ${getPriorityBadgeClass(meta.case_priority)} ms-1">${meta.case_priority || 'Normal'}</span>
</div>
<!-- Case Description -->
${meta.case_description ? `
<div class="case-description-box">
<div class="fw-bold text-dark mb-1" style="font-size: 0.8rem; text-transform: uppercase;">Opgavebeskrivelse</div>
${truncateText(stripHtml(meta.case_description), 300)}
</div>
` : ''}
<div class="case-meta mt-2 text-muted small">
${contactName ? `<i class="bi bi-person me-1"></i> ${contactName}` : ''}
</div>
</div>
<div class="text-end">
<span class="badge bg-white text-primary border fs-6">${group.entries.length} poster</span>
</div>
</div>
</div>
`;
// Table
let rowsHtml = '';
group.entries.forEach(entry => {
rowsHtml += `
<tr class="entry-row" id="row-${entry.id}">
<td style="width: 40px;">
<input type="checkbox" class="form-check-input entry-checkbox"
data-case-id="${caseId}"
value="${entry.id}" onchange="toggleSelection(${entry.id})">
</td>
<td style="width: 100px; white-space: nowrap;">
<div class="fw-bold">${formatDate(entry.worked_date)}</div>
<div class="small text-muted">${entry.user_name || 'Ukendt'}</div>
</td>
<td class="description-cell">
<div class="description-text">${entry.description || '<em class="text-muted">Ingen beskrivelse</em>'}</div>
</td>
<td style="width: 100px;" class="text-center">
<i class="bi bi-check-circle-fill fs-4 billable-toggle ${entry.billable !== false ? 'active' : ''}"
onclick="toggleBillable(${entry.id})"
title="Fakturerbar?"></i>
</td>
<td style="width: 40px;" class="text-center">
<i class="bi bi-car-front fs-5 travel-toggle ${entry.is_travel ? 'active' : ''}"
onclick="toggleTravel(${entry.id})"
title="Kørsel?"></i>
</td>
<td style="width: 150px;">
<div class="small text-muted mb-1">Registreret: ${formatHoursMinutes(entry.original_hours)}</div>
<div class="input-group input-group-sm">
<input type="number" class="form-control hours-input"
id="hours-${entry.id}"
value="${roundUpToQuarter(entry.original_hours)}"
step="0.25" min="0.25"
onchange="updateRowTotal(${entry.id})">
<span class="input-group-text">t</span>
</div>
</td>
<td style="width: 120px;" class="text-end fw-bold">
<span id="total-${entry.id}">-</span>
</td>
<td style="width: 140px;" class="text-end">
<div class="btn-group btn-group-sm">
<button class="btn btn-outline-primary" onclick='openEditModal(${JSON.stringify(entry).replace(/'/g, "&#39;")})' title="Rediger">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-outline-success" onclick="approveOne(${entry.id})" title="Godkend">
<i class="bi bi-check-lg"></i>
</button>
</div>
</td>
</tr>
`;
});
groupDiv.innerHTML = `
${headerHtml}
<div class="table-responsive">
<table class="table mb-0">
<thead class="table-light">
<tr>
<th><input type="checkbox" class="form-check-input" onchange="toggleGroupSelection(this, '${caseId}')"></th>
<th>Dato</th>
<th>Beskrivelse</th>
<th class="text-center">Fakt.</th>
<th class="text-center">Kørsel</th>
<th>Timer</th>
<th class="text-end">Total (DKK)</th>
<th></th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
</div>
`;
container.appendChild(groupDiv);
// Note: We used to update totals here, but that crashed updateSummary loop
// because not all groups were rendered yet.
});
// Init totals AFTER all rows are in the DOM
pendingEntries.forEach(e => updateRowTotal(e.id));
// Highlight specific time_id if present
if (currentTimeId) {
const row = document.getElementById(`row-${currentTimeId}`);
if (row) {
row.scrollIntoView({ behavior: 'smooth', block: 'center' });
row.classList.add('table-warning'); // Bootstrap highlight
setTimeout(() => row.classList.remove('table-warning'), 3000);
}
}
}
function updateSummary() {
const name = currentCustomerData?.customer_name || 'Kunde';
const rate = currentCustomerData?.customer_rate || DEFAULT_RATE;
document.getElementById('customer-name').textContent = name;
document.getElementById('hourly-rate').textContent = parseFloat(rate).toFixed(2);
document.getElementById('pending-count').textContent = pendingEntries.length;
let totalValue = 0;
pendingEntries.forEach(entry => {
const hoursInput = document.getElementById(`hours-${entry.id}`);
if (!hoursInput) return; // Skip if not rendered yet
const hours = parseFloat(hoursInput.value || entry.original_hours);
// Only count if billable
const toggle = document.querySelector(`#row-${entry.id} .billable-toggle`);
const isBillable = toggle && toggle.classList.contains('active');
if (isBillable) {
totalValue += hours * rate;
}
});
document.getElementById('total-value').textContent = totalValue.toLocaleString('da-DK', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function updateRowTotal(entryId) {
const row = document.getElementById(`row-${entryId}`);
if (!row) return;
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
const hoursInput = document.getElementById(`hours-${entryId}`);
const hours = parseFloat(hoursInput.value || 0);
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
const isBillable = toggle && toggle.classList.contains('active');
const totalElem = document.getElementById(`total-${entryId}`);
if (isBillable) {
const total = hours * rate;
totalElem.textContent = total.toLocaleString('da-DK', { minimumFractionDigits: 2 });
totalElem.classList.remove('text-muted', 'text-decoration-line-through');
row.style.opacity = '1';
} else {
totalElem.textContent = '0,00';
totalElem.classList.add('text-muted', 'text-decoration-line-through');
// Visual feedback for non-billable
row.style.opacity = '0.7';
}
updateSummary();
updateSelectionBar();
}
function toggleBillable(entryId) {
const toggle = document.querySelector(`#row-${entryId} .billable-toggle`);
toggle.classList.toggle('active');
updateRowTotal(entryId);
}
function toggleTravel(entryId) {
const toggle = document.querySelector(`#row-${entryId} .travel-toggle`);
toggle.classList.toggle('active');
}
function formatDate(dateStr) {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('da-DK', { day: '2-digit', month: '2-digit' });
}
function formatHoursMinutes(decimalHours) {
if (!decimalHours) return '0t 0m';
const hours = Math.floor(decimalHours);
const minutes = Math.floor((decimalHours - hours) * 60);
if (hours === 0) {
return `${minutes}m`;
} else if (minutes === 0) {
return `${hours}t`;
} else {
return `${hours}t ${minutes}m`;
}
}
function roundUpToQuarter(hours) {
// Round up to nearest 0.5 (30 minutes)
return Math.ceil(hours * 2) / 2;
}
function getContactName(meta) {
// vtiger data extraction if needed
return null;
}
// --- Selection Logic ---
function getPriorityBadgeClass(priority) {
if (!priority) return 'bg-light text-dark border';
const p = priority.toLowerCase();
if (p.includes('høj') || p.includes('urgent') || p.includes('high') || p.includes('critical')) return 'bg-danger text-white';
if (p.includes('mellem') || p.includes('medium')) return 'bg-warning text-dark';
if (p.includes('lav') || p.includes('low')) return 'bg-success text-white';
return 'bg-light text-dark border';
}
function stripHtml(html) {
if (!html) return '';
const tmp = document.createElement("DIV");
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || "";
}
function truncateText(text, length) {
if (!text) return '';
if (text.length <= length) return text;
return text.substring(0, length) + '...';
}
function toggleSelection(entryId) {
if (selectedEntries.has(entryId)) {
selectedEntries.delete(entryId);
} else {
selectedEntries.add(entryId);
}
updateSelectionBar();
}
function toggleGroupSelection(checkbox, caseId) {
const checkboxes = document.querySelectorAll(`.entry-checkbox[data-case-id="${caseId}"]`);
checkboxes.forEach(cb => {
if (cb.checked !== checkbox.checked) {
cb.checked = checkbox.checked;
// Update selection set via toggleSelection logic
// Since toggleSelection expects the ID and relies on current state,
// we can just call it if the state mismatch.
// However, toggleSelection toggles based on set presence.
// It's safer to manually manipulate the set here.
const id = parseInt(cb.value);
if (checkbox.checked) {
selectedEntries.add(id);
} else {
selectedEntries.delete(id);
}
}
});
updateSelectionBar();
}
function updateSelectionBar() {
const bar = document.getElementById('selection-bar');
const count = selectedEntries.size;
if (count > 0) {
bar.classList.add('visible');
document.getElementById('selected-count').textContent = count;
// Calc value of selection
let val = 0;
const rate = parseFloat(currentCustomerData?.customer_rate || DEFAULT_RATE);
selectedEntries.forEach(id => {
const hours = parseFloat(document.getElementById(`hours-${id}`).value || 0);
const isBillable = document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active');
if (isBillable) val += hours * rate;
});
document.getElementById('selected-value').textContent = val.toLocaleString('da-DK', {minimumFractionDigits: 2});
} else {
bar.classList.remove('visible');
}
}
function clearSelection() {
selectedEntries.clear();
document.querySelectorAll('.entry-checkbox').forEach(cb => cb.checked = false);
updateSelectionBar();
}
// --- Actions ---
async function approveOne(entryId) {
await processApproval([entryId]);
}
async function approveSelected() {
await processApproval(Array.from(selectedEntries));
}
async function approveAll() {
const allIds = pendingEntries.map(e => e.id);
if (confirm(`Er du sikker på du vil godkende alle ${allIds.length} tidsregistreringer?`)) {
await processApproval(allIds);
}
}
async function processApproval(ids) {
// Prepare payload with current values (hours, billable state)
const items = ids.map(id => {
return {
id: id,
billable_hours: parseFloat(document.getElementById(`hours-${id}`).value),
hourly_rate: currentCustomerData?.customer_rate || DEFAULT_RATE,
billable: document.querySelector(`#row-${id} .billable-toggle`).classList.contains('active'),
is_travel: document.querySelector(`#row-${id} .travel-toggle`).classList.contains('active')
};
});
// We accept list approval via a loop or new bulk endpoint.
// Let's loop for now to reuse existing endpoint or create a bulk one.
// It's safer to implement a bulk endpoint in backend, but for speed let's iterate.
// Actually, let's just make a specialized bulk endpoint or reuse the loop in JS
let successCount = 0;
// Show loading overlay
document.getElementById('loading-container').classList.remove('d-none');
document.getElementById('loading-container').innerHTML = '<div class="spinner-border text-primary"></div><p>Behandler godkendelser...</p>';
document.getElementById('main-content').classList.add('d-none');
try {
for (const item of items) {
await fetch(`/api/v1/timetracking/wizard/approve/${item.id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(item)
});
successCount++;
}
// Reload
loadCustomerEntries(currentCustomerId);
// Also refresh stats
loadCustomerList();
clearSelection();
} catch (e) {
alert('Fejl under godkendelse: ' + e.message);
loadCustomerEntries(currentCustomerId); // Refresh anyway
}
}
async function rejectSelected() {
const ids = Array.from(selectedEntries);
if (ids.length === 0) return;
const note = prompt("Begrundelse for afvisning:");
if (note === null) return;
// Loop reject
for (const id of ids) {
await fetch(`/api/v1/timetracking/wizard/reject/${id}`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ rejection_note: note })
});
}
loadCustomerEntries(currentCustomerId);
clearSelection();
}
// --- Edit Modal Logic ---
let editModal = null;
function openEditModal(entry) {
if (!editModal) {
editModal = new bootstrap.Modal(document.getElementById('editEntryModal'));
}
document.getElementById('editEntryId').value = entry.id;
document.getElementById('editDescription').value = entry.description || '';
document.getElementById('editHours').value = entry.original_hours;
const hubFields = document.getElementById('hubFields');
const moduleFields = document.getElementById('moduleFields');
if (entry.id < 0) {
// Hub Worklog
hubFields.classList.remove('d-none');
moduleFields.classList.add('d-none');
// Assuming _billing_method was passed via backend view logic
const billing = entry._billing_method || 'invoice';
document.getElementById('editBillingMethod').value = billing;
} else {
// Module Time
hubFields.classList.add('d-none');
moduleFields.classList.remove('d-none');
document.getElementById('editBillable').checked = entry.billable !== false;
}
editModal.show();
}
async function saveEntryChanges() {
const id = document.getElementById('editEntryId').value;
const desc = document.getElementById('editDescription').value;
const hours = document.getElementById('editHours').value;
const payload = {
description: desc,
original_hours: parseFloat(hours)
};
if (parseInt(id) < 0) {
payload.billing_method = document.getElementById('editBillingMethod').value;
} else {
payload.billable = document.getElementById('editBillable').checked;
}
try {
const res = await fetch(`/api/v1/timetracking/wizard/entry/${id}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error("Update failed");
editModal.hide();
// Reload EVERYTHING because changing hours/billing affects sums
loadCustomerEntries(currentCustomerId);
} catch (e) {
alert("Kunne ikke gemme: " + e.message);
}
}
</script>
{% endblock %}

View File

@ -1,47 +0,0 @@
#!/usr/bin/env python3
"""Create test draft invoice for customer 702007707 (Blåhund)"""
import asyncio
import aiohttp
import json
import os
async def create_invoice():
secret = os.getenv("ECONOMIC_APP_SECRET_TOKEN", "wy8ZhYBLsKhx8McirhvoBR9B6ILuoYJkEaiED5ijsA8")
grant = os.getenv("ECONOMIC_AGREEMENT_GRANT_TOKEN", "5AhipRpMpoLx3uklPMQZbtZ4Zw4mV9lDuFI264II0lE")
headers = {
"X-AppSecretToken": secret,
"X-AgreementGrantToken": grant,
"Content-Type": "application/json"
}
invoice_data = {
"customer": {
"customerNumber": 702007707
},
"date": "2026-01-20",
"lines": [
{
"product": {
"productNumber": "ABONNEMENT-BLAHUND"
},
"description": "Abonnement Blåhund",
"quantity": 1,
"unitNetPrice": 695.00
}
]
}
async with aiohttp.ClientSession() as session:
async with session.post(
"https://restapi.e-conomic.com/invoices/drafts",
headers=headers,
json=invoice_data
) as resp:
print(f"Status: {resp.status}")
data = await resp.json()
print(json.dumps(data, indent=2, default=str)[:800])
if __name__ == "__main__":
asyncio.run(create_invoice())

View File

@ -48,10 +48,6 @@ else
exit 1 exit 1
fi fi
# Clean up old images (keep last 14 days)
echo "🧹 Cleaning up images older than 14 days..."
ssh $PROD_SERVER "sudo podman image prune -a -f --filter 'until=336h'"
echo "" echo ""
echo "🎉 Production is now running version $VERSION" echo "🎉 Production is now running version $VERSION"
echo " http://172.16.31.183:8000" echo " http://172.16.31.183:8000"

View File

@ -1,246 +0,0 @@
# Data Consistency Checking System - Implementation Complete
## 📅 Implementation Date
8. januar 2026
## 🎯 Overview
Implemented a comprehensive data consistency checking system that automatically compares customer data across BMC Hub, vTiger Cloud, and e-conomic when loading a customer detail page. The system detects discrepancies and allows manual selection of the correct value to sync across all systems.
## ✅ Completed Features
### 1. Configuration Variables (`app/core/config.py`)
Added three new boolean flags to the Settings class:
- `VTIGER_SYNC_ENABLED: bool = True` - Enable/disable vTiger sync operations
- `ECONOMIC_SYNC_ENABLED: bool = True` - Enable/disable e-conomic sync operations
- `AUTO_CHECK_CONSISTENCY: bool = True` - Enable/disable automatic consistency checking
### 2. CustomerConsistencyService (`app/services/customer_consistency.py`)
Created a new service with the following functionality:
#### Field Mapping
Maps 11 customer fields across all three systems:
- name → accountname (vTiger) / name (e-conomic)
- cvr_number → cf_856 / corporateIdentificationNumber
- address → bill_street / address
- city → bill_city / city
- postal_code → bill_code / zip
- country → bill_country / country
- phone → phone / telephoneAndFaxNumber
- mobile_phone → mobile / mobilePhone
- email → email1 / email
- website → website / website
- invoice_email → email2 / email
#### Key Methods
- **`normalize_value()`**: Normalizes values for comparison (strip, lowercase, None handling)
- **`fetch_all_data()`**: Fetches customer data from all three systems in parallel using `asyncio.gather()`
- **`compare_data()`**: Compares normalized values and identifies discrepancies
- **`sync_field()`**: Updates a field across all enabled systems with the selected correct value
### 3. Backend API Endpoints (`app/customers/backend/router.py`)
Added two new endpoints:
#### GET `/api/v1/customers/{customer_id}/data-consistency`
- Checks if consistency checking is enabled
- Fetches data from all systems in parallel
- Compares all fields and counts discrepancies
- Returns:
```json
{
"enabled": true,
"customer_id": 123,
"discrepancy_count": 3,
"discrepancies": {
"address": {
"hub": "Hovedgaden 1",
"vtiger": "Hovedgade 1",
"economic": "Hovedgaden 1",
"discrepancy": true
}
},
"systems_available": {
"hub": true,
"vtiger": true,
"economic": false
}
}
```
#### POST `/api/v1/customers/{customer_id}/sync-field`
- Query parameters:
- `field_name`: Hub field name (e.g., "address")
- `source_system`: "hub", "vtiger", or "economic"
- `source_value`: The correct value to sync
- Updates the field in all systems
- Respects safety flags (ECONOMIC_READ_ONLY, ECONOMIC_DRY_RUN)
- Returns sync status for each system
### 4. Frontend Alert Box (`app/customers/frontend/customer_detail.html`)
Added a Bootstrap warning alert that:
- Displays after customer header when discrepancies are found
- Shows discrepancy count dynamically
- Has a "Sammenlign" (Compare) button to open the modal
- Is dismissible
- Hidden by default with `.d-none` class
### 5. Comparison Modal (`app/customers/frontend/customer_detail.html`)
Created a modal-xl Bootstrap modal with:
- Table showing all discrepant fields
- 5 columns: Felt (Field), BMC Hub, vTiger, e-conomic, Vælg Korrekt (Select Correct)
- Radio buttons for each system's value
- Danish field labels (Navn, CVR Nummer, Adresse, etc.)
- Visual indicators for unavailable systems
- "Synkroniser Valgte" (Sync Selected) button
### 6. JavaScript Functions (`app/customers/frontend/customer_detail.html`)
Implemented three main functions:
#### `checkDataConsistency()`
- Called automatically when customer loads
- Fetches consistency data from API
- Shows/hides alert based on discrepancy count
- Stores data in `consistencyData` global variable
#### `showConsistencyModal()`
- Populates modal table with only discrepant fields
- Creates radio buttons dynamically for each system
- Uses Danish field labels
- Handles unavailable systems gracefully
#### `syncSelectedFields()`
- Collects all selected radio button values
- Validates at least one selection
- Shows confirmation dialog
- Calls sync API for each field sequentially
- Shows success/failure count
- Reloads customer data and rechecks consistency
### 7. VTiger Service Updates (`app/services/vtiger_service.py`)
Added two new methods:
- **`get_account_by_id(account_id)`**: Fetches single account by vTiger ID
- **`update_account(account_id, update_data)`**: Updates account fields via REST API
### 8. E-conomic Service Updates (`app/services/economic_service.py`)
Added two new methods:
- **`get_customer(customer_number)`**: Fetches single customer by e-conomic number
- **`update_customer(customer_number, update_data)`**: Updates customer fields (respects safety flags)
## 🔧 Technical Implementation Details
### Parallel API Calls
Uses `asyncio.gather()` with `return_exceptions=True` to fetch from vTiger and e-conomic simultaneously:
```python
tasks = {'vtiger': vtiger_task, 'economic': economic_task}
task_results = await asyncio.gather(*tasks.values(), return_exceptions=True)
```
### Value Normalization
All values are normalized before comparison:
- Convert to string
- Strip whitespace
- Lowercase for case-insensitive comparison
- Empty strings → None
### Safety Flags
Respects existing e-conomic safety flags:
- `ECONOMIC_READ_ONLY=True` prevents all write operations
- `ECONOMIC_DRY_RUN=True` logs operations without executing
### Error Handling
- Individual system failures don't block the entire operation
- Exceptions are logged with appropriate emoji prefixes (✅ ❌ ⚠️)
- Frontend shows user-friendly messages
## 🎨 User Experience
### Workflow
1. User opens customer detail page (e.g., `/customers/23`)
2. `loadCustomer()` automatically calls `checkDataConsistency()`
3. If discrepancies found, yellow alert appears at top
4. User clicks "Sammenlign" button
5. Modal opens showing table with radio buttons
6. User selects correct value for each field
7. User clicks "Synkroniser Valgte"
8. Confirmation dialog appears
9. Selected fields sync to all systems
10. Page reloads with updated data
### Visual Design
- Uses existing Nordic Top design system
- Bootstrap 5 components (alerts, modals, tables)
- Consistent with BMC Hub's minimalist aesthetic
- Danish language throughout
## 📝 Configuration
### Environment Variables (.env)
```bash
# Data Consistency Settings
VTIGER_SYNC_ENABLED=True
ECONOMIC_SYNC_ENABLED=True
AUTO_CHECK_CONSISTENCY=True
# Safety Flags (respect existing)
ECONOMIC_READ_ONLY=True
ECONOMIC_DRY_RUN=True
```
### Disabling Features
- Set `AUTO_CHECK_CONSISTENCY=False` to disable automatic checks
- Set `VTIGER_SYNC_ENABLED=False` to prevent vTiger updates
- Set `ECONOMIC_SYNC_ENABLED=False` to prevent e-conomic updates
## 🚀 Deployment
### Status
✅ **DEPLOYED AND RUNNING**
The system has been:
1. Code implemented in all necessary files
2. Docker API container restarted successfully
3. Service running without errors (confirmed via logs)
4. Ready for testing at http://localhost:8001/customers/{id}
### Testing Checklist
- [ ] Open customer detail page
- [ ] Verify alert appears if discrepancies exist
- [ ] Click "Sammenlign" and verify modal opens
- [ ] Select values and click "Synkroniser Valgte"
- [ ] Confirm data syncs across systems
- [ ] Verify safety flags prevent unwanted writes
## 📚 Files Modified
1. `/app/core/config.py` - Added 3 config variables
2. `/app/services/customer_consistency.py` - NEW FILE (280 lines)
3. `/app/customers/backend/router.py` - Added 2 endpoints (~100 lines)
4. `/app/customers/frontend/customer_detail.html` - Added alert, modal, and JS functions (~250 lines)
5. `/app/services/vtiger_service.py` - Added 2 methods (~90 lines)
6. `/app/services/economic_service.py` - Added 2 methods (~75 lines)
**Total new code:** ~795 lines
## 🎓 Key Learnings
1. **Parallel async operations** are essential for performance when querying multiple external APIs
2. **Data normalization** is critical for accurate comparison (whitespace, case sensitivity, null handling)
3. **Progressive enhancement** - system degrades gracefully if external APIs are unavailable
4. **Safety-first approach** - dry-run and read-only flags prevent accidental data corruption
5. **User-driven sync** - manual selection ensures humans make final decisions on data conflicts
## 🔮 Future Enhancements
Potential improvements for future iterations:
- Auto-suggest most common value (modal default selection)
- Batch sync all fields with single button
- Conflict history log
- Scheduled consistency checks (background job)
- Email notifications for critical discrepancies
- Automatic sync rules (e.g., "always trust e-conomic for financial data")
- Conflict resolution confidence scores
## ✅ Implementation Complete
All planned features have been successfully implemented and deployed. The data consistency checking system is now active and ready for use.
**Next Steps:** Test the system with real customer data to ensure all integrations work correctly.

File diff suppressed because it is too large Load Diff

View File

@ -1,179 +0,0 @@
# Time Tracking - Billed Via Hub Order ID
## Oversigt
Implementeret tracking af hvilken Hub ordre (og dermed e-conomic ordre) hver tidsregistrering er blevet faktureret gennem.
## Database Ændringer
### Migration: `060_add_billed_via_thehub_id.sql`
Tilføjet nyt felt til `tmodule_times` tabellen:
```sql
ALTER TABLE tmodule_times ADD COLUMN billed_via_thehub_id INTEGER;
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_billed_via_thehub_id_fkey
FOREIGN KEY (billed_via_thehub_id)
REFERENCES tmodule_orders(id)
ON DELETE SET NULL;
CREATE INDEX idx_tmodule_times_billed_via_thehub_id ON tmodule_times(billed_via_thehub_id);
```
**Felt beskrivelse:**
- `billed_via_thehub_id`: Hub ordre ID som tidsregistreringen er faktureret gennem
- Foreign key til `tmodule_orders.id`
- Via Hub ordren kan man finde `economic_order_number` som er ordrenummeret i e-conomic
## Kode Ændringer
### 1. `app/timetracking/backend/models.py`
Tilføjet felt til `TModuleTime` Pydantic model:
```python
billed_via_thehub_id: Optional[int] = Field(None, description="Hub order ID this time was billed through")
is_travel: bool = False # Også tilføjet manglende felt
```
### 2. `app/timetracking/backend/economic_export.py`
Opdateret `export_order_to_economic()` til at sætte `billed_via_thehub_id` når ordren eksporteres:
```python
# Marker time entries som billed og opdater billed_via_thehub_id
execute_update(
"""UPDATE tmodule_times
SET status = 'billed',
billed_via_thehub_id = %s
WHERE id IN (
SELECT UNNEST(time_entry_ids)
FROM tmodule_order_lines
WHERE order_id = %s
)""",
(request.order_id, request.order_id)
)
```
**Vigtig note:** vTiger opdateres også via `update_timelog_billed()` som sætter `billed_via_thehub_id` i vTiger's Timelog records.
### 3. `app/timetracking/backend/order_service.py`
**FJERNET** for tidlig status opdatering:
```python
# BEFORE: Time entries blev sat til 'billed' når Hub ordre blev oprettet
# AFTER: Time entries forbliver 'approved' indtil e-conomic eksporten er succesfuld
```
Nu opdateres `status='billed'` og `billed_via_thehub_id` KUN i `economic_export.py` efter succesfuld eksport.
### 4. `app/timetracking/backend/vtiger_sync.py`
**BESKYTTELSE MOD OVERSKRIVNING:** Tidsregistreringer med `billed_via_thehub_id` opdateres IKKE ved sync:
```python
# Update only if NOT yet approved AND NOT yet billed
result = execute_update(
"""UPDATE tmodule_times
SET description = %s, original_hours = %s, worked_date = %s,
user_name = %s, billable = %s, vtiger_data = %s::jsonb,
sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP
WHERE vtiger_id = %s
AND status = 'pending'
AND billed_via_thehub_id IS NULL""",
...
)
```
Dette sikrer at allerede fakturerede tidsregistreringer forbliver låst og ikke overskrevet af vTiger sync.
## Workflow
### Før ændringerne:
1. Godkendte tider → `status='approved'`
2. **Opret ordre** i Hub → `status='billed'` ⚠️ (for tidligt!)
3. Eksporter til e-conomic → `economic_order_number` sættes på Hub ordre
### Efter ændringerne:
1. Godkendte tider → `status='approved'`
2. **Opret ordre** i Hub → tider forbliver `status='approved'`
3. **Eksporter til e-conomic**`status='billed'` + `billed_via_thehub_id` sættes
4. vTiger opdateres også med `billed_via_thehub_id`
## Data Relations
```
tmodule_times.billed_via_thehub_id
↓ (foreign key)
tmodule_orders.id
→ tmodule_orders.economic_order_number (e-conomic ordre nummer)
→ tmodule_orders.economic_draft_id (e-conomic kladde ID)
```
## Queries
### Find alle tidsregistreringer for en e-conomic ordre:
```sql
SELECT t.*, o.economic_order_number
FROM tmodule_times t
JOIN tmodule_orders o ON t.billed_via_thehub_id = o.id
WHERE o.economic_order_number = '12345';
```
### Find tider der er faktureret via en specifik Hub ordre:
```sql
SELECT * FROM tmodule_times
WHERE billed_via_thehub_id = 123;
```
### Find tider der IKKE er faktureret endnu:
```sql
SELECT * FROM tmodule_times
WHERE status = 'approved'
AND billed_via_thehub_id IS NULL;
```
## vTiger Integration
vTiger's `Timelog` records opdateres også via `app/timetracking/backend/vtiger_sync.py`:
```python
async def update_timelog_billed(self, vtiger_ids: List[str], hub_order_id: int):
payload = {
"elementType": "Timelog",
"element": {
"id": vtiger_id,
"billed_via_thehub_id": str(hub_order_id),
"cf_timelog_invoiced": "1"
}
}
```
**Note:** `cf_timelog_invoiced` (IS BILLED) feltet i vTiger kan vi IKKE ændre fra Hub (vTiger begrænsning), men vi opdaterer vores eget `billed_via_thehub_id` felt.
## Testing
Migrationen er kørt og verificeret:
```bash
docker exec bmc-hub-postgres psql -U bmc_hub -d bmc_hub -c "\d tmodule_times"
# Viser: billed_via_thehub_id | integer med foreign key
```
## Deployment
1. Migration er kørt på dev (060_add_billed_via_thehub_id.sql)
2. Kode ændringer deployed i samme commit
3. Eksisterende tidsregistreringer har `billed_via_thehub_id = NULL`
4. Fremtidige eksporter vil populere feltet korrekt
## Relaterede Filer
- `migrations/060_add_billed_via_thehub_id.sql`
- `app/timetracking/backend/models.py`
- `app/timetracking/backend/economic_export.py`
- `app/timetracking/backend/order_service.py`
- `app/timetracking/backend/vtiger_sync.py`

View File

@ -1,241 +0,0 @@
# GitHub Copilot Instructions - BMC Webshop (Frontend)
## Project Overview
BMC Webshop er en kunde-styret webshop løsning, hvor **BMC Hub** ejer indholdet, **API Gateway** (`apigateway.bmcnetworks.dk`) styrer logikken, og **Webshoppen** (dette projekt) kun viser og indsamler input.
**Tech Stack**: React/Next.js/Vue.js (vælg én), TypeScript, Tailwind CSS eller Bootstrap 5
---
## 3-Lags Arkitektur
```
┌─────────────────────────────────────────────────────────┐
│ TIER 1: BMC HUB (Admin System) │
│ - Administrerer webshop-opsætning │
│ - Pusher data til Gateway │
│ - Poller Gateway for nye ordrer │
│ https://hub.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▼ (Push config)
┌─────────────────────────────────────────────────────────┐
│ TIER 2: API GATEWAY (Forretningslogik + Database) │
│ - Modtager og gemmer webshop-config fra Hub │
│ - Ejer PostgreSQL database med produkter, priser, ordrer│
│ - Håndterer email/OTP login │
│ - Beregner priser og filtrerer varer │
│ - Leverer sikre API'er til Webshoppen │
│ https://apigateway.bmcnetworks.dk │
└─────────────────────────────────────────────────────────┘
▲ (API calls)
┌─────────────────────────────────────────────────────────┐
│ TIER 3: WEBSHOP (Dette projekt - Kun Frontend) │
│ - Viser logo, tekster, produkter, priser │
│ - Shopping cart (kun i frontend state) │
│ - Sender ordre som payload til Gateway │
│ - INGEN forretningslogik eller datapersistering │
└─────────────────────────────────────────────────────────┘
```
---
## Webshoppens Ansvar
### ✅ Hvad Webshoppen GØR
- Viser kundens logo, header-tekst, intro-tekst (fra Gateway)
- Viser produktkatalog med navn, beskrivelse, pris (fra Gateway)
- Samler kurv i browser state (localStorage/React state)
- Sender ordre til Gateway ved checkout
- Email/OTP login flow (kalder Gateway's auth-endpoint)
### ❌ Hvad Webshoppen IKKE GØR
- Gemmer INGEN data (hverken kurv, produkter, eller ordrer)
- Beregner INGEN priser eller avance
- Håndterer INGEN produkt-filtrering (Gateway leverer klar liste)
- Snakker IKKE direkte med Hub eller e-conomic
- Håndterer IKKE betalingsgateway (Gateway's ansvar)
---
## API Gateway Kontrakt
Base URL: `https://apigateway.bmcnetworks.dk`
### 1. Login med Email + Engangskode
**Step 1: Anmod om engangskode**
```http
POST /webshop/auth/request-code
Content-Type: application/json
{
"email": "kunde@firma.dk"
}
Response 200:
{
"success": true,
"message": "Engangskode sendt til kunde@firma.dk"
}
```
**Step 2: Verificer kode og få JWT token**
```http
POST /webshop/auth/verify-code
Content-Type: application/json
{
"email": "kunde@firma.dk",
"code": "123456"
}
Response 200:
{
"success": true,
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"customer_id": 42,
"expires_at": "2026-01-13T15:00:00Z"
}
```
### 2. Hent Webshop Context (Komplet Webshop-Data)
```http
GET /webshop/{customer_id}/context
Authorization: Bearer {jwt_token}
Response 200:
{
"customer_id": 42,
"company_name": "Advokatfirma A/S",
"config_version": "2026-01-13T12:00:00Z",
"branding": {
"logo_url": "https://apigateway.bmcnetworks.dk/assets/logos/42.png",
"header_text": "Velkommen til vores webshop",
"intro_text": "Bestil nemt og hurtigt direkte her.",
"primary_color": "#0f4c75",
"accent_color": "#3282b8"
},
"products": [
{
"id": 101,
"ean": "5711045071324",
"product_number": "FIRE-001",
"name": "Cisco Firewall ASA 5506-X",
"description": "Next-generation firewall med 8 porte",
"unit": "stk",
"base_price": 8500.00,
"calculated_price": 9350.00,
"margin_percent": 10.0,
"currency": "DKK",
"stock_available": true,
"category": "Network Security"
}
],
"allowed_payment_methods": ["invoice", "card"],
"min_order_amount": 500.00,
"shipping_cost": 0.00
}
```
### 3. Opret Ordre
```http
POST /webshop/orders
Authorization: Bearer {jwt_token}
Content-Type: application/json
{
"customer_id": 42,
"order_items": [
{
"product_id": 101,
"quantity": 2,
"unit_price": 9350.00
}
],
"shipping_address": {
"company_name": "Advokatfirma A/S",
"street": "Hovedgaden 1",
"postal_code": "1000",
"city": "København K",
"country": "DK"
},
"delivery_note": "Levering til bagsiden, ring på døren",
"total_amount": 18700.00
}
Response 201:
{
"success": true,
"order_id": "ORD-2026-00123",
"status": "pending",
"total_amount": 18700.00,
"created_at": "2026-01-13T14:30:00Z",
"message": "Ordre modtaget. Du vil modtage en bekræftelse på email."
}
```
---
## Frontend Krav
### Mandatory Features
1. **Responsive Design**
- Mobile-first approach
- Breakpoints: 576px (mobile), 768px (tablet), 992px (desktop)
2. **Dark Mode Support**
- Toggle mellem light/dark theme
- Gem præference i localStorage
3. **Shopping Cart**
- Gem kurv i localStorage (persist ved page reload)
- Vis antal varer i header badge
- Real-time opdatering af total pris
4. **Login Flow**
- Email input → Send kode
- Vis countdown timer (5 minutter)
- Verificer kode → Få JWT token
- Auto-logout ved token expiry
5. **Product Catalog**
- Vis produkter i grid layout
- Søgning i produktnavn/beskrivelse
- "Tilføj til kurv" knap
6. **Checkout Flow**
- Vis kurv-oversigt
- Leveringsadresse
- "Bekræft ordre" knap
- Success/error feedback
### Design Guidelines
**Stil**: Minimalistisk, clean, "Nordic" æstetik
**Farver** (kan overskrives af Gateway's branding):
- Primary: `#0f4c75` (Deep Blue)
- Accent: `#3282b8` (Bright Blue)
---
## Security
1. **HTTPS Only** - Al kommunikation med Gateway over HTTPS
2. **JWT Token** - Gem i localStorage, send i Authorization header
3. **Input Validation** - Validér email, antal, adresse
4. **CORS** - Gateway skal have CORS headers
---
## Common Pitfalls to Avoid
1. **Gem IKKE data i Webshoppen** - alt kommer fra Gateway
2. **Beregn IKKE priser selv** - Gateway leverer `calculated_price`
3. **Snakker IKKE direkte med Hub** - kun via Gateway
4. **Gem IKKE kurv i database** - kun localStorage
5. **Hardcode IKKE customer_id** - hent fra JWT token

25
main.py
View File

@ -50,13 +50,6 @@ from app.settings.backend import router as settings_api
from app.settings.backend import views as settings_views from app.settings.backend import views as settings_views
from app.backups.backend.router import router as backups_api from app.backups.backend.router import router as backups_api
from app.backups.frontend import views as backups_views from app.backups.frontend import views as backups_views
from app.backups.backend.scheduler import backup_scheduler
from app.conversations.backend import router as conversations_api
from app.conversations.frontend import views as conversations_views
# Modules
from app.modules.webshop.backend import router as webshop_api
from app.modules.webshop.frontend import views as webshop_views
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@ -79,13 +72,9 @@ async def lifespan(app: FastAPI):
init_db() init_db()
# Start unified scheduler (handles backups + email fetch)
backup_scheduler.start()
logger.info("✅ System initialized successfully") logger.info("✅ System initialized successfully")
yield yield
# Shutdown # Shutdown
backup_scheduler.stop()
logger.info("👋 Shutting down...") logger.info("👋 Shutting down...")
# Create FastAPI app # Create FastAPI app
@ -133,10 +122,6 @@ app.include_router(tags_api.router, prefix="/api/v1", tags=["Tags"])
app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"]) app.include_router(emails_api.router, prefix="/api/v1", tags=["Emails"])
app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"]) app.include_router(settings_api.router, prefix="/api/v1", tags=["Settings"])
app.include_router(backups_api, prefix="/api/v1", tags=["Backups"]) app.include_router(backups_api, prefix="/api/v1", tags=["Backups"])
app.include_router(conversations_api.router, prefix="/api/v1", tags=["Conversations"])
# Module Routers
app.include_router(webshop_api.router, prefix="/api/v1", tags=["Webshop"])
# Frontend Routers # Frontend Routers
app.include_router(dashboard_views.router, tags=["Frontend"]) app.include_router(dashboard_views.router, tags=["Frontend"])
@ -151,12 +136,9 @@ app.include_router(tags_views.router, tags=["Frontend"])
app.include_router(settings_views.router, tags=["Frontend"]) app.include_router(settings_views.router, tags=["Frontend"])
app.include_router(emails_views.router, tags=["Frontend"]) app.include_router(emails_views.router, tags=["Frontend"])
app.include_router(backups_views.router, tags=["Frontend"]) app.include_router(backups_views.router, tags=["Frontend"])
app.include_router(conversations_views.router, tags=["Frontend"])
app.include_router(webshop_views.router, tags=["Frontend"])
# Serve static files (UI) # Serve static files (UI)
app.mount("/static", StaticFiles(directory="static", html=True), name="static") app.mount("/static", StaticFiles(directory="static", html=True), name="static")
app.mount("/docs", StaticFiles(directory="docs"), name="docs")
@app.get("/health") @app.get("/health")
async def health_check(): async def health_check():
@ -171,11 +153,8 @@ if __name__ == "__main__":
import uvicorn import uvicorn
import os import os
# Only enable reload in local development (not in Docker) - check both variables # Only enable reload in local development (not in Docker)
enable_reload = ( enable_reload = os.getenv("ENABLE_RELOAD", "false").lower() == "true"
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
os.getenv("API_RELOAD", "false").lower() == "true"
)
if enable_reload: if enable_reload:
uvicorn.run( uvicorn.run(

View File

@ -1,24 +0,0 @@
-- Migration: Add import_method tracking to email_messages
-- Purpose: Track how emails were imported (IMAP, Graph API, or manual upload)
-- Date: 2026-01-06
-- Add import_method column
ALTER TABLE email_messages
ADD COLUMN import_method VARCHAR(50) DEFAULT 'imap';
-- Add comment
COMMENT ON COLUMN email_messages.import_method IS 'How the email was imported: imap, graph_api, or manual_upload';
-- Create index for filtering by import method
CREATE INDEX idx_email_messages_import_method ON email_messages(import_method);
-- Update existing records to reflect their actual source
-- (all existing emails were fetched via IMAP or Graph API)
UPDATE email_messages
SET import_method = 'imap'
WHERE import_method IS NULL;
-- Add constraint to ensure valid values
ALTER TABLE email_messages
ADD CONSTRAINT chk_email_import_method
CHECK (import_method IN ('imap', 'graph_api', 'manual_upload'));

View File

@ -1,48 +0,0 @@
-- Migration: Add billed_via_thehub_id to tmodule_times
-- This tracks which Hub order (and indirectly e-conomic order) each time entry was billed through
DO $$
BEGIN
-- Add billed_via_thehub_id column if it doesn't exist
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'tmodule_times' AND column_name = 'billed_via_thehub_id'
) THEN
ALTER TABLE tmodule_times ADD COLUMN billed_via_thehub_id INTEGER;
RAISE NOTICE 'Added column billed_via_thehub_id to tmodule_times';
ELSE
RAISE NOTICE 'Column billed_via_thehub_id already exists';
END IF;
-- Add foreign key constraint to tmodule_orders
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'tmodule_times'
AND constraint_name = 'tmodule_times_billed_via_thehub_id_fkey'
) THEN
ALTER TABLE tmodule_times
ADD CONSTRAINT tmodule_times_billed_via_thehub_id_fkey
FOREIGN KEY (billed_via_thehub_id)
REFERENCES tmodule_orders(id)
ON DELETE SET NULL;
RAISE NOTICE 'Added foreign key constraint for billed_via_thehub_id';
ELSE
RAISE NOTICE 'Foreign key constraint already exists';
END IF;
-- Add index for faster lookups
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'tmodule_times'
AND indexname = 'idx_tmodule_times_billed_via_thehub_id'
) THEN
CREATE INDEX idx_tmodule_times_billed_via_thehub_id ON tmodule_times(billed_via_thehub_id);
RAISE NOTICE 'Created index idx_tmodule_times_billed_via_thehub_id';
ELSE
RAISE NOTICE 'Index idx_tmodule_times_billed_via_thehub_id already exists';
END IF;
END $$;
-- Add comment explaining the column
COMMENT ON COLUMN tmodule_times.billed_via_thehub_id IS
'Hub order ID that this time entry was billed through. Links to tmodule_orders.id which contains economic_order_number.';

View File

@ -1,41 +0,0 @@
-- Migration: Update timetracking views to exclude billed entries
-- Ensures that time entries with billed_via_thehub_id are not shown in pending counts
-- Drop and recreate approval stats view to exclude billed entries
DROP VIEW IF EXISTS tmodule_approval_stats CASCADE;
CREATE VIEW tmodule_approval_stats AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
c.uses_time_card AS uses_time_card,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS total_entries,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'pending' AND t.billed_via_thehub_id IS NULL) AS pending_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.billed_via_thehub_id IS NULL) AS approved_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected') AS rejected_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed') AS billed_count,
SUM(t.original_hours) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS total_original_hours,
SUM(t.approved_hours) FILTER (WHERE t.billable = true AND t.status = 'approved' AND t.billed_via_thehub_id IS NULL) AS total_approved_hours,
MAX(t.worked_date) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS latest_work_date,
MAX(t.last_synced_at) FILTER (WHERE t.billable = true AND t.billed_via_thehub_id IS NULL) AS last_sync
FROM tmodule_customers c
LEFT JOIN tmodule_times t ON c.id = t.customer_id
GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card;
-- Drop and recreate next pending view to exclude billed entries
DROP VIEW IF EXISTS tmodule_next_pending CASCADE;
CREATE VIEW tmodule_next_pending AS
SELECT
t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.status = 'pending'
AND t.billable = true
AND t.billed_via_thehub_id IS NULL
ORDER BY cust.name, c.title, t.worked_date;

View File

@ -1,139 +0,0 @@
-- Migration: Filter out vTiger-invoiced entries from approval views
-- Ensures that time entries already invoiced in vTiger are not shown in pending/approval flows
-- Also filters entries with cf_timelog_rounduptimespent set (manually invoiced in vTiger)
-- Also filters entries with cf_timelog_billedviathehubid set (billed via Hub in vTiger)
-- Drop and recreate approval stats view to exclude vTiger-invoiced entries
DROP VIEW IF EXISTS tmodule_approval_stats CASCADE;
CREATE VIEW tmodule_approval_stats AS
SELECT
c.id AS customer_id,
c.name AS customer_name,
c.vtiger_id AS customer_vtiger_id,
c.uses_time_card AS uses_time_card,
COUNT(t.id) FILTER (WHERE
t.billable = true
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS total_entries,
COUNT(t.id) FILTER (WHERE
t.billable = true
AND t.status = 'pending'
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS pending_count,
COUNT(t.id) FILTER (WHERE
t.billable = true
AND t.status = 'approved'
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS approved_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'rejected') AS rejected_count,
COUNT(t.id) FILTER (WHERE t.billable = true AND t.status = 'billed') AS billed_count,
SUM(t.original_hours) FILTER (WHERE
t.billable = true
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS total_original_hours,
SUM(t.approved_hours) FILTER (WHERE
t.billable = true
AND t.status = 'approved'
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS total_approved_hours,
MAX(t.worked_date) FILTER (WHERE
t.billable = true
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS latest_work_date,
MAX(t.last_synced_at) FILTER (WHERE
t.billable = true
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
) AS last_sync
FROM tmodule_customers c
LEFT JOIN tmodule_times t ON c.id = t.customer_id
GROUP BY c.id, c.name, c.vtiger_id, c.uses_time_card;
-- Drop and recreate next pending view to exclude vTiger-invoiced entries
DROP VIEW IF EXISTS tmodule_next_pending CASCADE;
CREATE VIEW tmodule_next_pending AS
SELECT
t.*,
COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title,
c.status AS case_status,
c.vtiger_id AS case_vtiger_id,
cust.name AS customer_name,
cust.hourly_rate AS customer_rate
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
JOIN tmodule_customers cust ON t.customer_id = cust.id
WHERE t.status = 'pending'
AND t.billable = true
AND t.billed_via_thehub_id IS NULL
AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL
OR t.vtiger_data->>'cf_timelog_invoiced' = '0'
OR t.vtiger_data->>'cf_timelog_invoiced' = '')
AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0'
OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '')
AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0'
OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '')
ORDER BY cust.name, c.title, t.worked_date;

View File

@ -1,14 +0,0 @@
-- Migration 063: Ticket Enhancements (Types, Internal Notes, Worklog Visibility)
-- 1. Add ticket_type and internal_note to tickets
-- Defaults: ticket_type='incident' (for existing rows)
ALTER TABLE tticket_tickets
ADD COLUMN IF NOT EXISTS ticket_type VARCHAR(50) DEFAULT 'incident',
ADD COLUMN IF NOT EXISTS internal_note TEXT;
-- 2. Add is_internal to worklog (singular)
ALTER TABLE tticket_worklog
ADD COLUMN IF NOT EXISTS is_internal BOOLEAN DEFAULT FALSE;
-- 3. Create index for performance on filtering by type
CREATE INDEX IF NOT EXISTS idx_tticket_tickets_type ON tticket_tickets(ticket_type);

View File

@ -1,11 +0,0 @@
-- Add 'unknown' to billing_method check constraint
ALTER TABLE tticket_worklog DROP CONSTRAINT IF EXISTS tticket_worklog_billing_method_check;
ALTER TABLE tticket_worklog ADD CONSTRAINT tticket_worklog_billing_method_check
CHECK (billing_method::text = ANY (ARRAY[
'prepaid_card',
'invoice',
'internal',
'warranty',
'unknown'
]));

View File

@ -1,10 +0,0 @@
-- Migration: Allow Multiple Active Prepaid Cards Per Customer
-- Date: 2025-01-10
-- Description: Drops the partial unique index that enforced "only one active prepaid card per customer"
-- to allow customers to have multiple active cards simultaneously.
-- Drop the constraint that prevents multiple active cards per customer
DROP INDEX IF EXISTS idx_tticket_prepaid_unique_active;
-- Add a comment to document the change
COMMENT ON TABLE tticket_prepaid_cards IS 'Prepaid cards (klippekort) for customers. Multiple active cards per customer are allowed as of migration 065.';

View File

@ -1,9 +0,0 @@
-- Create table for storing custom AI prompts
CREATE TABLE IF NOT EXISTS ai_prompts (
key VARCHAR(100) PRIMARY KEY,
prompt_text TEXT NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_by INTEGER REFERENCES users(user_id)
);
-- Note: We only store overrides here. If a key is missing, we use the hardcoded default.

View File

@ -1,30 +0,0 @@
-- Add Regex Extract and Link Action
-- Allows configurable regex extraction and database linking workflows
INSERT INTO email_workflow_actions (action_code, name, description, category, parameter_schema, example_config)
VALUES (
'regex_extract_and_link',
'Regex Ekstrahering & Linking',
'Søg efter mønstre (Regex) og link email til database matches',
'linking',
'{
"type": "object",
"properties": {
"regex_pattern": {"type": "string", "title": "Regex Pattern (med 1 gruppe)"},
"target_table": {"type": "string", "enum": ["customers", "vendors", "users"], "title": "Tabel"},
"target_column": {"type": "string", "title": "Søge Kolonne"},
"link_column": {"type": "string", "title": "Link Kolonne i Email", "default": "customer_id"},
"value_column": {"type": "string", "title": "Værdi Kolonne", "default": "id"},
"on_match": {"type": "string", "enum": ["update_email", "none"], "default": "update_email", "title": "Handling"}
},
"required": ["regex_pattern", "target_table", "target_column"]
}',
'{
"regex_pattern": "CVR-nr\\.?:?\\s*(\\d{8})",
"target_table": "customers",
"target_column": "cvr_number",
"link_column": "customer_id",
"value_column": "id",
"on_match": "update_email"
}'
) ON CONFLICT (action_code) DO NOTHING;

View File

@ -1,38 +0,0 @@
-- 068_conversations_module.sql
-- Table for storing transcribed conversations (calls, voice notes)
CREATE TABLE IF NOT EXISTS conversations (
id SERIAL PRIMARY KEY,
customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE,
ticket_id INTEGER REFERENCES tticket_tickets(id) ON DELETE SET NULL,
user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
email_message_id INTEGER REFERENCES email_messages(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
transcript TEXT, -- The full transcribed text
summary TEXT, -- AI generated summary (optional)
audio_file_path VARCHAR(500) NOT NULL,
duration_seconds INTEGER DEFAULT 0,
-- Privacy and Deletion
is_private BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP, -- Soft delete
source VARCHAR(50) DEFAULT 'email', -- 'email', 'upload', 'phone_system'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Index for linkage
CREATE INDEX idx_conversations_customer ON conversations(customer_id);
CREATE INDEX idx_conversations_ticket ON conversations(ticket_id);
CREATE INDEX idx_conversations_user ON conversations(user_id);
-- Full Text Search Index for Danish
ALTER TABLE conversations ADD COLUMN search_vector tsvector GENERATED ALWAYS AS (
to_tsvector('danish', coalesce(title, '') || ' ' || coalesce(transcript, ''))
) STORED;
CREATE INDEX idx_conversations_search ON conversations USING GIN(search_vector);

View File

@ -1,5 +0,0 @@
-- 069_conversation_category.sql
-- Add category column for conversation classification
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Conversation Category: General, Support, Sales, Internal, Meeting';

View File

@ -1,4 +0,0 @@
-- 072_add_category_to_conversations.sql
ALTER TABLE conversations ADD COLUMN category VARCHAR(50) DEFAULT 'General';
COMMENT ON COLUMN conversations.category IS 'Category of the conversation (e.g. Sales, Support, General)';

View File

@ -1,61 +0,0 @@
-- Migration 1000: Supplier Invoice Enhancements
-- Adds support for accounting integration (modkonti) and line item tracking
-- Date: 2026-01-06
-- Add columns to supplier_invoice_lines for accounting integration
ALTER TABLE supplier_invoice_lines
ADD COLUMN IF NOT EXISTS contra_account VARCHAR(10),
ADD COLUMN IF NOT EXISTS line_purpose VARCHAR(50),
ADD COLUMN IF NOT EXISTS resale_customer_id INT REFERENCES customers(id),
ADD COLUMN IF NOT EXISTS resale_order_number VARCHAR(50),
ADD COLUMN IF NOT EXISTS is_invoiced_to_customer BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS invoiced_date TIMESTAMP;
-- Create index for faster filtering by purpose
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_purpose
ON supplier_invoice_lines(line_purpose);
-- Create index for faster filtering by resale status
CREATE INDEX IF NOT EXISTS idx_supplier_invoice_lines_resale
ON supplier_invoice_lines(is_invoiced_to_customer);
-- Create table to cache e-conomic chart of accounts (kontoplan)
CREATE TABLE IF NOT EXISTS economic_accounts (
account_number INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
account_type VARCHAR(50),
vat_code VARCHAR(10),
balance DECIMAL(15, 2),
is_active BOOLEAN DEFAULT TRUE,
last_synced TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
-- Create index for fast account lookups
CREATE INDEX IF NOT EXISTS idx_economic_accounts_active
ON economic_accounts(is_active) WHERE is_active = TRUE;
-- Create index for account type filtering
CREATE INDEX IF NOT EXISTS idx_economic_accounts_type
ON economic_accounts(account_type);
-- Add comments for documentation
COMMENT ON COLUMN supplier_invoice_lines.contra_account IS 'e-conomic account number (modkonto) for this line item';
COMMENT ON COLUMN supplier_invoice_lines.line_purpose IS 'Purpose: resale, internal, project, stock';
COMMENT ON COLUMN supplier_invoice_lines.resale_customer_id IS 'Customer ID if this line is for resale';
COMMENT ON COLUMN supplier_invoice_lines.resale_order_number IS 'Order number if linked to customer order';
COMMENT ON COLUMN supplier_invoice_lines.is_invoiced_to_customer IS 'TRUE when this line has been invoiced to customer';
COMMENT ON COLUMN supplier_invoice_lines.invoiced_date IS 'Date when line was invoiced to customer';
COMMENT ON TABLE economic_accounts IS 'Cached e-conomic chart of accounts (kontoplan) for dropdown menus';
COMMENT ON COLUMN economic_accounts.account_number IS 'e-conomic account number (e.g., 5810, 1970)';
COMMENT ON COLUMN economic_accounts.name IS 'Account name/description';
COMMENT ON COLUMN economic_accounts.account_type IS 'Type: profitAndLoss, status, etc.';
COMMENT ON COLUMN economic_accounts.vat_code IS 'Associated VAT code if applicable';
COMMENT ON COLUMN economic_accounts.last_synced IS 'Last time this account was updated from e-conomic API';
-- Success message
DO $$
BEGIN
RAISE NOTICE '✅ Migration 1000 completed: Supplier invoice enhancements added';
END $$;

View File

@ -12,5 +12,4 @@ paramiko==3.4.1
apscheduler==3.10.4 apscheduler==3.10.4
pandas==2.2.3 pandas==2.2.3
openpyxl==3.1.2 openpyxl==3.1.2
extract-msg==0.55.0 extract-msg==0.48.8
pdfplumber==0.11.4

View File

@ -1,83 +0,0 @@
#!/bin/bash
# Script til at rette forkerte 10-cifrede e-conomic kundenumre
# Baseret på CSV eksport fra e-conomic
set -e
CONTAINER_NAME="bmc-hub-postgres-prod"
DB_USER="bmc_hub"
DB_NAME="bmc_hub"
# Farver til output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}🔍 Finder kunder med forkerte 10-cifrede economic_customer_number...${NC}"
# Find alle kunder med 10+ cifrede numre (over 999999999)
echo "SELECT id, name, economic_customer_number FROM customers WHERE economic_customer_number > 999999999 ORDER BY economic_customer_number;" | \
sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME"
echo ""
echo -e "${YELLOW}⚠️ Disse numre er ugyldige i e-conomic (max 9 cifre tilladt)${NC}"
echo ""
# Ret specifikke kendte cases fra CSV
echo -e "${GREEN}🔧 Retter kendte forkerte numre...${NC}"
# PFA kunder med forkerte numre - baseret på mønstre i CSV
# 2065328011 skal være 20653280X (find det rigtige i e-conomic)
# 2065328014 skal være 206532814 eller lignende
# Først: Fjern sidste ciffer hvis det er 1 og nummeret eksisterer i e-conomic
echo -e "${YELLOW}📋 Strategi: Fjerner sidste ciffer fra 10-cifrede numre${NC}"
# Hent alle 10-cifrede numre
INVALID_NUMBERS=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"SELECT id, economic_customer_number FROM customers WHERE economic_customer_number > 999999999;")
if [ -z "$INVALID_NUMBERS" ]; then
echo -e "${GREEN}✅ Ingen forkerte numre fundet!${NC}"
exit 0
fi
# For hver forkert nummer, prøv at rette det
echo "$INVALID_NUMBERS" | while IFS='|' read -r id number; do
id=$(echo "$id" | xargs)
number=$(echo "$number" | xargs)
if [ -z "$id" ] || [ -z "$number" ]; then
continue
fi
# Fjern sidste ciffer
corrected=$((number / 10))
echo -e "${YELLOW}Kunde ID $id: $number$corrected${NC}"
# Opdater i database
sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c \
"UPDATE customers SET economic_customer_number = $corrected WHERE id = $id;"
echo -e "${GREEN}✅ Opdateret kunde $id${NC}"
done
echo ""
echo -e "${GREEN}✅ Rettelser komplet!${NC}"
echo ""
echo -e "${YELLOW}📋 Verificer resultatet:${NC}"
sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c \
"SELECT id, name, economic_customer_number FROM customers WHERE economic_customer_number > 999999999;"
REMAINING=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"SELECT COUNT(*) FROM customers WHERE economic_customer_number > 999999999;")
if [ "$REMAINING" -eq 0 ]; then
echo -e "${GREEN}✅ Alle kundenumre er nu gyldige!${NC}"
else
echo -e "${RED}⚠️ $REMAINING kunder har stadig ugyldige numre${NC}"
fi

View File

@ -1,38 +0,0 @@
import asyncio
import os
import sys
import logging
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from app.core.database import init_db, execute_query, execute_update
from app.services.ollama_service import ollama_service
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def main(conv_id):
init_db()
# Get transcript
rows = execute_query("SELECT transcript FROM conversations WHERE id = %s", (conv_id,))
if not rows or not rows[0]['transcript']:
print("No transcript found")
return
transcript = rows[0]['transcript']
print(f"Transcript length: {len(transcript)}")
# Override endpoint for Docker Mac access
ollama_service.endpoint = os.environ.get("OLLAMA_ENDPOINT", "http://host.docker.internal:11434")
print(f"Using Ollama endpoint: {ollama_service.endpoint}")
summary = await ollama_service.generate_summary(transcript)
print(f"Summary generated: {summary}")
execute_update("UPDATE conversations SET summary = %s WHERE id = %s", (summary, conv_id))
print("Database updated")
if __name__ == "__main__":
asyncio.run(main(1))

View File

@ -1,85 +0,0 @@
#!/usr/bin/env python3
"""
Import e-conomic customers fra CSV til Hub database
"""
import sys
import csv
import subprocess
def run_sql(sql):
"""Kør SQL kommando via podman"""
cmd = [
'sudo', 'podman', 'exec', '-i', 'bmc-hub-postgres-prod',
'psql', '-U', 'bmc_hub', '-d', 'bmc_hub', '-t', '-c', sql
]
result = subprocess.run(cmd, capture_output=True, text=True)
return result.stdout.strip()
def main():
if len(sys.argv) < 2:
print("Brug: python3 import_economic_csv.py <csv-file>")
sys.exit(1)
csv_file = sys.argv[1]
print(f"📂 Læser {csv_file}...")
updated = 0
created = 0
skipped = 0
with open(csv_file, 'r', encoding='utf-8-sig') as f:
# Skip første 3 linjer (header osv.)
for _ in range(3):
next(f)
reader = csv.DictReader(f, delimiter=';')
for row in reader:
nummer = row.get('Nr.', '').strip()
navn = row.get('Navn', '').strip()
if not nummer or not navn:
continue
# Skip non-numeric
if not nummer.isdigit():
continue
nummer_int = int(nummer)
# Skip for store numre
if nummer_int > 999999999:
print(f"⚠️ Springer over {navn} - Nummer for stort: {nummer}")
skipped += 1
continue
print(f"[{updated + created + skipped + 1}] {nummer} - {navn}")
# Escape quotes
navn_esc = navn.replace("'", "''")
# Find kunde
find_sql = f"SELECT id FROM customers WHERE LOWER(name) = LOWER('{navn_esc}') LIMIT 1;"
kunde_id = run_sql(find_sql).strip()
if kunde_id and kunde_id.isdigit():
# Update
update_sql = f"UPDATE customers SET economic_customer_number = {nummer_int} WHERE id = {kunde_id};"
run_sql(update_sql)
print(f" ✅ Opdateret ID {kunde_id}")
updated += 1
else:
# Create
create_sql = f"INSERT INTO customers (name, economic_customer_number, created_at, updated_at) VALUES ('{navn_esc}', {nummer_int}, NOW(), NOW());"
run_sql(create_sql)
print(f" Oprettet")
created += 1
print(f"\n✅ Færdig!")
print(f" Opdateret: {updated}")
print(f" Oprettet: {created}")
print(f" Sprunget over: {skipped}")
print(f" Total: {updated + created}")
if __name__ == '__main__':
main()

View File

@ -1,97 +0,0 @@
#!/bin/bash
# Script til at linke tmodule_customers til customers automatisk
# Matcher baseret på navn eller economic_customer_number
set -e
CONTAINER_NAME="bmc-hub-postgres-prod"
DB_USER="bmc_hub"
DB_NAME="bmc_hub"
# Farver til output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Check om container kører
if ! sudo podman ps | grep -q "$CONTAINER_NAME"; then
echo -e "${RED}❌ Container $CONTAINER_NAME kører ikke!${NC}"
exit 1
fi
echo -e "${GREEN}🔍 Finder kunder uden hub_customer_id...${NC}"
# Find alle tmodule_customers uden hub_customer_id
UNLINKED=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c "
SELECT COUNT(*)
FROM tmodule_customers
WHERE hub_customer_id IS NULL;
")
echo -e "${YELLOW}📊 Fandt $UNLINKED ulinkede kunder${NC}"
if [ "$UNLINKED" -eq 0 ]; then
echo -e "${GREEN}✅ Alle kunder er allerede linket!${NC}"
exit 0
fi
echo -e "${GREEN}🔗 Linker kunder baseret på navn match...${NC}"
# Link kunder hvor navnet matcher præcist
LINKED=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c "
UPDATE tmodule_customers tc
SET hub_customer_id = c.id
FROM customers c
WHERE tc.hub_customer_id IS NULL
AND LOWER(TRIM(tc.name)) = LOWER(TRIM(c.name))
RETURNING tc.id;
" | wc -l)
echo -e "${GREEN}✅ Linkede $LINKED kunder baseret på navn${NC}"
# Link kunder baseret på economic_customer_number (hvis begge har det)
echo -e "${GREEN}🔗 Linker kunder baseret på economic_customer_number...${NC}"
LINKED_ECON=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c "
UPDATE tmodule_customers tc
SET hub_customer_id = c.id
FROM customers c
WHERE tc.hub_customer_id IS NULL
AND tc.economic_customer_number IS NOT NULL
AND c.economic_customer_number IS NOT NULL
AND tc.economic_customer_number = c.economic_customer_number
RETURNING tc.id;
" | wc -l)
echo -e "${GREEN}✅ Linkede $LINKED_ECON kunder baseret på economic_customer_number${NC}"
# Vis stadig ulinkede kunder
echo -e "${YELLOW}📋 Kunder der stadig mangler link:${NC}"
sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
tc.id,
tc.vtiger_id,
tc.name,
tc.economic_customer_number
FROM tmodule_customers tc
WHERE tc.hub_customer_id IS NULL
ORDER BY tc.name
LIMIT 20;
"
REMAINING=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c "
SELECT COUNT(*)
FROM tmodule_customers
WHERE hub_customer_id IS NULL;
")
echo -e "${YELLOW}⚠️ $REMAINING kunder mangler stadig link${NC}"
if [ "$REMAINING" -gt 0 ]; then
echo -e "${YELLOW}💡 Disse skal linkes manuelt via UI eller direkte SQL${NC}"
fi
echo -e "${GREEN}✅ Linking komplet!${NC}"

View File

@ -1,145 +0,0 @@
#!/bin/bash
# Script til at matche ALLE kunder fra e-conomic CSV til Hub database
# Opdaterer economic_customer_number baseret på kunde navn match
# VIGTIGT: Ingen set -e her - vi vil fortsætte ved fejl
set +e
CONTAINER_NAME="bmc-hub-postgres-prod"
DB_USER="bmc_hub"
DB_NAME="bmc_hub"
CSV_FILE="$1"
# Farver
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
if [ -z "$CSV_FILE" ]; then
echo -e "${RED}❌ Brug: $0 <path-to-csv-file>${NC}"
echo ""
echo "Eksempel: $0 ~/Downloads/Kunder-2.csv"
exit 1
fi
if [ ! -f "$CSV_FILE" ]; then
echo -e "${RED}❌ Fil ikke fundet: $CSV_FILE${NC}"
exit 1
fi
echo -e "${GREEN}📂 Læser CSV fil: $CSV_FILE${NC}"
# Vis første 10 linjer for debugging
echo -e "${YELLOW}📋 Første linjer i CSV:${NC}"
head -10 "$CSV_FILE"
echo ""
# Statistik
TOTAL=0
MATCHED=0
UPDATED=0
SKIPPED=0
ERRORS=0
# Lav temp fil med data
TEMP_FILE=$(mktemp)
tail -n +5 "$CSV_FILE" | grep -v "^$" > "$TEMP_FILE"
echo -e "${GREEN}🔢 Antal linjer i CSV: $(wc -l < "$TEMP_FILE")${NC}"
echo ""
# Parse CSV og match kunder
# Format: Nr.;Navn;Gruppe;Attention;Saldo;Forfalden;E-mail
while IFS=';' read -r nummer navn gruppe attention saldo forfalden email rest || [ -n "$nummer" ]; do
# Skip tomme linjer og header
if [ -z "$nummer" ] || [ "$nummer" = "Nr." ]; then
continue
fi
# Trim whitespace og fjern BOM/special chars
nummer=$(echo "$nummer" | xargs | tr -cd '0-9')
navn=$(echo "$navn" | xargs)
if [ -z "$navn" ] || [ -z "$nummer" ]; then
echo -e "${RED}DEBUG: Skipping empty - nummer='$nummer', navn='$navn'${NC}"
continue
fi
TOTAL=$((TOTAL + 1))
# Debug ALLE linjer
echo -e "${YELLOW}[$TOTAL] Processing: $nummer - $navn${NC}"
# Escape single quotes i navn
navn_escaped=$(echo "$navn" | sed "s/'/''/g")
# Tjek om nummer er gyldigt (max 9 cifre)
if [ "$nummer" -gt 999999999 ] 2>/dev/null; then
echo -e "${YELLOW}⚠️ Springer over $navn - Nummer for stort: $nummer${NC}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Find matchende kunde i Hub
MATCH=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"SELECT id FROM customers WHERE LOWER(TRIM(name)) = LOWER(TRIM('$navn_escaped')) LIMIT 1;")
MATCH=$(echo "$MATCH" | xargs)
if [ -n "$MATCH" ]; then
MATCHED=$((MATCHED + 1))
# Opdater economic_customer_number (altid - tvungen opdatering)
UPDATE_RESULT=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"UPDATE customers SET economic_customer_number = $nummer, last_synced_at = NOW()
WHERE id = $MATCH
RETURNING id;" 2>&1)
if echo "$UPDATE_RESULT" | grep -q "^[0-9]"; then
echo -e "${GREEN}$navn → e-conomic #$nummer (opdateret)${NC}"
UPDATED=$((UPDATED + 1))
else
echo -e "${YELLOW}⏭️ $navn - Ingen ændring nødvendig${NC}"
fi
else
# Kunde findes ikke i Hub - opret den
CREATE_RESULT=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"INSERT INTO customers (name, economic_customer_number, created_at, updated_at)
VALUES ('$navn_escaped', $nummer, NOW(), NOW())
ON CONFLICT DO NOTHING
RETURNING id;" 2>&1)
if echo "$CREATE_RESULT" | grep -q "^[0-9]"; then
echo -e "${GREEN} OPRETTET: $navn → e-conomic #$nummer${NC}"
UPDATED=$((UPDATED + 1))
else
echo -e "${YELLOW}⚠️ Kunne ikke oprette: $navn (e-conomic #$nummer)${NC}"
SKIPPED=$((SKIPPED + 1))
fi
fi
# Progress hver 50. kunde
if [ $((TOTAL % 50)) -eq 0 ] && [ $TOTAL -gt 0 ]; then
echo -e "${YELLOW}📊 Progress: $TOTAL behandlet, $MATCHED matched, $UPDATED opdateret${NC}"
fi
done < "$TEMP_FILE"
# Cleanup
rm -f "$TEMP_FILE"
echo ""
echo -e "${GREEN}✅ Matching komplet!${NC}"
echo -e "${GREEN}📊 Statistik:${NC}"
echo -e " Total behandlet: $TOTAL"
echo -e " Matched i Hub: $MATCHED"
echo -e " Opdateret: $UPDATED"
echo -e " Sprunget over: $SKIPPED"
echo ""
# Vis kunder der mangler economic_customer_number
MISSING=$(sudo podman exec -i "$CONTAINER_NAME" psql -U "$DB_USER" -d "$DB_NAME" -t -c \
"SELECT COUNT(*) FROM customers WHERE economic_customer_number IS NULL;")
echo -e "${YELLOW}⚠️ $MISSING kunder mangler stadig economic_customer_number${NC}"

View File

@ -1,58 +0,0 @@
#!/bin/bash
# Simpel version - ingen fancy features
CSV_FILE="$1"
CONTAINER="bmc-hub-postgres-prod"
if [ -z "$CSV_FILE" ]; then
echo "Brug: $0 <csv-file>"
exit 1
fi
echo "Reading $CSV_FILE..."
LINE_NO=0
UPDATED=0
while IFS=';' read -r nummer navn rest; do
LINE_NO=$((LINE_NO + 1))
# Skip første 4 linjer
if [ $LINE_NO -le 4 ]; then
continue
fi
# Clean nummer og navn
nummer=$(echo "$nummer" | tr -cd '0-9')
navn=$(echo "$navn" | xargs)
# Skip hvis tomt
if [ -z "$nummer" ] || [ -z "$navn" ]; then
continue
fi
echo "[$LINE_NO] $nummer - $navn"
# Escape quotes
navn_esc=$(echo "$navn" | sed "s/'/''/g")
# Find kunde
ID=$(sudo podman exec -i "$CONTAINER" psql -U bmc_hub -d bmc_hub -t -c \
"SELECT id FROM customers WHERE LOWER(name) = LOWER('$navn_esc') LIMIT 1;" | xargs)
if [ -n "$ID" ]; then
# Update
sudo podman exec -i "$CONTAINER" psql -U bmc_hub -d bmc_hub -c \
"UPDATE customers SET economic_customer_number = $nummer WHERE id = $ID;" > /dev/null
echo " ✅ Updated ID $ID"
UPDATED=$((UPDATED + 1))
else
# Create
sudo podman exec -i "$CONTAINER" psql -U bmc_hub -d bmc_hub -c \
"INSERT INTO customers (name, economic_customer_number, created_at, updated_at) VALUES ('$navn_esc', $nummer, NOW(), NOW());" > /dev/null
echo " Created"
UPDATED=$((UPDATED + 1))
fi
done < "$CSV_FILE"
echo ""
echo "Done! Updated: $UPDATED customers"

View File

@ -1,61 +0,0 @@
import aiohttp
import asyncio
import os
import json
async def test_whisper_variant(session, url, file_path, params=None, form_fields=None, description=""):
print(f"\n--- Testing: {description} ---")
try:
data = aiohttp.FormData()
# Re-open file for each request to ensure pointer is at start
data.add_field('file', open(file_path, 'rb'), filename='rec.mp3')
if form_fields:
for k, v in form_fields.items():
data.add_field(k, str(v))
async with session.post(url, data=data, params=params) as response:
print(f"Status: {response.status}")
if response.status == 200:
try:
text_content = await response.text()
try:
result = json.loads(text_content)
# Print keys to see if we got something new
if isinstance(result, dict):
print("Keys:", result.keys())
if 'results' in result and len(result['results']) > 0:
print("Result[0] keys:", result['results'][0].keys())
if 'segments' in result:
print("FOUND SEGMENTS!")
print("Raw (truncated):", text_content[:300])
except:
print("Non-JSON Response:", text_content[:300])
except Exception as e:
print(f"Reading error: {e}")
else:
print("Error:", await response.text())
except Exception as e:
print(f"Exception: {e}")
async def test_whisper():
url = "http://172.16.31.115:5000/transcribe"
file_path = "uploads/email_attachments/65d2ca781a6bf3cee9cee0a8ce80acac_rec.mp3"
if not os.path.exists(file_path):
print(f"File not found: {file_path}")
return
async with aiohttp.ClientSession() as session:
# Variant 1: 'timestamps': 'true' as form field
await test_whisper_variant(session, url, file_path, form_fields={'timestamps': 'true'}, description="Form: timestamps=true")
# Variant 2: 'response_format': 'verbose_json' (OpenAI style)
await test_whisper_variant(session, url, file_path, form_fields={'response_format': 'verbose_json'}, description="Form: verbose_json")
# Variant 3: 'verbose': 'true'
await test_whisper_variant(session, url, file_path, form_fields={'verbose': 'true'}, description="Form: verbose=true")
if __name__ == "__main__":
asyncio.run(test_whisper())

View File

@ -1,44 +0,0 @@
#!/usr/bin/env python3
"""Test billed_via_thehub_id opdatering"""
import sys
sys.path.insert(0, '/app')
from app.core.database import execute_query, execute_update
# Test query
print("\n=== Checking tmodule_times schema ===")
result = execute_query("""
SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = 'tmodule_times'
AND column_name = 'billed_via_thehub_id'
""")
print(f"Column exists: {len(result) > 0}")
if result:
print(f" Type: {result[0]['data_type']}")
print(f" Nullable: {result[0]['is_nullable']}")
# Test foreign key
print("\n=== Checking foreign key constraint ===")
fk_result = execute_query("""
SELECT constraint_name, table_name, column_name
FROM information_schema.key_column_usage
WHERE table_name = 'tmodule_times'
AND column_name = 'billed_via_thehub_id'
""")
if fk_result:
print(f"Foreign key: {fk_result[0]['constraint_name']}")
# Check if there are any time entries
print("\n=== Sample time entries ===")
times = execute_query("""
SELECT id, status, billed_via_thehub_id, original_hours, worked_date
FROM tmodule_times
ORDER BY id DESC
LIMIT 5
""")
print(f"Found {len(times)} time entries:")
for t in times:
print(f" ID {t['id']}: status={t['status']}, billed_via={t['billed_via_thehub_id']}, hours={t['original_hours']}")
print("\n✅ Test complete!")

View File

@ -1,31 +0,0 @@
#!/usr/bin/env python3
"""Test vTiger account retrieval"""
import asyncio
import sys
sys.path.insert(0, '/app')
from app.services.vtiger_service import VTigerService
from app.core.config import settings
import json
async def main():
vtiger = VTigerService()
account_id = "3x957"
# Fetch account
print(f"\n=== Fetching account {account_id} ===")
account = await vtiger.get_account_by_id(account_id)
if account:
print(f"\nAccount fields ({len(account)} total):")
for key in sorted(account.keys()):
value = account[key]
if len(str(value)) > 100:
value = str(value)[:100] + "..."
print(f" {key}: {value}")
else:
print("Account not found!")
if __name__ == "__main__":
asyncio.run(main())