Compare commits
No commits in common. "c9f04c77b4e95aa0a243582ea532ae513c3cd34f" and "5f603bdd2edaa4df48ebf43d6ef62000b449f214" have entirely different histories.
c9f04c77b4
...
5f603bdd2e
@ -10,7 +10,6 @@ RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
g++ \
|
||||
python3-dev \
|
||||
postgresql-client \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Build arguments for GitHub release deployment
|
||||
|
||||
BIN
Kunder-2.xlsx
BIN
Kunder-2.xlsx
Binary file not shown.
@ -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
|
||||
@ -1,6 +1,6 @@
|
||||
"""
|
||||
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
|
||||
@ -26,42 +26,17 @@ class BackupScheduler:
|
||||
self.running = False
|
||||
|
||||
def start(self):
|
||||
"""Start the scheduler with enabled jobs (backups and/or emails)"""
|
||||
if self.running:
|
||||
logger.warning("⚠️ Scheduler already running")
|
||||
"""Start the backup scheduler with all jobs"""
|
||||
if not self.enabled:
|
||||
logger.info("⏭️ Backup scheduler disabled (BACKUP_ENABLED=false)")
|
||||
return
|
||||
|
||||
logger.info("🚀 Starting unified scheduler...")
|
||||
if self.running:
|
||||
logger.warning("⚠️ Backup scheduler already running")
|
||||
return
|
||||
|
||||
# Add backup jobs if enabled
|
||||
if self.enabled:
|
||||
self._add_backup_jobs()
|
||||
else:
|
||||
logger.info("⏭️ Backup jobs disabled (BACKUP_ENABLED=false)")
|
||||
logger.info("🚀 Starting backup scheduler...")
|
||||
|
||||
# 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
|
||||
self.scheduler.add_job(
|
||||
func=self._daily_backup_job,
|
||||
@ -131,6 +106,12 @@ class BackupScheduler:
|
||||
)
|
||||
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):
|
||||
"""Stop the backup scheduler"""
|
||||
if not self.running:
|
||||
@ -396,25 +377,6 @@ class BackupScheduler:
|
||||
except Exception as e:
|
||||
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:
|
||||
"""Convert day name to APScheduler weekday number (0=Monday, 6=Sunday)"""
|
||||
days = {
|
||||
|
||||
@ -248,15 +248,11 @@
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-clock-history"></i> Scheduled Jobs</span>
|
||||
<button class="btn btn-light btn-sm" onclick="loadSchedulerStatus()">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history"></i> Scheduler Status
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="card-body">
|
||||
<div id="scheduler-status">
|
||||
<div class="text-center p-4">
|
||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||
<span class="ms-2">Loading...</span>
|
||||
</div>
|
||||
@ -264,7 +260,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup History -->
|
||||
<div class="row">
|
||||
@ -506,138 +501,29 @@
|
||||
|
||||
if (!status.running) {
|
||||
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
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Group jobs by type
|
||||
const backupJobs = status.jobs.filter(j => ['daily_backup', 'monthly_backup'].includes(j.id));
|
||||
const maintenanceJobs = status.jobs.filter(j => ['backup_rotation', 'storage_check', 'offsite_upload', 'offsite_retry'].includes(j.id));
|
||||
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>
|
||||
container.innerHTML = `
|
||||
<div class="alert alert-success mb-0">
|
||||
<i class="bi bi-check-circle"></i> Active
|
||||
</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) {
|
||||
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
|
||||
async function createBackup(event) {
|
||||
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
@ -16,16 +16,7 @@ async def supplier_invoices_page(request: Request):
|
||||
"""Supplier invoices (kassekladde) page"""
|
||||
return templates.TemplateResponse("billing/frontend/supplier_invoices.html", {
|
||||
"request": request,
|
||||
"title": "Leverandør fakturaer"
|
||||
})
|
||||
|
||||
|
||||
@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)"
|
||||
"title": "Kassekladde"
|
||||
})
|
||||
|
||||
|
||||
|
||||
@ -3,27 +3,15 @@ Contact API Router - Simplified (Read-Only)
|
||||
Only GET endpoints for now
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, status
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from app.core.database import execute_query, execute_insert
|
||||
from app.core.database import execute_query
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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")
|
||||
async def debug_contacts():
|
||||
"""Debug endpoint: Check contact-company links"""
|
||||
@ -131,55 +119,6 @@ async def get_contacts(
|
||||
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}")
|
||||
async def get_contact(contact_id: int):
|
||||
"""Get a single contact by ID with linked companies"""
|
||||
|
||||
@ -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]
|
||||
@ -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 %}
|
||||
@ -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})
|
||||
@ -19,7 +19,6 @@ class Settings(BaseSettings):
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = 8000
|
||||
API_RELOAD: bool = False
|
||||
ENABLE_RELOAD: bool = False # Added to match docker-compose.yml
|
||||
|
||||
# Security
|
||||
SECRET_KEY: str = "dev-secret-key-change-in-production"
|
||||
@ -64,7 +63,7 @@ class Settings(BaseSettings):
|
||||
EMAIL_RULES_ENABLED: bool = True
|
||||
EMAIL_RULES_AUTO_PROCESS: 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_MAX_FETCH_PER_RUN: int = 50
|
||||
EMAIL_PROCESS_INTERVAL_MINUTES: int = 5
|
||||
@ -77,11 +76,6 @@ class Settings(BaseSettings):
|
||||
VTIGER_USERNAME: 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
|
||||
TIMETRACKING_DEFAULT_HOURLY_RATE: float = 1200.00
|
||||
TIMETRACKING_AUTO_ROUND: bool = True
|
||||
@ -126,9 +120,6 @@ class Settings(BaseSettings):
|
||||
|
||||
# Offsite Backup Settings (SFTP)
|
||||
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_PORT: int = 22
|
||||
SFTP_USER: str = ""
|
||||
@ -151,12 +142,6 @@ class Settings(BaseSettings):
|
||||
GITHUB_TOKEN: str = ""
|
||||
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')
|
||||
@classmethod
|
||||
def strip_whitespace(cls, v):
|
||||
|
||||
@ -85,17 +85,14 @@ def execute_query(query: str, params: tuple = None, fetch: bool = True):
|
||||
|
||||
|
||||
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()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
|
||||
cursor.execute(query, params)
|
||||
conn.commit()
|
||||
result = cursor.fetchone()
|
||||
if result:
|
||||
# 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
|
||||
return result['id'] if result and 'id' in result else None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Insert error: {e}")
|
||||
|
||||
@ -9,10 +9,9 @@ from typing import List, Optional, Dict
|
||||
from pydantic import BaseModel
|
||||
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.customer_activity_logger import CustomerActivityLogger
|
||||
from app.services.customer_consistency import CustomerConsistencyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -380,29 +379,13 @@ async def get_customer(customer_id: int):
|
||||
except Exception as 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 {
|
||||
**customer,
|
||||
'contact_count': contact_count,
|
||||
'bmc_locked': bmc_locked,
|
||||
'bankruptcy_alert': bankruptcy_alert
|
||||
'bmc_locked': bmc_locked
|
||||
}
|
||||
|
||||
|
||||
|
||||
@router.post("/customers")
|
||||
async def create_customer(customer: CustomerCreate):
|
||||
"""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))
|
||||
|
||||
|
||||
@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")
|
||||
async def sync_economic_numbers_from_simplycrm():
|
||||
"""
|
||||
@ -1355,51 +1243,3 @@ async def get_subscription_comment(customer_id: int):
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error fetching subscription comment: {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))
|
||||
|
||||
|
||||
@ -163,56 +163,6 @@
|
||||
border-radius: 50%;
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -233,8 +183,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-edit-customer" onclick="editCustomer()">
|
||||
<i class="bi bi-pencil-square me-2"></i>Rediger Kunde
|
||||
<button class="btn btn-light btn-sm" onclick="editCustomer()">
|
||||
<i class="bi bi-pencil me-2"></i>Rediger
|
||||
</button>
|
||||
<button class="btn btn-light btn-sm" onclick="window.location.href='/customers'">
|
||||
<i class="bi bi-arrow-left me-2"></i>Tilbage
|
||||
@ -243,39 +193,6 @@
|
||||
</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 -->
|
||||
<div class="row">
|
||||
<div class="col-lg-3 col-md-4">
|
||||
@ -301,11 +218,6 @@
|
||||
<i class="bi bi-arrow-repeat"></i>Abonnnents tjek
|
||||
</a>
|
||||
</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">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#hardware">
|
||||
<i class="bi bi-hdd"></i>Hardware
|
||||
@ -316,11 +228,6 @@
|
||||
<i class="bi bi-clock-history"></i>Aktivitet
|
||||
</a>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@ -495,42 +402,6 @@
|
||||
</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 -->
|
||||
<div class="tab-pane fade" id="hardware">
|
||||
<h5 class="fw-bold mb-4">Hardware</h5>
|
||||
@ -548,143 +419,6 @@
|
||||
</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>
|
||||
@ -791,14 +525,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}, { 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;
|
||||
});
|
||||
|
||||
@ -811,9 +537,6 @@ async function loadCustomer() {
|
||||
|
||||
customerData = await response.json();
|
||||
displayCustomer(customerData);
|
||||
|
||||
// Check data consistency
|
||||
await checkDataConsistency();
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer:', error);
|
||||
alert('Kunne ikke indlæse kunde');
|
||||
@ -825,23 +548,6 @@ function displayCustomer(customer) {
|
||||
// Update page title
|
||||
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
|
||||
document.getElementById('customerAvatar').textContent = getInitials(customer.name);
|
||||
document.getElementById('customerName').textContent = customer.name;
|
||||
@ -1472,121 +1178,6 @@ async function loadActivity() {
|
||||
}, 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) {
|
||||
const linesDiv = document.getElementById(`${itemId}-lines`);
|
||||
const icon = document.getElementById(`${itemId}-icon`);
|
||||
@ -1683,270 +1274,8 @@ function toggleLineItems(itemId) {
|
||||
}
|
||||
|
||||
function editCustomer() {
|
||||
if (!customerData) {
|
||||
alert('Kunde data ikke indlæst endnu');
|
||||
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();
|
||||
// TODO: Open edit modal with pre-filled data
|
||||
console.log('Edit customer:', customerId);
|
||||
}
|
||||
|
||||
function showAddContactModal() {
|
||||
@ -2208,199 +1537,5 @@ function editInternalComment() {
|
||||
editDiv.style.display = 'block';
|
||||
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>
|
||||
|
||||
<!-- 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 %}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from app.core.database import execute_query_single
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="app")
|
||||
@ -11,52 +10,4 @@ async def dashboard(request: Request):
|
||||
"""
|
||||
Render the dashboard page
|
||||
"""
|
||||
# Fetch count of unknown billing worklogs
|
||||
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
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("dashboard/frontend/index.html", {"request": request})
|
||||
|
||||
@ -14,40 +14,6 @@
|
||||
</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="col-md-3">
|
||||
<div class="card stat-card p-4 h-100">
|
||||
|
||||
@ -4,7 +4,7 @@ API endpoints for email viewing, classification, and rule management
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException, Query, UploadFile, File
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime, date
|
||||
@ -148,7 +148,6 @@ class ProcessingStats(BaseModel):
|
||||
async def list_emails(
|
||||
status: Optional[str] = Query(None),
|
||||
classification: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
limit: int = Query(50, le=500),
|
||||
offset: int = Query(0, ge=0)
|
||||
):
|
||||
@ -165,11 +164,6 @@ async def list_emails(
|
||||
where_clauses.append("em.classification = %s")
|
||||
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)
|
||||
|
||||
query = f"""
|
||||
@ -354,7 +348,7 @@ async def delete_email(email_id: int):
|
||||
|
||||
@router.post("/emails/{email_id}/reprocess")
|
||||
async def reprocess_email(email_id: int):
|
||||
"""Reprocess email (re-classify, run workflows, and apply rules)"""
|
||||
"""Reprocess email (re-classify and apply rules)"""
|
||||
try:
|
||||
# Get email
|
||||
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]
|
||||
|
||||
# Re-classify and run full processing pipeline
|
||||
# Re-classify
|
||||
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
|
||||
result = execute_query(query, (email_id,))
|
||||
email = result[0]
|
||||
# Update classification
|
||||
update_query = """
|
||||
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 {
|
||||
"success": True,
|
||||
"message": "Email reprocessed with workflows",
|
||||
"classification": email['classification'],
|
||||
"confidence": email.get('confidence_score', 0),
|
||||
"workflows_executed": processing_result.get('workflows_executed', 0)
|
||||
"message": "Email reprocessed",
|
||||
"classification": classification,
|
||||
"confidence": confidence
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@ -407,211 +410,6 @@ async def process_emails():
|
||||
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")
|
||||
async def bulk_archive(email_ids: List[int]):
|
||||
"""Bulk archive emails"""
|
||||
@ -652,14 +450,25 @@ async def bulk_reprocess(email_ids: List[int]):
|
||||
result = execute_query(query, (email_id,))
|
||||
|
||||
if result:
|
||||
email_data = result[0]
|
||||
# Use central processing logic
|
||||
await processor.process_single_email(email_data)
|
||||
email = result[0]
|
||||
classification, confidence = await processor.classify_email(
|
||||
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
|
||||
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:
|
||||
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 classification = 'invoice' THEN 1 END) as invoices,
|
||||
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 auto_processed THEN 1 END) as auto_processed,
|
||||
AVG(confidence_score) as avg_confidence
|
||||
@ -1065,13 +873,11 @@ async def execute_workflows_for_email(email_id: int):
|
||||
FROM email_messages
|
||||
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")
|
||||
|
||||
email_data = email_result[0] # Get first row as dict
|
||||
|
||||
# Execute workflows
|
||||
result = await email_workflow_service.execute_workflows(email_data)
|
||||
|
||||
|
||||
@ -713,64 +713,6 @@
|
||||
max-height: calc(100vh - 250px);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -827,28 +769,6 @@
|
||||
</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">
|
||||
<button class="filter-pill active" data-filter="active" onclick="setFilter('active')">
|
||||
Aktive <span class="count" id="countActive">0</span>
|
||||
@ -877,9 +797,6 @@
|
||||
<button class="filter-pill" data-filter="general" onclick="setFilter('general')">
|
||||
Generel <span class="count" id="countGeneral">0</span>
|
||||
</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')">
|
||||
Spam <span class="count" id="countSpam">0</span>
|
||||
</button>
|
||||
@ -1432,10 +1349,8 @@ async function loadEmails(searchQuery = '') {
|
||||
// Handle special filters
|
||||
if (currentFilter === 'active') {
|
||||
// Show only new, error, or flagged (pending review) emails
|
||||
// If searching, ignore status filter to allow global search
|
||||
if (!searchQuery) {
|
||||
// Exclude processed and archived
|
||||
url += '&status=new';
|
||||
}
|
||||
} else if (currentFilter === 'processed') {
|
||||
url += '&status=processed';
|
||||
} else if (currentFilter !== 'all') {
|
||||
@ -1584,9 +1499,7 @@ async function loadEmailDetail(emailId) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load email detail:', error);
|
||||
const errorMsg = error?.message || String(error) || 'Ukendt fejl';
|
||||
alert('Kunne ikke indlæse email detaljer: ' + errorMsg);
|
||||
showEmptyState();
|
||||
showError('Kunne ikke indlæse email detaljer: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1748,11 +1661,6 @@ function renderEmailDetail(email) {
|
||||
|
||||
function renderEmailAnalysis(email) {
|
||||
const aiAnalysisTab = document.getElementById('aiAnalysisTab');
|
||||
if (!aiAnalysisTab) {
|
||||
console.error('aiAnalysisTab element not found in DOM');
|
||||
return;
|
||||
}
|
||||
|
||||
const classification = email.classification || 'general';
|
||||
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="time_confirmation" ${classification === 'time_confirmation' ? 'selected' : ''}>⏰ Tidsregistrering</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="spam" ${classification === 'spam' ? 'selected' : ''}>🚫 Spam</option>
|
||||
<option value="general" ${classification === 'general' ? 'selected' : ''}>📧 Generel</option>
|
||||
@ -1892,8 +1799,7 @@ async function loadStats() {
|
||||
document.getElementById('countFreight').textContent = 0;
|
||||
document.getElementById('countTime').textContent = stats.time_confirmations || 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 - (stats.newsletters || 0) || 0;
|
||||
document.getElementById('countGeneral').textContent = stats.total_emails - stats.invoices - stats.time_confirmations || 0;
|
||||
document.getElementById('countSpam').textContent = stats.spam_emails || 0;
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
@ -3971,143 +3877,5 @@ function showNotification(message, type = 'info') {
|
||||
document.body.appendChild(toast);
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
"""
|
||||
Scheduled Jobs Module
|
||||
"""
|
||||
@ -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}")
|
||||
@ -20,22 +20,6 @@ class CustomerCreate(CustomerBase):
|
||||
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):
|
||||
"""Full customer schema"""
|
||||
id: int
|
||||
@ -105,37 +89,3 @@ class Vendor(VendorBase):
|
||||
|
||||
class Config:
|
||||
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)
|
||||
|
||||
@ -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
|
||||
@ -1 +0,0 @@
|
||||
# Backend package for template module
|
||||
@ -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))
|
||||
@ -1 +0,0 @@
|
||||
# Frontend package for template module
|
||||
@ -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 %}
|
||||
@ -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})
|
||||
@ -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();
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
@ -122,13 +122,23 @@ async def get_prepaid_card(card_id: int):
|
||||
async def create_prepaid_card(card: PrepaidCardCreate):
|
||||
"""
|
||||
Create a new prepaid card
|
||||
|
||||
Note: As of migration 065, customers can have multiple active cards simultaneously.
|
||||
"""
|
||||
try:
|
||||
# Calculate total amount
|
||||
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)
|
||||
conn = None
|
||||
try:
|
||||
|
||||
@ -48,17 +48,6 @@
|
||||
<h5 class="mb-0">Oversigt</h5>
|
||||
</div>
|
||||
<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">
|
||||
<small class="text-muted d-block mb-1">Købte Timer</small>
|
||||
<h4 class="mb-0" id="statPurchased">-</h4>
|
||||
@ -159,27 +148,6 @@ async function loadCardDetails() {
|
||||
currency: 'DKK'
|
||||
}).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
|
||||
const statusBadge = getStatusBadge(card.status);
|
||||
const expiresAt = card.expires_at ?
|
||||
|
||||
@ -129,7 +129,6 @@
|
||||
<th class="text-end">Købte Timer</th>
|
||||
<th class="text-end">Brugte Timer</th>
|
||||
<th class="text-end">Tilbage</th>
|
||||
<th>Forbrug</th>
|
||||
<th class="text-end">Pris/Time</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th>Status</th>
|
||||
@ -139,7 +138,7 @@
|
||||
</thead>
|
||||
<tbody id="cardsTableBody">
|
||||
<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">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
@ -160,73 +159,35 @@
|
||||
<h5 class="modal-title">💳 Opret Nyt Prepaid Kort</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body p-4">
|
||||
<form id="createCardForm" class="needs-validation" novalidate>
|
||||
<!-- 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="modal-body">
|
||||
<form id="createCardForm">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Udløbsdato <small class="text-muted fw-normal">(valgfri)</small></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-calendar"></i></span>
|
||||
<label class="form-label">Kunde *</label>
|
||||
<select class="form-select" id="customerId" required>
|
||||
<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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Bemærkninger</label>
|
||||
<textarea class="form-control" id="notes" rows="3" placeholder="Interne noter..."></textarea>
|
||||
<label class="form-label">Bemærkninger</label>
|
||||
<textarea class="form-control" id="notes" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border small text-muted d-flex align-items-center gap-2">
|
||||
<i class="bi bi-info-circle-fill text-primary"></i>
|
||||
Kortnummeret genereres automatisk ved oprettelse
|
||||
<div class="alert alert-info small">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
Kortnummeret bliver automatisk genereret
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@ -309,7 +270,7 @@ function renderCards(cards) {
|
||||
|
||||
if (!cards || cards.length === 0) {
|
||||
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
|
||||
</td></tr>
|
||||
`;
|
||||
@ -328,14 +289,6 @@ function renderCards(cards) {
|
||||
const pricePerHour = parseFloat(card.price_per_hour);
|
||||
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 `
|
||||
<tr>
|
||||
<td>
|
||||
@ -354,19 +307,6 @@ function renderCards(cards) {
|
||||
${remainingHours.toFixed(1)} t
|
||||
</strong>
|
||||
</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"><strong>${totalAmount.toFixed(2)} kr</strong></td>
|
||||
<td>${statusBadge}</td>
|
||||
@ -401,120 +341,36 @@ function getStatusBadge(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
|
||||
let allCustomers = [];
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
// Fetch max customers for client-side filtering (up to 1000)
|
||||
const response = await fetch('/api/v1/customers?limit=1000');
|
||||
const data = await response.json();
|
||||
const response = await fetch('/api/v1/customers');
|
||||
const customers = await response.json();
|
||||
|
||||
// Handle API response format (might be array or paginated object)
|
||||
allCustomers = Array.isArray(data) ? data : (data.customers || []);
|
||||
|
||||
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());
|
||||
}
|
||||
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);
|
||||
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
|
||||
function openCreateModal() {
|
||||
const form = document.getElementById('createCardForm');
|
||||
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');
|
||||
|
||||
document.getElementById('createCardForm').reset();
|
||||
createCardModal.show();
|
||||
}
|
||||
|
||||
// Create Card
|
||||
async function createCard() {
|
||||
const form = document.getElementById('createCardForm');
|
||||
|
||||
// Custom validation for dropdown
|
||||
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');
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
customer_id: parseInt(customerId),
|
||||
customer_id: parseInt(document.getElementById('customerId').value),
|
||||
purchased_hours: parseFloat(document.getElementById('purchasedHours').value),
|
||||
price_per_hour: parseFloat(document.getElementById('pricePerHour').value),
|
||||
expires_at: document.getElementById('expiresAt').value || null,
|
||||
@ -537,9 +393,6 @@ async function createCard() {
|
||||
loadStats();
|
||||
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!');
|
||||
} catch (error) {
|
||||
console.error('Error creating card:', error);
|
||||
@ -547,7 +400,6 @@ async function createCard() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Cancel Card
|
||||
async function cancelCard(cardId) {
|
||||
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 {
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
@ -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
|
||||
@ -227,71 +227,6 @@ class EconomicService:
|
||||
logger.error(f"❌ Error searching customer by name: {e}")
|
||||
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 ==========
|
||||
|
||||
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}")
|
||||
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) ==========
|
||||
|
||||
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}")
|
||||
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,
|
||||
journal_number: int,
|
||||
supplier_number: int,
|
||||
|
||||
@ -61,14 +61,13 @@ class EmailAnalysisService:
|
||||
"""Build Danish system prompt for email classification"""
|
||||
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:
|
||||
- invoice: Contains invoice number, amount, or payment info
|
||||
- time_confirmation: Time/hours confirmation, often with case references
|
||||
- case_notification: Notifications about specific cases (CC0001, Case #123)
|
||||
- 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
|
||||
|
||||
Response format (JSON only, no other text):
|
||||
|
||||
@ -5,18 +5,16 @@ Based on OmniSync architecture adapted for BMC Hub
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime
|
||||
|
||||
from app.services.email_service import EmailService
|
||||
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.email_workflow_service import email_workflow_service
|
||||
from app.services.email_activity_logger import email_activity_logger
|
||||
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__)
|
||||
|
||||
@ -27,7 +25,6 @@ class EmailProcessorService:
|
||||
def __init__(self):
|
||||
self.email_service = EmailService()
|
||||
self.analysis_service = EmailAnalysisService()
|
||||
self.transcription_service = TranscriptionService()
|
||||
self.enabled = settings.EMAIL_TO_TICKET_ENABLED
|
||||
self.rules_enabled = settings.EMAIL_RULES_ENABLED
|
||||
self.auto_process = settings.EMAIL_RULES_AUTO_PROCESS
|
||||
@ -81,13 +78,37 @@ class EmailProcessorService:
|
||||
message_id=email_data.get('message_id', 'unknown')
|
||||
)
|
||||
|
||||
# Step 3-5: Process the single email
|
||||
result = await self.process_single_email(email_data)
|
||||
|
||||
if result.get('classified'):
|
||||
# Step 3: Classify email with AI
|
||||
if settings.EMAIL_AI_ENABLED and settings.EMAIL_AUTO_CLASSIFY:
|
||||
await self._classify_and_update(email_data)
|
||||
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
|
||||
elif workflow_processed:
|
||||
logger.info(f"⏭️ Email {email_id} processed by workflow, skipping rules (coordination)")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error processing email: {e}")
|
||||
@ -101,106 +122,21 @@ class EmailProcessorService:
|
||||
stats['errors'] += 1
|
||||
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):
|
||||
"""Classify email and update database"""
|
||||
try:
|
||||
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)
|
||||
logger.info(f"🔍 Running keyword classification for email {email_data['id']}")
|
||||
# Run classification (AI or simple keyword-based)
|
||||
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)
|
||||
|
||||
classification = result.get('classification', 'unknown')
|
||||
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
|
||||
query = """
|
||||
UPDATE email_messages
|
||||
@ -521,108 +457,6 @@ class EmailProcessorService:
|
||||
"""
|
||||
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):
|
||||
"""Manually reprocess a single email"""
|
||||
try:
|
||||
@ -636,33 +470,6 @@ class EmailProcessorService:
|
||||
|
||||
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)
|
||||
if settings.EMAIL_AUTO_CLASSIFY:
|
||||
await self._classify_and_update(email_data)
|
||||
|
||||
77
app/services/email_scheduler.py
Normal file
77
app/services/email_scheduler.py
Normal 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()
|
||||
@ -297,22 +297,11 @@ class EmailService:
|
||||
continue
|
||||
|
||||
# Skip text parts (body content)
|
||||
content_type = part.get_content_type()
|
||||
if content_type in ['text/plain', 'text/html']:
|
||||
if part.get_content_type() in ['text/plain', 'text/html']:
|
||||
continue
|
||||
|
||||
# Check if part has a filename (indicates attachment)
|
||||
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:
|
||||
# Decode filename if needed
|
||||
filename = self._decode_header(filename)
|
||||
@ -423,26 +412,14 @@ class EmailService:
|
||||
else:
|
||||
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({
|
||||
'filename': filename or 'unknown',
|
||||
'filename': att.get('name', 'unknown'),
|
||||
'content': content,
|
||||
'content_type': content_type,
|
||||
'content_type': att.get('contentType', 'application/octet-stream'),
|
||||
'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:
|
||||
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"
|
||||
execute_query(query, (status, email_id))
|
||||
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
|
||||
|
||||
|
||||
@ -51,6 +51,15 @@ class EmailWorkflowService:
|
||||
|
||||
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 = {
|
||||
'status': 'executed',
|
||||
'workflows_executed': 0,
|
||||
@ -59,29 +68,6 @@ class EmailWorkflowService:
|
||||
'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
|
||||
for workflow in workflows:
|
||||
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")
|
||||
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]:
|
||||
"""Find all workflows that match this email"""
|
||||
classification = email_data.get('classification')
|
||||
@ -193,7 +104,7 @@ class EmailWorkflowService:
|
||||
ORDER BY priority ASC
|
||||
"""
|
||||
|
||||
workflows = execute_query(query, (classification, confidence))
|
||||
workflows = execute_query_single(query, (classification, confidence))
|
||||
|
||||
# Filter by additional patterns
|
||||
matching = []
|
||||
@ -362,7 +273,6 @@ class EmailWorkflowService:
|
||||
'link_to_customer': self._action_link_to_customer,
|
||||
'extract_invoice_data': self._action_extract_invoice_data,
|
||||
'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_email_notification': self._action_send_email_notification,
|
||||
'mark_as_processed': self._action_mark_as_processed,
|
||||
@ -393,70 +303,6 @@ class EmailWorkflowService:
|
||||
|
||||
# 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:
|
||||
"""Create a ticket from email using new ticket system"""
|
||||
from app.ticket.backend.email_integration import EmailTicketIntegration
|
||||
@ -560,10 +406,9 @@ class EmailWorkflowService:
|
||||
vendor_id = result['id']
|
||||
|
||||
# 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",
|
||||
(email_data['id'],))
|
||||
current_vendor = result_vendor[0] if result_vendor else None
|
||||
|
||||
if current_vendor and current_vendor.get('supplier_id') == vendor_id:
|
||||
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')
|
||||
|
||||
# Get PDF attachments from email
|
||||
attachments = execute_query(
|
||||
attachments = execute_query_single(
|
||||
"""SELECT filename, file_path, size_bytes, content_type
|
||||
FROM email_attachments
|
||||
WHERE email_id = %s AND content_type = 'application/pdf'""",
|
||||
|
||||
@ -6,7 +6,6 @@ Handles supplier invoice extraction using Ollama LLM with CVR matching
|
||||
import json
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
from datetime import datetime
|
||||
@ -594,42 +593,6 @@ Output: {
|
||||
logger.info(f"⚠️ No vendor found with CVR: {cvr_clean}")
|
||||
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
|
||||
ollama_service = OllamaService()
|
||||
|
||||
@ -35,22 +35,10 @@ class SimpleEmailClassifier:
|
||||
'case_notification': [
|
||||
'cc[0-9]{4}', 'case #', 'sag ', 'ticket', 'support'
|
||||
],
|
||||
'recording': [
|
||||
'lydbesked', 'optagelse', 'voice note', 'voicemail',
|
||||
'telefonsvarer', 'samtale', 'recording', 'audio note'
|
||||
],
|
||||
'bankruptcy': [
|
||||
'konkurs', 'bankruptcy', 'rekonstruktion', 'insolvency',
|
||||
'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': [
|
||||
'unsubscribe', 'click here', 'free offer', 'gratis tilbud',
|
||||
'vind nu', 'win now', 'limited time'
|
||||
|
||||
@ -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
|
||||
@ -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
|
||||
@ -82,101 +82,6 @@ class VTigerService:
|
||||
logger.error(f"❌ vTiger query error: {e}")
|
||||
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]:
|
||||
"""
|
||||
Fetch sales orders for a customer from vTiger
|
||||
|
||||
@ -273,17 +273,18 @@ async def reset_user_password(user_id: int, new_password: str):
|
||||
return {"message": "Password reset successfully"}
|
||||
|
||||
|
||||
# AI Prompts Management
|
||||
|
||||
def _get_default_prompts():
|
||||
"""Helper to get default system prompts"""
|
||||
# AI Prompts Endpoint
|
||||
@router.get("/ai-prompts", tags=["Settings"])
|
||||
async def get_ai_prompts():
|
||||
"""Get all AI prompts used in the system"""
|
||||
from app.services.ollama_service import OllamaService
|
||||
|
||||
ollama_service = OllamaService()
|
||||
|
||||
return {
|
||||
prompts = {
|
||||
"invoice_extraction": {
|
||||
"name": "📄 Faktura Udtrækning (Invoice Parser)",
|
||||
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM. Håndterer danske nummerformater, datoer og linjegenkendelse.",
|
||||
"name": "Faktura Udtrækning (Invoice Extraction)",
|
||||
"description": "System prompt brugt til at udtrække data fra fakturaer og kreditnotaer via Ollama LLM",
|
||||
"model": ollama_service.model,
|
||||
"endpoint": ollama_service.endpoint,
|
||||
"prompt": ollama_service._build_system_prompt(),
|
||||
@ -292,209 +293,7 @@ def _get_default_prompts():
|
||||
"top_p": 0.9,
|
||||
"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 på 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 på 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
|
||||
|
||||
|
||||
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")
|
||||
|
||||
|
||||
|
||||
@ -980,92 +980,41 @@ async function loadAIPrompts() {
|
||||
const prompts = await response.json();
|
||||
|
||||
const container = document.getElementById('aiPromptsContent');
|
||||
|
||||
const accordionHtml = `
|
||||
<div class="accordion" id="aiPromptsAccordion">
|
||||
${Object.entries(prompts).map(([key, prompt], index) => `
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading_${key}">
|
||||
<button class="accordion-button ${index !== 0 ? 'collapsed' : ''}" type="button"
|
||||
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>
|
||||
container.innerHTML = Object.entries(prompts).map(([key, prompt]) => `
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h6 class="mb-1 fw-bold">${escapeHtml(prompt.name)}</h6>
|
||||
<small class="text-muted">${escapeHtml(prompt.description)}</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="copyPrompt('${key}')">
|
||||
<i class="bi bi-clipboard me-1"></i>Kopier
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse_${key}" class="accordion-collapse collapse ${index === 0 ? 'show' : ''}"
|
||||
aria-labelledby="heading_${key}" data-bs-parent="#aiPromptsAccordion">
|
||||
<div class="accordion-body bg-light">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<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>
|
||||
<small class="text-muted">Model:</small>
|
||||
<div><code>${escapeHtml(prompt.model)}</code></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<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>
|
||||
<small class="text-muted">Endpoint:</small>
|
||||
<div><code>${escapeHtml(prompt.endpoint)}</code></div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card h-100 border-0 shadow-sm">
|
||||
<div class="card-body py-2">
|
||||
<small class="text-uppercase text-muted fw-bold" style="font-size: 0.7rem;">Parametre</small>
|
||||
<div class="font-monospace small text-truncate" title='${JSON.stringify(prompt.parameters)}'>${JSON.stringify(prompt.parameters)}</div>
|
||||
<small class="text-muted">Parametre:</small>
|
||||
<div><code>${JSON.stringify(prompt.parameters)}</code></div>
|
||||
</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 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;
|
||||
`).join('');
|
||||
|
||||
} catch (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) {
|
||||
const promptElement = document.getElementById(`prompt_${key}`);
|
||||
const text = promptElement.textContent;
|
||||
|
||||
@ -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/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="/conversations/my"><i class="bi bi-mic me-2"></i>Mine Samtaler</a></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="/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="#">Produkter</a></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>
|
||||
</ul>
|
||||
</li>
|
||||
@ -262,7 +259,7 @@
|
||||
</a>
|
||||
<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="/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="#">Betalinger</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@ -273,7 +270,6 @@
|
||||
</a>
|
||||
<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/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/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>
|
||||
|
||||
@ -4,8 +4,7 @@ Klippekort (Prepaid Time Card) Service
|
||||
|
||||
Business logic for prepaid time cards: purchase, balance, deduction.
|
||||
|
||||
NOTE: As of migration 065, customers can have multiple active cards simultaneously.
|
||||
When multiple active cards exist, operations default to the card with earliest expiry.
|
||||
CONSTRAINT: Only 1 active card per customer (enforced by database UNIQUE index).
|
||||
"""
|
||||
|
||||
import logging
|
||||
@ -39,7 +38,8 @@ class KlippekortService:
|
||||
"""
|
||||
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:
|
||||
card_data: Card purchase data
|
||||
@ -47,9 +47,26 @@ class KlippekortService:
|
||||
|
||||
Returns:
|
||||
Created card dict
|
||||
|
||||
Raises:
|
||||
ValueError: If customer already has active card
|
||||
"""
|
||||
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")
|
||||
|
||||
# Insert card (trigger will auto-generate card_number if NULL)
|
||||
@ -115,31 +132,19 @@ class KlippekortService:
|
||||
"SELECT * FROM tticket_prepaid_balances WHERE id = %s",
|
||||
(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
|
||||
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.
|
||||
"""
|
||||
cards = KlippekortService.get_active_cards_for_customer(customer_id)
|
||||
return cards[0] if cards else None
|
||||
return execute_query_single(
|
||||
"""
|
||||
SELECT * FROM tticket_prepaid_cards
|
||||
WHERE customer_id = %s AND status = 'active'
|
||||
""",
|
||||
(customer_id,))
|
||||
|
||||
@staticmethod
|
||||
def check_balance(customer_id: int) -> Dict[str, Any]:
|
||||
|
||||
@ -60,7 +60,6 @@ class BillingMethod(str, Enum):
|
||||
INVOICE = "invoice"
|
||||
INTERNAL = "internal"
|
||||
WARRANTY = "warranty"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class WorklogStatus(str, Enum):
|
||||
@ -89,14 +88,6 @@ class TransactionType(str, Enum):
|
||||
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
|
||||
# ============================================================================
|
||||
@ -107,8 +98,6 @@ class TTicketBase(BaseModel):
|
||||
description: Optional[str] = None
|
||||
status: TicketStatus = Field(default=TicketStatus.OPEN)
|
||||
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)
|
||||
customer_id: Optional[int] = Field(None, description="Reference til customers.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)
|
||||
description: Optional[str] = None
|
||||
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')
|
||||
@classmethod
|
||||
@ -263,7 +251,6 @@ class TTicketWorklogUpdate(BaseModel):
|
||||
billing_method: Optional[BillingMethod] = None
|
||||
status: Optional[WorklogStatus] = None
|
||||
prepaid_card_id: Optional[int] = None
|
||||
is_internal: Optional[bool] = None
|
||||
|
||||
|
||||
class TTicketWorklog(TTicketWorklogBase):
|
||||
|
||||
@ -514,61 +514,15 @@ async def create_worklog(
|
||||
Create worklog entry for ticket
|
||||
|
||||
Creates time entry in draft status.
|
||||
If billing_method is 'prepaid_card', validates and auto-selects card when only 1 active.
|
||||
"""
|
||||
try:
|
||||
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(
|
||||
"""
|
||||
INSERT INTO tticket_worklog
|
||||
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id, is_internal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
(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)
|
||||
""",
|
||||
(
|
||||
ticket_id,
|
||||
@ -579,8 +533,7 @@ async def create_worklog(
|
||||
worklog_data.billing_method.value,
|
||||
'draft',
|
||||
user_id or worklog_data.user_id,
|
||||
prepaid_card_id,
|
||||
worklog_data.is_internal
|
||||
worklog_data.prepaid_card_id
|
||||
)
|
||||
)
|
||||
|
||||
@ -591,14 +544,10 @@ async def create_worklog(
|
||||
entity_id=worklog_id,
|
||||
user_id=user_id,
|
||||
action="created",
|
||||
details={
|
||||
"hours": float(worklog_data.hours),
|
||||
"work_type": worklog_data.work_type.value,
|
||||
"is_internal": worklog_data.is_internal
|
||||
}
|
||||
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
|
||||
)
|
||||
|
||||
worklog = execute_query_single(
|
||||
worklog = execute_query(
|
||||
"SELECT * FROM tticket_worklog WHERE id = %s",
|
||||
(worklog_id,))
|
||||
|
||||
@ -620,12 +569,11 @@ async def update_worklog(
|
||||
Update worklog entry (partial update)
|
||||
|
||||
Only draft entries can be fully edited.
|
||||
If billing_method changes to 'prepaid_card', validates and auto-selects card when only 1 active.
|
||||
"""
|
||||
try:
|
||||
# Get current worklog
|
||||
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,))
|
||||
|
||||
if not current:
|
||||
@ -637,43 +585,6 @@ async def update_worklog(
|
||||
|
||||
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():
|
||||
if hasattr(value, 'value'):
|
||||
value = value.value
|
||||
|
||||
@ -89,8 +89,8 @@ class TicketService:
|
||||
INSERT INTO tticket_tickets (
|
||||
ticket_number, subject, description, status, priority, category,
|
||||
customer_id, contact_id, assigned_to_user_id, created_by_user_id,
|
||||
source, tags, custom_fields, ticket_type, internal_note
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
source, tags, custom_fields
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
@ -106,9 +106,7 @@ class TicketService:
|
||||
user_id or ticket_data.created_by_user_id,
|
||||
ticket_data.source.value,
|
||||
ticket_data.tags or [], # PostgreSQL array
|
||||
Json(ticket_data.custom_fields or {}), # PostgreSQL JSONB
|
||||
ticket_data.ticket_type.value,
|
||||
ticket_data.internal_note
|
||||
Json(ticket_data.custom_fields or {}) # PostgreSQL JSONB
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -5,17 +5,13 @@
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.ticket-header {
|
||||
background: white;
|
||||
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-light) 100%);
|
||||
padding: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
color: white;
|
||||
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 {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 1rem;
|
||||
@ -292,69 +288,41 @@
|
||||
{% block content %}
|
||||
<div class="container-fluid px-4">
|
||||
<!-- Ticket Header -->
|
||||
<div class="ticket-header priority-{{ ticket.priority }}">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 mb-2 text-muted small">
|
||||
<span class="ticket-number font-monospace">{{ ticket.ticket_number }}</span>
|
||||
<span>•</span>
|
||||
<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
|
||||
<div class="ticket-header">
|
||||
<div class="ticket-number">{{ ticket.ticket_number }}</div>
|
||||
<div class="ticket-title">{{ ticket.subject }}</div>
|
||||
<div class="mt-3">
|
||||
<span class="badge badge-status-{{ ticket.status }}">
|
||||
{{ ticket.status.replace('_', ' ').title() }}
|
||||
</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 }}">
|
||||
{{ ticket.priority.title() }} Priority
|
||||
</span>
|
||||
|
||||
<!-- Tags -->
|
||||
<div class="tags-container d-inline-flex m-0" id="ticketTags"></div>
|
||||
<button class="btn btn-sm btn-light text-muted" onclick="showTagPicker('ticket', {{ ticket.id }}, reloadTags)">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</div>
|
||||
<div class="tags-container" id="ticketTags">
|
||||
<!-- Tags loaded via JavaScript -->
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Internal Note Alert -->
|
||||
{% if ticket.internal_note %}
|
||||
<div class="alert alert-warning mt-3 mb-0 d-flex align-items-start border-warning" style="background-color: #fff3cd;">
|
||||
<i class="bi bi-shield-lock-fill me-2 fs-5 text-warning"></i>
|
||||
<div>
|
||||
<strong><i class="bi bi-eye-slash"></i> Internt Notat:</strong>
|
||||
<span style="white-space: pre-wrap;">{{ ticket.internal_note }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- Action Buttons -->
|
||||
<div class="action-buttons mb-4">
|
||||
<a href="/api/v1/ticket/tickets/{{ ticket.id }}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Rediger
|
||||
</a>
|
||||
<button class="btn btn-outline-secondary" onclick="addComment()">
|
||||
<i class="bi bi-chat"></i> Tilføj Kommentar
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" onclick="addWorklog()">
|
||||
<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>
|
||||
|
||||
<!-- Action Buttons Removed (Moved to specific sections) -->
|
||||
<div class="row">
|
||||
<!-- Main Content -->
|
||||
<div class="col-lg-8">
|
||||
@ -376,23 +344,6 @@
|
||||
<div class="section-title">
|
||||
<i class="bi bi-chat-dots"></i> Kommentarer ({{ comments|length }})
|
||||
</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 %}
|
||||
{% for comment in comments %}
|
||||
<div class="comment {% if comment.internal_note %}internal{% endif %}">
|
||||
@ -425,14 +376,8 @@
|
||||
<!-- Worklog -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="section-title mb-0">
|
||||
<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 class="section-title">
|
||||
<i class="bi bi-clock-history"></i> Worklog ({{ worklog|length }})
|
||||
</div>
|
||||
{% if worklog %}
|
||||
<div class="table-responsive">
|
||||
@ -495,32 +440,38 @@
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Metadata Card (Consolidated) -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header bg-white py-3 border-bottom">
|
||||
<h6 class="mb-0 fw-bold text-dark"><i class="bi bi-info-circle me-2"></i>Detaljer</h6>
|
||||
</div>
|
||||
<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>
|
||||
<!-- Ticket Info -->
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">
|
||||
<i class="bi bi-info-circle"></i> Ticket Information
|
||||
</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 %}
|
||||
</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 -->
|
||||
<div class="card">
|
||||
@ -539,9 +490,47 @@
|
||||
</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>
|
||||
@ -549,262 +538,15 @@
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
// ============================================
|
||||
// QUICK COMMENT & STATUS
|
||||
// ============================================
|
||||
|
||||
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();
|
||||
}
|
||||
// Add comment (placeholder - integrate with API)
|
||||
function addComment() {
|
||||
alert('Add comment functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/comments');
|
||||
}
|
||||
|
||||
async function submitQuickComment() {
|
||||
const textarea = document.getElementById('quickCommentText');
|
||||
const internalCheck = document.getElementById('quickCommentInternal');
|
||||
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');
|
||||
// Add worklog (placeholder - integrate with API)
|
||||
function addWorklog() {
|
||||
alert('Add worklog functionality - integrate with POST /api/v1/ticket/tickets/{{ ticket.id }}/worklog');
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
async function loadTicketTags() {
|
||||
@ -814,7 +556,6 @@
|
||||
|
||||
const tags = await response.json();
|
||||
const container = document.getElementById('ticketTags');
|
||||
if (!container) return; // Guard clause
|
||||
|
||||
if (tags.length === 0) {
|
||||
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() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/ticket/tickets/{{ ticket.id }}/contacts');
|
||||
@ -870,7 +607,6 @@
|
||||
|
||||
const data = await response.json();
|
||||
const container = document.getElementById('contactsList');
|
||||
if (!container) return;
|
||||
|
||||
if (!data.contacts || data.contacts.length === 0) {
|
||||
container.innerHTML = `
|
||||
@ -934,25 +670,20 @@
|
||||
}
|
||||
|
||||
async function showAddContactModal() {
|
||||
// Load contacts if not cached
|
||||
if (allContactsCache.length === 0) {
|
||||
// Fetch all contacts for selection
|
||||
try {
|
||||
const response = await fetch('/api/v1/contacts?limit=1000');
|
||||
const data = await response.json();
|
||||
allContactsCache = data.contacts || [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load contacts for modal", e);
|
||||
alert("Kunne ikke hente kontaktliste");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw new Error('Failed to load contacts');
|
||||
|
||||
// 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 ticketContacts = await ticketContactsResp.json();
|
||||
const isFirstContact = !ticketContacts.contacts || ticketContacts.contacts.length === 0;
|
||||
|
||||
// Define Modal HTML
|
||||
// Create modal content
|
||||
const modalHtml = `
|
||||
<div class="modal fade" id="addContactModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
@ -962,82 +693,20 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<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">
|
||||
<label class="form-label small">Titel</label>
|
||||
<input type="text" class="form-control" id="newContactTitle">
|
||||
<label class="form-label">Kontakt *</label>
|
||||
<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 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">
|
||||
<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>
|
||||
<optgroup label="Standard roller">
|
||||
<option value="primary">⭐ Primær kontakt</option>
|
||||
@ -1058,22 +727,20 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3" id="customRoleDiv" style="display: none;">
|
||||
<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 class="mb-3">
|
||||
<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 class="modal-footer">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
@ -1082,210 +749,26 @@
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clean up old
|
||||
// Remove old modal if exists
|
||||
const oldModal = document.getElementById('addContactModal');
|
||||
if (oldModal) oldModal.remove();
|
||||
|
||||
// Append and show new modal
|
||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
||||
const modalEl = document.getElementById('addContactModal');
|
||||
const modal = new bootstrap.Modal(modalEl);
|
||||
const modal = new bootstrap.Modal(document.getElementById('addContactModal'));
|
||||
|
||||
// Setup Search Listener
|
||||
const input = document.getElementById('contactSearchInput');
|
||||
input.addEventListener('input', (e) => filterContacts(e.target.value));
|
||||
|
||||
// Setup First Contact Logic
|
||||
// Show notice and disable role selector if first contact
|
||||
if (isFirstContact) {
|
||||
document.getElementById('firstContactNotice').style.display = 'block';
|
||||
document.getElementById('roleSelect').value = 'primary';
|
||||
// Note: User still needs to select a contact first
|
||||
document.getElementById('roleSelect').disabled = true;
|
||||
}
|
||||
|
||||
modal.show();
|
||||
// Focus input
|
||||
setTimeout(() => input.focus(), 500);
|
||||
} catch (error) {
|
||||
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() {
|
||||
@ -1303,15 +786,16 @@
|
||||
}
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
|
||||
let role = document.getElementById('roleSelect').value;
|
||||
const notes = document.getElementById('contactNotes').value;
|
||||
|
||||
// Custom role logic
|
||||
// If custom role selected, use the custom input
|
||||
if (role === '_custom') {
|
||||
const customRole = document.getElementById('customRoleInput').value.trim();
|
||||
if (!customRole) {
|
||||
@ -1322,7 +806,7 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${selectedContactId}&role=${role}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
||||
const url = `/api/v1/ticket/tickets/{{ ticket.id }}/contacts?contact_id=${contactId}&role=${role}${notes ? '¬es=' + encodeURIComponent(notes) : ''}`;
|
||||
const response = await fetch(url, { method: 'POST' });
|
||||
|
||||
if (!response.ok) {
|
||||
@ -1377,15 +861,9 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTicketTags();
|
||||
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) {
|
||||
const originalShow = window.tagPicker.show.bind(window.tagPicker);
|
||||
window.showTagPicker = function(entityType, entityId, onSelect) {
|
||||
|
||||
@ -1,9 +1,104 @@
|
||||
{% extends "shared/frontend/base.html" %}
|
||||
|
||||
{% block title %}Worklog Godkendelse - BMC Hub{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="da">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<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>
|
||||
: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 {
|
||||
background: var(--bg-card);
|
||||
}
|
||||
@ -89,6 +184,22 @@
|
||||
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 {
|
||||
background: var(--bg-card);
|
||||
padding: 1.5rem;
|
||||
@ -142,41 +253,74 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
</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 -->
|
||||
<div class="row mb-4 align-items-center">
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<h1 class="mb-2">
|
||||
<i class="bi bi-clock-history"></i> Worklog Godkendelse
|
||||
</h1>
|
||||
<p class="text-muted">Godkend eller afvis enkelt-entries fra draft worklog</p>
|
||||
</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>
|
||||
|
||||
<!-- Statistics Row -->
|
||||
<div class="row stats-row">
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card p-4">
|
||||
<div class="card stat-card">
|
||||
<h3>{{ total_entries }}</h3>
|
||||
<p>Entries til godkendelse</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card stat-card p-4">
|
||||
<div class="card stat-card">
|
||||
<h3>{{ "%.2f"|format(total_hours) }}t</h3>
|
||||
<p>Total timer</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p>Fakturerbare timer</p>
|
||||
</div>
|
||||
@ -236,7 +380,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for worklog in worklogs %}
|
||||
<tr class="worklog-row {% if worklog.billing_method == 'unknown' %}table-warning{% endif %}">
|
||||
<tr class="worklog-row">
|
||||
<td>
|
||||
<span class="ticket-number">{{ worklog.ticket_number }}</span>
|
||||
<br>
|
||||
@ -276,12 +420,6 @@
|
||||
{% if worklog.card_number %}
|
||||
<br><small class="text-muted">{{ worklog.card_number }}</small>
|
||||
{% 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 %}
|
||||
</td>
|
||||
<td>
|
||||
@ -297,31 +435,15 @@
|
||||
<div class="btn-group-actions">
|
||||
<form method="post" action="/ticket/worklog/{{ worklog.id }}/approve" style="display: inline;">
|
||||
<input type="hidden" name="redirect_to" value="{{ request.url }}">
|
||||
<button type="submit" class="btn btn-approve btn-sm" title="Godkend">
|
||||
<i class="bi bi-check-circle"></i>
|
||||
<button type="submit" class="btn btn-approve btn-sm">
|
||||
<i class="bi bi-check-circle"></i> Godkend
|
||||
</button>
|
||||
</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
|
||||
type="button"
|
||||
class="btn btn-reject btn-sm ms-1"
|
||||
title="Afvis"
|
||||
onclick="rejectWorklog({{ worklog.id }}, '{{ request.url }}')">
|
||||
<i class="bi bi-x-circle"></i>
|
||||
<i class="bi bi-x-circle"></i> Afvis
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
@ -343,76 +465,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% 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>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
@ -447,10 +499,37 @@
|
||||
</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>
|
||||
// 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
|
||||
function rejectWorklog(worklogId, redirectUrl) {
|
||||
const form = document.getElementById('rejectForm');
|
||||
@ -476,153 +555,6 @@
|
||||
document.body.appendChild(badge);
|
||||
}
|
||||
}, 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>
|
||||
{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -266,41 +266,6 @@ class EconomicExportService:
|
||||
|
||||
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
|
||||
economic_payload = {
|
||||
"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)
|
||||
)
|
||||
|
||||
# Marker time entries som billed og opdater billed_via_thehub_id
|
||||
# Marker time entries som billed
|
||||
execute_update(
|
||||
"""UPDATE tmodule_times
|
||||
SET status = 'billed',
|
||||
billed_via_thehub_id = %s
|
||||
SET status = 'billed'
|
||||
WHERE id IN (
|
||||
SELECT UNNEST(time_entry_ids)
|
||||
FROM tmodule_order_lines
|
||||
WHERE order_id = %s
|
||||
)""",
|
||||
(request.order_id, request.order_id)
|
||||
(request.order_id,)
|
||||
)
|
||||
|
||||
# Hent vTiger IDs for tidsregistreringerne
|
||||
|
||||
@ -138,10 +138,8 @@ class TModuleTime(TModuleTimeBase):
|
||||
rounded_to: Optional[Decimal] = None
|
||||
approval_note: Optional[str] = None
|
||||
billable: bool = True
|
||||
is_travel: bool = False
|
||||
approved_at: Optional[datetime] = 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
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
@ -275,7 +273,6 @@ class TModuleOrderDetails(BaseModel):
|
||||
class TModuleApprovalStats(BaseModel):
|
||||
"""Approval statistics per customer (from view)"""
|
||||
customer_id: int
|
||||
hub_customer_id: Optional[int] = None
|
||||
customer_name: str
|
||||
customer_vtiger_id: str
|
||||
uses_time_card: bool = False
|
||||
@ -317,13 +314,6 @@ class TModuleWizardProgress(BaseModel):
|
||||
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):
|
||||
"""Next entry for wizard approval"""
|
||||
has_next: bool
|
||||
|
||||
@ -100,9 +100,6 @@ class OrderService:
|
||||
c.vtiger_id as case_vtiger_id,
|
||||
COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number,
|
||||
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
|
||||
FROM tmodule_times t
|
||||
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_number': time_entry.get('case_number'), # Fra vtiger_data
|
||||
'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'),
|
||||
'worked_date': time_entry.get('worked_date'), # Seneste dato
|
||||
'is_travel': False, # Marker hvis nogen entry er rejse
|
||||
@ -199,30 +193,11 @@ class OrderService:
|
||||
# Sidste fallback hvis intet andet
|
||||
case_title = "Support arbejde"
|
||||
|
||||
# Build description med case nummer, titel, dato, type, prioritet
|
||||
description_parts = []
|
||||
|
||||
# Case nummer og titel
|
||||
# Build description med case nummer prefix
|
||||
if case_number:
|
||||
description_parts.append(f"{case_number} - {case_title}")
|
||||
description = f"{case_number} - {case_title}"
|
||||
else:
|
||||
description_parts.append(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)
|
||||
description = case_title
|
||||
|
||||
# Calculate line total
|
||||
line_total = case_hours * hourly_rate
|
||||
@ -304,8 +279,22 @@ class OrderService:
|
||||
|
||||
logger.info(f"✅ Created {len(created_lines)} order lines")
|
||||
|
||||
# NOTE: Time entries remain 'approved' status until exported to e-conomic
|
||||
# They will be updated to 'billed' with billed_via_thehub_id in economic_export.py
|
||||
# Update time entries to 'billed' status
|
||||
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
|
||||
audit.log_order_created(
|
||||
|
||||
@ -8,7 +8,6 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Body
|
||||
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.economic_export import economic_service
|
||||
from app.timetracking.backend.audit import audit
|
||||
from app.services.customer_consistency import CustomerConsistencyService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -398,44 +396,6 @@ async def approve_time_entry(
|
||||
from app.core.config import settings
|
||||
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
|
||||
query = """
|
||||
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))
|
||||
rounded_to = None
|
||||
|
||||
# Note: hourly_rate is stored on customer level (tmodule_customers.hourly_rate), not on time entries
|
||||
# Frontend sends it for calculation display but we don't store it per time entry
|
||||
# Opdater med hourly_rate hvis angivet
|
||||
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
|
||||
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(
|
||||
time_id=time_id,
|
||||
approved_hours=approved_hours,
|
||||
rounded_to=rounded_to,
|
||||
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)
|
||||
)
|
||||
|
||||
@ -503,144 +468,16 @@ async def approve_time_entry(
|
||||
@router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"])
|
||||
async def reject_time_entry(
|
||||
time_id: int,
|
||||
request: Dict[str, Any] = Body(None), # Allow body
|
||||
reason: Optional[str] = None,
|
||||
user_id: Optional[int] = None
|
||||
):
|
||||
"""Afvis en tidsregistrering"""
|
||||
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)
|
||||
except Exception as 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 på 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"])
|
||||
async def reset_to_pending(
|
||||
time_id: int,
|
||||
@ -729,152 +566,6 @@ async def get_customer_progress(customer_id: int):
|
||||
# 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"])
|
||||
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,
|
||||
c.vtiger_id AS case_vtiger_id,
|
||||
c.description AS case_description,
|
||||
c.priority AS case_priority,
|
||||
c.module_type AS case_type,
|
||||
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.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]
|
||||
@ -1425,78 +1104,6 @@ async def get_customer_time_entries(customer_id: int, status: Optional[str] = No
|
||||
|
||||
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)}
|
||||
|
||||
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))
|
||||
|
||||
|
||||
@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"])
|
||||
async def get_time_entry(time_id: int):
|
||||
"""
|
||||
|
||||
@ -670,18 +670,17 @@ class TimeTrackingVTigerService:
|
||||
- timelognumber: Unique ID (TL1234)
|
||||
- duration: Time in seconds
|
||||
- relatedto: Reference to Case/Account
|
||||
- isbillable: '1' = yes, '0' = no
|
||||
- is_billable: '1' = yes, '0' = no
|
||||
- cf_timelog_invoiced: '1' = has been invoiced
|
||||
- billed_via_thehub_id: Hub order ID (in vTiger custom field)
|
||||
|
||||
We only sync entries where:
|
||||
- relatedto is not empty (linked to a Case or Account)
|
||||
- Has valid duration > 0
|
||||
- isbillable = '1' (only billable entries)
|
||||
- cf_timelog_invoiced = '0' or NULL (not yet invoiced in vTiger)
|
||||
- billed_via_thehub_id = '0' or NULL (not yet billed via Hub)
|
||||
|
||||
NOTE: is_billable and cf_timelog_invoiced fields are not reliably populated in vTiger,
|
||||
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}
|
||||
|
||||
@ -717,49 +716,12 @@ class TimeTrackingVTigerService:
|
||||
|
||||
logger.info(f"✅ Total fetched: {len(all_timelogs)} Timelog entries from vTiger")
|
||||
|
||||
# Filter timelogs based on requirements:
|
||||
# 1. isbillable = '1' (only billable)
|
||||
# 2. cf_timelog_invoiced = '0' or NULL (not invoiced in vTiger)
|
||||
# 3. billed_via_thehub_id = '0' or NULL (not billed via Hub)
|
||||
# 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}
|
||||
# We don't filter here - the existing code already filters by:
|
||||
# 1. duration > 0
|
||||
# 2. relatedto not empty
|
||||
# These filters happen in the processing loop below
|
||||
|
||||
for tl in all_timelogs:
|
||||
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
|
||||
timelogs = all_timelogs[:limit] # Trim to requested limit
|
||||
logger.info(f"📊 Processing {len(timelogs)} timelogs...")
|
||||
|
||||
# NOTE: retrieve API is too slow for batch operations (1500+ individual calls)
|
||||
@ -833,15 +795,13 @@ class TimeTrackingVTigerService:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Update only if NOT yet approved AND NOT yet billed
|
||||
# Update only if NOT yet approved
|
||||
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""",
|
||||
WHERE vtiger_id = %s AND status = 'pending'""",
|
||||
(
|
||||
timelog.get('name', ''),
|
||||
hours,
|
||||
@ -857,7 +817,7 @@ class TimeTrackingVTigerService:
|
||||
if result > 0:
|
||||
stats["updated"] += 1
|
||||
else:
|
||||
logger.debug(f"⏭️ Time entry {vtiger_id} already approved or billed")
|
||||
logger.debug(f"⏭️ Time entry {vtiger_id} already approved")
|
||||
stats["skipped"] += 1
|
||||
else:
|
||||
# Insert new
|
||||
|
||||
@ -49,70 +49,12 @@ class WizardService:
|
||||
|
||||
@staticmethod
|
||||
def get_all_customers_stats() -> list[TModuleApprovalStats]:
|
||||
"""Hent approval statistics for alle kunder (inkl. Hub Worklogs)"""
|
||||
"""Hent approval statistics for alle kunder"""
|
||||
try:
|
||||
# 1. Get base stats from module view
|
||||
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
|
||||
"""
|
||||
query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name"
|
||||
results = execute_query(query)
|
||||
stats_map = {row['customer_id']: dict(row) for row in results}
|
||||
|
||||
# 2. Get pending count from Hub Worklogs
|
||||
# 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 []
|
||||
return [TModuleApprovalStats(**row) for row in results]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Error getting all customer stats: {e}")
|
||||
@ -230,13 +172,6 @@ class WizardService:
|
||||
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
|
||||
logger.info(f"🔄 Updating time entry {approval.time_id} in database")
|
||||
update_query = """
|
||||
@ -250,7 +185,6 @@ class WizardService:
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
approved_by = %s
|
||||
WHERE id = %s
|
||||
AND billed_via_thehub_id IS NULL
|
||||
"""
|
||||
|
||||
execute_update(
|
||||
@ -331,13 +265,6 @@ class WizardService:
|
||||
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_query = """
|
||||
UPDATE tmodule_times
|
||||
@ -347,7 +274,6 @@ class WizardService:
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
approved_by = %s
|
||||
WHERE id = %s
|
||||
AND billed_via_thehub_id IS NULL
|
||||
"""
|
||||
|
||||
execute_update(update_query, (reason, user_id, time_id))
|
||||
@ -415,13 +341,6 @@ class WizardService:
|
||||
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
|
||||
update_query = """
|
||||
UPDATE tmodule_times
|
||||
@ -433,7 +352,6 @@ class WizardService:
|
||||
approved_at = NULL,
|
||||
approved_by = NULL
|
||||
WHERE id = %s
|
||||
AND billed_via_thehub_id IS NULL
|
||||
"""
|
||||
|
||||
execute_update(update_query, (reason, time_id))
|
||||
@ -572,9 +490,7 @@ class WizardService:
|
||||
SELECT c.id, c.title
|
||||
FROM tmodule_times t
|
||||
JOIN tmodule_cases c ON t.case_id = c.id
|
||||
WHERE t.customer_id = %s
|
||||
AND t.status = 'pending'
|
||||
AND t.billed_via_thehub_id IS NULL
|
||||
WHERE t.customer_id = %s AND t.status = 'pending'
|
||||
ORDER BY t.worked_date
|
||||
LIMIT 1
|
||||
"""
|
||||
@ -642,13 +558,6 @@ class WizardService:
|
||||
AND t.status = 'pending'
|
||||
AND t.billable = true
|
||||
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
|
||||
ORDER BY t.worked_date, t.id
|
||||
"""
|
||||
@ -676,13 +585,6 @@ class WizardService:
|
||||
AND t.status = 'pending'
|
||||
AND t.billable = true
|
||||
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
|
||||
"""
|
||||
|
||||
|
||||
@ -267,58 +267,10 @@
|
||||
</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>
|
||||
let allCustomers = [];
|
||||
let defaultRate = 850.00; // Fallback værdi
|
||||
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
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@ -724,7 +676,6 @@
|
||||
|
||||
async function createOrderForCustomer(customerId, customerName) {
|
||||
currentOrderCustomerId = customerId;
|
||||
pendingOrderCustomerId = customerId;
|
||||
document.getElementById('order-customer-name').textContent = customerName;
|
||||
document.getElementById('order-loading').classList.remove('d-none');
|
||||
document.getElementById('order-content').classList.add('d-none');
|
||||
@ -736,43 +687,6 @@
|
||||
modal.show();
|
||||
|
||||
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
|
||||
const response = await fetch(`/api/v1/timetracking/customers/${customerId}/times`);
|
||||
if (!response.ok) throw new Error('Failed to load time entries');
|
||||
@ -1050,185 +964,6 @@
|
||||
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>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@ -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 %}
|
||||
@ -28,18 +28,6 @@ async def timetracking_wizard(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")
|
||||
async def timetracking_customers(request: Request):
|
||||
"""Time Tracking Customers - manage hourly rates"""
|
||||
|
||||
@ -725,12 +725,6 @@
|
||||
<i class="bi bi-car-front"></i> Indeholder kørsel
|
||||
</label>
|
||||
</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 class="mt-3">
|
||||
@ -1162,10 +1156,6 @@
|
||||
const travelCheckbox = document.getElementById(`travel-${entryId}`);
|
||||
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
|
||||
const approvalNoteField = document.getElementById(`approval-note-${entryId}`);
|
||||
const approvalNote = approvalNoteField ? approvalNoteField.value.trim() : '';
|
||||
@ -1180,7 +1170,6 @@
|
||||
billable_hours: billableHours,
|
||||
hourly_rate: hourlyRate,
|
||||
is_travel: isTravel,
|
||||
billable: isBillable,
|
||||
approval_note: approvalNote || null
|
||||
})
|
||||
});
|
||||
|
||||
@ -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, "'")})' 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 %}
|
||||
@ -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())
|
||||
@ -48,10 +48,6 @@ else
|
||||
exit 1
|
||||
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 "🎉 Production is now running version $VERSION"
|
||||
echo " http://172.16.31.183:8000"
|
||||
|
||||
@ -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
@ -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`
|
||||
@ -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
25
main.py
@ -50,13 +50,6 @@ from app.settings.backend import router as settings_api
|
||||
from app.settings.backend import views as settings_views
|
||||
from app.backups.backend.router import router as backups_api
|
||||
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
|
||||
logging.basicConfig(
|
||||
@ -79,13 +72,9 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
init_db()
|
||||
|
||||
# Start unified scheduler (handles backups + email fetch)
|
||||
backup_scheduler.start()
|
||||
|
||||
logger.info("✅ System initialized successfully")
|
||||
yield
|
||||
# Shutdown
|
||||
backup_scheduler.stop()
|
||||
logger.info("👋 Shutting down...")
|
||||
|
||||
# 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(settings_api.router, prefix="/api/v1", tags=["Settings"])
|
||||
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
|
||||
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(emails_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)
|
||||
app.mount("/static", StaticFiles(directory="static", html=True), name="static")
|
||||
app.mount("/docs", StaticFiles(directory="docs"), name="docs")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
@ -171,11 +153,8 @@ if __name__ == "__main__":
|
||||
import uvicorn
|
||||
import os
|
||||
|
||||
# Only enable reload in local development (not in Docker) - check both variables
|
||||
enable_reload = (
|
||||
os.getenv("ENABLE_RELOAD", "false").lower() == "true" or
|
||||
os.getenv("API_RELOAD", "false").lower() == "true"
|
||||
)
|
||||
# Only enable reload in local development (not in Docker)
|
||||
enable_reload = os.getenv("ENABLE_RELOAD", "false").lower() == "true"
|
||||
|
||||
if enable_reload:
|
||||
uvicorn.run(
|
||||
|
||||
@ -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'));
|
||||
@ -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.';
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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'
|
||||
]));
|
||||
@ -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.';
|
||||
@ -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.
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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';
|
||||
@ -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)';
|
||||
@ -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 $$;
|
||||
@ -12,5 +12,4 @@ paramiko==3.4.1
|
||||
apscheduler==3.10.4
|
||||
pandas==2.2.3
|
||||
openpyxl==3.1.2
|
||||
extract-msg==0.55.0
|
||||
pdfplumber==0.11.4
|
||||
extract-msg==0.48.8
|
||||
|
||||
@ -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
|
||||
@ -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))
|
||||
@ -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()
|
||||
@ -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}"
|
||||
@ -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}"
|
||||
@ -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"
|
||||
@ -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())
|
||||
@ -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!")
|
||||
@ -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())
|
||||
Loading…
Reference in New Issue
Block a user