bmc_hub/docs/ORDRE_SYSTEM_IMPLEMENTATION.md
Christian eacbd36e83 feat: Implement Transcription Service for audio files using Whisper API
- Added `transcription_service.py` to handle audio transcription via Whisper API.
- Integrated logging for transcription processes and error handling.
- Supported audio format checks based on configuration settings.

docs: Create Ordre System Implementation Plan

- Drafted comprehensive implementation plan for e-conomic order integration.
- Outlined business requirements, database changes, backend and frontend implementation details.
- Included testing plan and deployment steps for the new order system.

feat: Add AI prompts and regex action capabilities

- Created `ai_prompts` table for storing custom AI prompts.
- Added regex extraction and linking action to email workflow actions.

feat: Introduce conversations module for transcribed audio

- Created `conversations` table to store transcribed conversations with relevant metadata.
- Added indexing for customer, ticket, and user linkage.
- Implemented full-text search capabilities for Danish language.

fix: Add category column to conversations for classification

- Added `category` column to `conversations` table for better conversation classification.
2026-01-11 19:23:21 +01:00

35 KiB

# Ordre System Implementation Plan

Status: 📋 Planlagt (ikke implementeret endnu)
Dato: 10. januar 2026
Formål: Implementer e-conomic ordre integration for ticket worklogs og klippekort

📋 Oversigt

Dette dokument beskriver implementeringen af et komplet ordre system der:

  1. Eksporterer ticket worklogs til e-conomic som draft orders (ikke invoices)
  2. Opretter lokale ordrer ved klippekort køb (manuelt eksporteres senere)
  3. Auto-grupperer worklogs per kunde ved batch export
  4. Viser multi-kunde preview som Bootstrap cards
  5. Håndterer partial failures (markerer succesfulde, viser fejl separat)

🎯 Business Requirements

Ticket Worklog Export

  • Bruger vælger worklogs via checkboxes i worklog review siden
  • System grupperer automatisk per kunde (også hvis man vælger fra flere kunder)
  • Preview modal viser cards (én per kunde) med total timer + beløb
  • Execute opretter én ordre per kunde i e-conomic
  • economic_order_number gemmes i worklog tabellen og vises
  • Ved fejl på én kunde fortsættes med resten (partial success)

Klippekort Ordrer

  • Ved oprettelse af klippekort skal pris indtastes manuelt (optional felt)
  • System opretter lokal ordre i tmodule_orders automatisk
  • INGEN automatisk eksport til e-conomic (skal manuelt eksporteres fra orders siden)
  • Reason: Man skal kunne tilføje flere varelinjer før eksport
  • Description i e-conomic: "Klippekort - XXtimer"
  • Intern reference gemmes så ordre kan spores tilbage til klippekortet

Order Type Filtering

  • Alle ordrer (timetracking + klippekort) vises på samme side
  • Filter dropdown: "Alle ordrer", "Kun timetracking", "Kun klippekort"
  • Order type vises som badge i tabellen

🗄️ Database Ændringer

Migration 066: Tilføj order_type og reference til tmodule_orders

-- migrations/066_add_order_type_and_reference.sql

-- Add order_type column with default
ALTER TABLE tmodule_orders 
ADD COLUMN order_type VARCHAR(50) DEFAULT 'timetracking';

-- Add reference column for linking back to source (prepaid_card_id, manual reference, etc)
ALTER TABLE tmodule_orders 
ADD COLUMN reference VARCHAR(255);

-- Add index for filtering
CREATE INDEX idx_tmodule_orders_order_type ON tmodule_orders(order_type);
CREATE INDEX idx_tmodule_orders_reference ON tmodule_orders(reference);

-- Update existing rows to have explicit order_type
UPDATE tmodule_orders SET order_type = 'timetracking' WHERE order_type IS NULL;

COMMENT ON COLUMN tmodule_orders.order_type IS 'Type of order: timetracking, prepaid_card, manual';
COMMENT ON COLUMN tmodule_orders.reference IS 'Reference to source entity (prepaid_card_id, ticket_id, etc)';

Eksisterende Worklog Felt

tticket_worklog.economic_order_number findes allerede (eller skal oprettes hvis ikke):

-- Verificer felt eksisterer, ellers tilføj:
ALTER TABLE tticket_worklog 
ADD COLUMN IF NOT EXISTS economic_order_number INTEGER;

CREATE INDEX IF NOT EXISTS idx_tticket_worklog_economic_order 
ON tticket_worklog(economic_order_number);

🔧 Backend Implementation

1. E-conomic Service: Ordre Metoder

Fil: app/services/economic_service.py
Indsæt efter: Linje ~440 (mellem customer/supplier metoder og kassekladde sektion)

# ========== ORDERS ==========

async def create_draft_order(self, order_data: Dict) -> Dict:
    """
    Create draft order in e-conomic
    
    POST /orders/drafts
    
    Args:
        order_data: {
            'date': '2026-01-10',
            'currency': 'DKK',
            'customer': {'customerNumber': 123},
            'recipient': {
                'name': '...',
                'address': '...',
                'zip': '...',
                'city': '...',
                'vatZone': {'vatZoneNumber': 1}
            },
            'layout': {'layoutNumber': 19},
            'paymentTerms': {'paymentTermsNumber': 1},
            'lines': [
                {
                    'product': {'productNumber': '1000'},
                    'description': 'Support arbejde',
                    'quantity': 5.0,
                    'unitNetPrice': 850.00,
                    'unit': {'unitNumber': 1}
                }
            ]
        }
    
    Returns:
        {
            'orderNumber': 12345,
            'grossAmount': 5312.50,
            'netAmount': 4250.00,
            'vatAmount': 1062.50,
            ...
        }
    """
    # Safety check
    if not self._check_write_permission("Create draft order"):
        return {
            'orderNumber': -1,
            'blocked': True,
            'reason': 'READ_ONLY or DRY_RUN mode enabled',
            'mock': True
        }
    
    self._log_api_call("POST", "/orders/drafts", payload=order_data)
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{self.api_url}/orders/drafts",
                headers=self._get_headers(),
                json=order_data
            ) as response:
                response_text = await response.text()
                
                if response.status in (200, 201):
                    result = json.loads(response_text)
                    order_number = result.get('orderNumber')
                    gross_amount = result.get('grossAmount', 0)
                    
                    self._log_api_call(
                        "POST", "/orders/drafts",
                        payload=order_data,
                        response_data=result,
                        status_code=response.status
                    )
                    
                    logger.info(f"✅ Created draft order #{order_number} - Amount: {gross_amount} DKK")
                    return result
                else:
                    logger.error(f"❌ Failed to create draft order: {response.status} - {response_text}")
                    raise Exception(f"E-conomic API error: {response.status} - {response_text}")
                    
    except Exception as e:
        logger.error(f"❌ Error creating draft order: {e}")
        raise

async def book_draft_order(self, draft_order_number: int) -> Dict:
    """
    Book draft order → sent order
    
    POST /orders/sent
    
    Args:
        draft_order_number: Draft order number from e-conomic
    
    Returns:
        {
            'orderNumber': 54321,
            ...
        }
    """
    # Safety check
    if not self._check_write_permission(f"Book draft order {draft_order_number}"):
        return {
            'orderNumber': -1,
            'blocked': True,
            'reason': 'READ_ONLY or DRY_RUN mode enabled',
            'mock': True
        }
    
    payload = {
        'draftOrder': {
            'orderNumber': draft_order_number
        }
    }
    
    self._log_api_call("POST", "/orders/sent", payload=payload)
    
    try:
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"{self.api_url}/orders/sent",
                headers=self._get_headers(),
                json=payload
            ) as response:
                response_text = await response.text()
                
                if response.status in (200, 201):
                    result = json.loads(response_text)
                    order_number = result.get('orderNumber')
                    
                    self._log_api_call(
                        "POST", "/orders/sent",
                        payload=payload,
                        response_data=result,
                        status_code=response.status
                    )
                    
                    logger.info(f"✅ Booked order #{order_number} (was draft #{draft_order_number})")
                    return result
                else:
                    logger.error(f"❌ Failed to book order: {response.status} - {response_text}")
                    raise Exception(f"E-conomic API error: {response.status} - {response_text}")
                    
    except Exception as e:
        logger.error(f"❌ Error booking draft order: {e}")
        raise

async def get_order_pdf(self, order_number: int) -> bytes:
    """
    Download order PDF from e-conomic
    
    GET /orders/sent/{orderNumber}/pdf
    
    Args:
        order_number: Sent order number
    
    Returns:
        PDF bytes
    """
    try:
        async with aiohttp.ClientSession() as session:
            async with session.get(
                f"{self.api_url}/orders/sent/{order_number}/pdf",
                headers=self._get_headers()
            ) as response:
                if response.status == 200:
                    pdf_bytes = await response.read()
                    logger.info(f"✅ Downloaded PDF for order #{order_number} ({len(pdf_bytes)} bytes)")
                    return pdf_bytes
                else:
                    error_text = await response.text()
                    logger.error(f"❌ Failed to download PDF: {response.status} - {error_text}")
                    raise Exception(f"E-conomic API error: {response.status} - {error_text}")
                    
    except Exception as e:
        logger.error(f"❌ Error downloading order PDF: {e}")
        raise

2. Economic Export: Ret til Orders + Auto-Gruppering

Fil: app/ticket/backend/economic_export.py
Ændringer:

# Linje ~306: Ret metode kald
# FRA: result = await self.economic.create_draft_invoice(invoice_data)
# TIL:  result = await self.economic.create_draft_order(order_data)

# Linje ~233-274: Opdatér payload struktur
# FRA: "date": invoice_date, "customer": {...}, "recipient": {...}, "lines": [...]
# TIL:  "date": order_date, "customer": {...}, "recipient": {...}, "deliveryLocation": {...}, "lines": [...]

# Tilføj auto-gruppering i export_billable_worklog_batch():
async def export_billable_worklog_batch(self, worklog_ids: List[int]) -> Dict:
    """
    Export selected worklogs to e-conomic as draft orders
    Auto-groups by customer (one order per customer)
    
    Returns:
        {
            'success': [
                {
                    'customer_id': 123,
                    'customer_name': 'Kunde A',
                    'order_number': 12345,
                    'total_hours': 12.5,
                    'total_amount': 10625.00,
                    'worklog_count': 5
                }
            ],
            'failed': [
                {
                    'customer_id': 456,
                    'customer_name': 'Kunde B',
                    'error': 'Customer not found in e-conomic'
                }
            ]
        }
    """
    # 1. Fetch all worklogs
    worklogs = self._get_worklogs_by_ids(worklog_ids)
    
    # 2. Group by customer_id
    grouped = {}
    for wl in worklogs:
        customer_id = wl['customer_id']
        if customer_id not in grouped:
            grouped[customer_id] = []
        grouped[customer_id].append(wl)
    
    # 3. Process each customer separately
    success = []
    failed = []
    
    for customer_id, customer_worklogs in grouped.items():
        try:
            # Create order for this customer
            order_data = self._create_economic_order(customer_id, customer_worklogs)
            result = await self.economic.create_draft_order(order_data)
            
            if result.get('blocked') or result.get('mock'):
                # DRY_RUN mode - still mark as success but with note
                order_number = -1
            else:
                order_number = result.get('orderNumber')
            
            # Mark worklogs as billed
            self._mark_worklogs_as_billed(customer_worklogs, order_number)
            
            # Add to success list
            success.append({
                'customer_id': customer_id,
                'customer_name': customer_worklogs[0]['customer_name'],
                'order_number': order_number,
                'total_hours': sum(wl['hours'] for wl in customer_worklogs),
                'total_amount': sum(wl['hours'] * wl['hourly_rate'] for wl in customer_worklogs),
                'worklog_count': len(customer_worklogs)
            })
            
        except Exception as e:
            # Log error and continue with next customer
            logger.error(f"❌ Failed to export worklogs for customer {customer_id}: {e}")
            failed.append({
                'customer_id': customer_id,
                'customer_name': customer_worklogs[0]['customer_name'],
                'error': str(e)
            })
    
    return {
        'success': success,
        'failed': failed
    }

3. Klippekort: Automatisk Ordre Oprettelse

Fil: app/prepaid/backend/router.py
Ændring i create_prepaid_card() endpoint:

@router.post("/prepaid-cards", response_model=PrepaidCardFull)
async def create_prepaid_card(card: PrepaidCardCreate):
    """Create new prepaid card + automatic local order"""
    
    # 1. Create prepaid card (existing logic)
    query = """
        INSERT INTO tticket_prepaid_cards 
        (customer_id, purchased_hours, price, expiry_date, active)
        VALUES (%s, %s, %s, %s, %s)
        RETURNING *
    """
    result = execute_query(query, (
        card.customer_id,
        card.purchased_hours,
        card.price,
        card.expiry_date,
        True
    ))
    
    if not result:
        raise HTTPException(status_code=500, detail="Failed to create prepaid card")
    
    created_card = result[0]
    prepaid_card_id = created_card['id']
    
    # 2. Create local order automatically
    if card.price and card.price > 0:
        order_query = """
            INSERT INTO tmodule_orders 
            (customer_id, amount, order_type, status, reference, description, created_at)
            VALUES (%s, %s, %s, %s, %s, %s, NOW())
            RETURNING id
        """
        order_result = execute_query(order_query, (
            card.customer_id,
            card.price,
            'prepaid_card',
            'pending',
            str(prepaid_card_id),  # Reference to prepaid card
            f"Klippekort - {card.purchased_hours}timer"
        ))
        
        if order_result:
            logger.info(f"✅ Created local order for prepaid card #{prepaid_card_id}")
        else:
            logger.warning(f"⚠️ Failed to create local order for prepaid card #{prepaid_card_id}")
    
    return created_card

4. Config Flags

Fil: app/core/config.py
Tilføj i Settings klassen:

class Settings(BaseSettings):
    # ... existing fields ...
    
    # Ticket E-conomic Integration
    TICKET_ECONOMIC_READ_ONLY: bool = Field(default=True, description="Prevent actual writes to e-conomic (ticket module)")
    TICKET_ECONOMIC_DRY_RUN: bool = Field(default=True, description="Log API calls but don't execute (ticket module)")
    TICKET_ECONOMIC_AUTO_EXPORT: bool = Field(default=False, description="Auto-export worklogs when approved")
    TICKET_ECONOMIC_LAYOUT: int = Field(default=19, description="E-conomic layout number for ticket orders")
    TICKET_ECONOMIC_PRODUCT: str = Field(default="1000", description="E-conomic product number for ticket worklogs")

🎨 Frontend Implementation

1. Prepaid Cards: Price Input Field

Fil: app/prepaid/frontend/index.html
Create Modal - Tilføj efter purchased_hours input:

<!-- Existing: Timer købt input -->
<div class="mb-3">
    <label for="purchased_hours" class="form-label">Timer købt</label>
    <input type="number" class="form-control" id="purchased_hours" required min="0" step="0.5">
    <div class="d-flex gap-2 mt-2">
        <button type="button" class="btn btn-sm btn-outline-secondary" onclick="setPurchasedHours(10)">10t</button>
        <button type="button" class="btn btn-sm btn-outline-secondary" onclick="setPurchasedHours(25)">25t</button>
        <button type="button" class="btn btn-sm btn-outline-secondary" onclick="setPurchasedHours(50)">50t</button>
    </div>
</div>

<!-- NYT: Pris input -->
<div class="mb-3">
    <label for="price" class="form-label">Pris (DKK) <span class="text-muted">(optional)</span></label>
    <input type="number" class="form-control" id="price" min="0" step="0.01" placeholder="0.00">
    <div class="form-text">Hvis udfyldt oprettes der automatisk en lokal ordre</div>
</div>

JavaScript - Opdatér createCard() funktion:

async function createCard() {
    const customer_id = document.getElementById('customer_id').value;
    const purchased_hours = document.getElementById('purchased_hours').value;
    const price = document.getElementById('price').value;
    const expiry_date = document.getElementById('expiry_date').value;
    
    const response = await fetch('/api/v1/prepaid-cards', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({
            customer_id: parseInt(customer_id),
            purchased_hours: parseFloat(purchased_hours),
            price: price ? parseFloat(price) : null,  // Send null hvis tom
            expiry_date: expiry_date
        })
    });
    
    if (response.ok) {
        bootstrap.Modal.getInstance(document.getElementById('createModal')).hide();
        loadCards();
        showToast('Klippekort oprettet' + (price ? ' + ordre oprettet' : ''), 'success');
    }
}

2. Timetracking Orders: Order Type Filter

Fil: app/timetracking/frontend/orders.html
Tilføj filter i toolbar (omkring linje 50):

<div class="d-flex gap-3 align-items-center mb-3">
    <h5 class="mb-0">Ordrer</h5>
    
    <!-- NYT: Order Type Filter -->
    <select class="form-select form-select-sm" style="width: 200px;" id="orderTypeFilter" onchange="filterOrdersByType()">
        <option value="all">Alle ordrer</option>
        <option value="timetracking">Kun timetracking</option>
        <option value="prepaid_card">Kun klippekort</option>
    </select>
    
    <button class="btn btn-primary btn-sm ms-auto" onclick="showCreateModal()">
        <i class="bi bi-plus-circle"></i> Opret manuel ordre
    </button>
</div>

Opdatér tabel columns (omkring linje 100):

<thead>
    <tr>
        <th>Order #</th>
        <th>Type</th> <!-- NYT -->
        <th>Kunde</th>
        <th>Beskrivelse</th> <!-- NYT -->
        <th>Beløb</th>
        <th>Status</th>
        <th>Oprettet</th>
        <th>E-conomic #</th>
        <th>Handlinger</th>
    </tr>
</thead>

JavaScript:

function renderOrders(orders) {
    const tbody = document.getElementById('ordersTableBody');
    tbody.innerHTML = '';
    
    orders.forEach(order => {
        // Order type badge
        let typeBadge = '';
        if (order.order_type === 'prepaid_card') {
            typeBadge = '<span class="badge bg-info">Klippekort</span>';
        } else if (order.order_type === 'timetracking') {
            typeBadge = '<span class="badge bg-primary">Timetracking</span>';
        } else {
            typeBadge = '<span class="badge bg-secondary">Manuel</span>';
        }
        
        const row = `
            <tr>
                <td>${order.id}</td>
                <td>${typeBadge}</td>
                <td>${order.customer_name}</td>
                <td>${order.description || '-'}</td>
                <td>${order.amount.toFixed(2)} DKK</td>
                <td>${getStatusBadge(order.status)}</td>
                <td>${formatDate(order.created_at)}</td>
                <td>${order.economic_order_number || '-'}</td>
                <td>
                    ${order.status === 'pending' ? `
                        <button class="btn btn-sm btn-success" onclick="exportOrder(${order.id})">
                            Eksporter
                        </button>
                    ` : ''}
                </td>
            </tr>
        `;
        tbody.innerHTML += row;
    });
}

function filterOrdersByType() {
    const filter = document.getElementById('orderTypeFilter').value;
    const rows = document.querySelectorAll('#ordersTableBody tr');
    
    rows.forEach(row => {
        if (filter === 'all') {
            row.style.display = '';
        } else {
            const badge = row.querySelector('.badge');
            const type = badge.textContent.toLowerCase();
            
            if (filter === 'timetracking' && type === 'timetracking') {
                row.style.display = '';
            } else if (filter === 'prepaid_card' && type === 'klippekort') {
                row.style.display = '';
            } else {
                row.style.display = 'none';
            }
        }
    });
}

3. Worklog Review: Checkboxes + Export Button

Fil: app/ticket/frontend/worklog_review.html
Tilføj checkbox kolonne i tabel header:

<thead>
    <tr>
        <th style="width: 30px;">
            <input type="checkbox" id="selectAllCheckbox" onchange="toggleSelectAll()">
        </th>
        <th>Ticket</th>
        <th>Kunde</th>
        <th>Agent</th>
        <th>Timer</th>
        <th>Timepris</th>
        <th>Beløb</th>
        <th>Faktureringsmetode</th>
        <th>E-conomic #</th> <!-- NYT -->
        <th>Dato</th>
        <th>Status</th>
        <th>Handlinger</th>
    </tr>
</thead>

Tilføj checkbox i hver row (i renderWorklogs()):

function renderWorklogs(worklogs) {
    const tbody = document.getElementById('worklogTableBody');
    tbody.innerHTML = '';
    
    worklogs.forEach(wl => {
        const row = `
            <tr data-worklog-id="${wl.id}" data-customer-id="${wl.customer_id}">
                <td>
                    <input type="checkbox" class="worklog-checkbox" value="${wl.id}" 
                           onchange="updateExportButtonState()">
                </td>
                <td><a href="/tickets/${wl.ticket_id}">#${wl.ticket_id}</a></td>
                <td>${wl.customer_name}</td>
                <td>${wl.agent_name}</td>
                <td>${wl.hours}</td>
                <td>${wl.hourly_rate} DKK</td>
                <td>${(wl.hours * wl.hourly_rate).toFixed(2)} DKK</td>
                <td>${getBillingMethodBadge(wl.billing_method)}</td>
                <td>
                    ${wl.economic_order_number ? 
                        `<span class="badge bg-success">#${wl.economic_order_number}</span>` : 
                        '-'}
                </td>
                <td>${formatDate(wl.work_date)}</td>
                <td>${getStatusBadge(wl.status)}</td>
                <td>
                    <button class="btn btn-sm btn-success" onclick="approveWorklog(${wl.id})">Godkend</button>
                    <button class="btn btn-sm btn-primary" onclick="openEditModal(${wl.id})">Rediger</button>
                    <button class="btn btn-sm btn-danger" onclick="rejectWorklog(${wl.id})">Afvis</button>
                </td>
            </tr>
        `;
        tbody.innerHTML += row;
    });
}

Tilføj export knap i toolbar:

<div class="d-flex gap-3 mb-3">
    <h5>Worklog Review</h5>
    
    <!-- Existing: Billing Wizard button -->
    <button class="btn btn-primary btn-sm" onclick="openBillingWizard()">
        <i class="bi bi-calculator"></i> Åbn Billing Wizard
    </button>
    
    <!-- NYT: Export button -->
    <button class="btn btn-success btn-sm" id="exportSelectedBtn" 
            onclick="showExportPreview()" disabled>
        <i class="bi bi-cloud-upload"></i> Eksporter valgte til e-conomic
        <span class="badge bg-white text-success ms-2" id="selectedCount">0</span>
    </button>
</div>

JavaScript functions:

function toggleSelectAll() {
    const selectAll = document.getElementById('selectAllCheckbox').checked;
    document.querySelectorAll('.worklog-checkbox').forEach(cb => {
        cb.checked = selectAll;
    });
    updateExportButtonState();
}

function updateExportButtonState() {
    const checkboxes = document.querySelectorAll('.worklog-checkbox:checked');
    const exportBtn = document.getElementById('exportSelectedBtn');
    const countBadge = document.getElementById('selectedCount');
    
    exportBtn.disabled = checkboxes.length === 0;
    countBadge.textContent = checkboxes.length;
}

async function showExportPreview() {
    const selected = Array.from(document.querySelectorAll('.worklog-checkbox:checked'))
        .map(cb => parseInt(cb.value));
    
    if (selected.length === 0) {
        showToast('Vælg mindst ét worklog', 'warning');
        return;
    }
    
    // Call preview API
    const response = await fetch('/api/v1/tickets/worklog/export/preview', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({worklog_ids: selected})
    });
    
    if (response.ok) {
        const preview = await response.json();
        showPreviewModal(preview);
    } else {
        showToast('Fejl ved preview', 'danger');
    }
}

function showPreviewModal(preview) {
    // preview = [{customer_id, customer_name, total_hours, total_amount, worklog_count}, ...]
    
    let cardsHtml = '';
    preview.forEach(customer => {
        cardsHtml += `
            <div class="card mb-3">
                <div class="card-body">
                    <h6 class="card-title">${customer.customer_name}</h6>
                    <div class="row">
                        <div class="col-md-4">
                            <strong>Timer:</strong> ${customer.total_hours}
                        </div>
                        <div class="col-md-4">
                            <strong>Beløb:</strong> ${customer.total_amount.toFixed(2)} DKK
                        </div>
                        <div class="col-md-4">
                            <strong>Antal worklogs:</strong> ${customer.worklog_count}
                        </div>
                    </div>
                </div>
            </div>
        `;
    });
    
    document.getElementById('previewCardsContainer').innerHTML = cardsHtml;
    
    const modal = new bootstrap.Modal(document.getElementById('exportPreviewModal'));
    modal.show();
}

async function executeExport() {
    const selected = Array.from(document.querySelectorAll('.worklog-checkbox:checked'))
        .map(cb => parseInt(cb.value));
    
    const response = await fetch('/api/v1/tickets/worklog/export/execute', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({worklog_ids: selected})
    });
    
    if (response.ok) {
        const result = await response.json();
        showResultModal(result);
        
        // Reload worklogs to show economic_order_number
        loadWorklogs();
        
        // Clear selections
        document.querySelectorAll('.worklog-checkbox').forEach(cb => cb.checked = false);
        updateExportButtonState();
    } else {
        showToast('Fejl ved eksport', 'danger');
    }
}

function showResultModal(result) {
    // result = {success: [...], failed: [...]}
    
    let html = '';
    
    // Success cards (green)
    if (result.success.length > 0) {
        html += '<h6 class="text-success">✅ Succesfulde eksporter</h6>';
        result.success.forEach(s => {
            html += `
                <div class="card border-success mb-2">
                    <div class="card-body py-2">
                        <strong>${s.customer_name}</strong> - 
                        Ordre #${s.order_number} - 
                        ${s.total_hours} timer - 
                        ${s.total_amount.toFixed(2)} DKK
                    </div>
                </div>
            `;
        });
    }
    
    // Failed cards (red)
    if (result.failed.length > 0) {
        html += '<h6 class="text-danger mt-3">❌ Fejlede eksporter</h6>';
        result.failed.forEach(f => {
            html += `
                <div class="card border-danger mb-2">
                    <div class="card-body py-2">
                        <strong>${f.customer_name}</strong><br>
                        <small class="text-danger">${f.error}</small>
                    </div>
                </div>
            `;
        });
    }
    
    document.getElementById('resultCardsContainer').innerHTML = html;
    
    // Close preview modal, open result modal
    bootstrap.Modal.getInstance(document.getElementById('exportPreviewModal')).hide();
    const resultModal = new bootstrap.Modal(document.getElementById('exportResultModal'));
    resultModal.show();
}

Tilføj modals i HTML:

<!-- Export Preview Modal -->
<div class="modal fade" id="exportPreviewModal" tabindex="-1">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Eksport Preview</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <p class="text-muted">Følgende ordrer vil blive oprettet i e-conomic:</p>
                <div id="previewCardsContainer"></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-success" onclick="executeExport()">
                    <i class="bi bi-check-circle"></i> Bekræft eksport
                </button>
            </div>
        </div>
    </div>
</div>

<!-- Export Result Modal -->
<div class="modal fade" id="exportResultModal" tabindex="-1">
    <div class="modal-dialog modal-lg">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Eksport Resultat</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
            </div>
            <div class="modal-body">
                <div id="resultCardsContainer"></div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-primary" data-bs-dismiss="modal">Luk</button>
            </div>
        </div>
    </div>
</div>

🧪 Testing Plan

1. E-conomic Service Testing

# Test draft order creation (DRY_RUN mode)
curl -X POST http://localhost:8001/api/v1/test/economic/draft-order \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": 123,
    "lines": [
      {"product": "1000", "description": "Support", "quantity": 5, "unitNetPrice": 850}
    ]
  }'

# Expected: Log entry but no actual e-conomic call
# Response: {"orderNumber": -1, "mock": true, "blocked": true}

2. Klippekort Ordre Testing

# 1. Opret klippekort MED pris
curl -X POST http://localhost:8001/api/v1/prepaid-cards \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": 10,
    "purchased_hours": 25,
    "price": 15000,
    "expiry_date": "2026-12-31"
  }'

# 2. Verificer ordre oprettet
curl http://localhost:8001/api/v1/timetracking/orders?order_type=prepaid_card

# Expected: Ordre med order_type='prepaid_card', description='Klippekort - 25timer'

# 3. Opret klippekort UDEN pris
curl -X POST http://localhost:8001/api/v1/prepaid-cards \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": 10,
    "purchased_hours": 10,
    "expiry_date": "2026-06-30"
  }'

# Expected: Ordre IKKE oprettet (price=null)

3. Worklog Export Testing

# 1. Opret test worklogs
# - 3 worklogs for kunde A (total 12.5 timer)
# - 2 worklogs for kunde B (total 8 timer)

# 2. Preview export
curl -X POST http://localhost:8001/api/v1/tickets/worklog/export/preview \
  -H "Content-Type: application/json" \
  -d '{"worklog_ids": [1, 2, 3, 4, 5]}'

# Expected: 
# [
#   {customer_id: 10, customer_name: "Kunde A", total_hours: 12.5, total_amount: 10625, worklog_count: 3},
#   {customer_id: 11, customer_name: "Kunde B", total_hours: 8, total_amount: 6800, worklog_count: 2}
# ]

# 3. Execute export (DRY_RUN=true)
curl -X POST http://localhost:8001/api/v1/tickets/worklog/export/execute \
  -H "Content-Type: application/json" \
  -d '{"worklog_ids": [1, 2, 3, 4, 5]}'

# Expected:
# {
#   "success": [
#     {customer_id: 10, order_number: -1, ...},
#     {customer_id: 11, order_number: -1, ...}
#   ],
#   "failed": []
# }

# 4. Verificer economic_order_number gemt
curl http://localhost:8001/api/v1/tickets/worklogs?ids=1,2,3,4,5

# Expected: Alle worklogs har economic_order_number=-1 (DRY_RUN mode)

4. Frontend Testing

  1. Prepaid Cards

    • Naviger til /prepaid-cards
    • Klik "Opret nyt klippekort"
    • Udfyld: Kunde, 25 timer, 15000 DKK, udløbsdato
    • Submit → success toast "Klippekort oprettet + ordre oprettet"
    • Naviger til /timetracking/orders
    • Filtrer på "Kun klippekort"
    • Verificer ordre vises med description "Klippekort - 25timer"
  2. Worklog Export

    • Naviger til /tickets/worklogs/review
    • Vælg 3 worklogs via checkboxes (2 fra kunde A, 1 fra kunde B)
    • Klik "Eksporter valgte til e-conomic" (badge viser "3")
    • Preview modal viser 2 cards (Kunde A, Kunde B) med timer + beløb
    • Klik "Bekræft eksport"
    • Result modal viser 2 grønne success cards
    • Luk modal → tabel opdateret med economic_order_number i ny kolonne
  3. Order Type Filter

    • Naviger til /timetracking/orders
    • Dropdown viser: "Alle ordrer" (default)
    • Skift til "Kun klippekort" → kun prepaid_card ordrer vises
    • Skift til "Kun timetracking" → kun timetracking ordrer vises
    • Skift til "Alle ordrer" → alle vises igen

🔐 Safety Checklist

  • TICKET_ECONOMIC_READ_ONLY=True by default
  • TICKET_ECONOMIC_DRY_RUN=True by default
  • Alle API calls logger med _log_api_call() før execution
  • DRY_RUN mode returnerer mock data med order_number=-1
  • Partial failures markerer succesfulde kunder før error
  • Economic_order_number gemmes i DB efter success
  • Frontend viser tydelig feedback (success/failed cards)

📝 Deployment Steps

  1. Database Migration

    docker-compose exec db psql -U postgres -d bmc_hub -f /app/migrations/066_add_order_type_and_reference.sql
    
  2. Update Config

    # Add to .env
    TICKET_ECONOMIC_READ_ONLY=true
    TICKET_ECONOMIC_DRY_RUN=true
    TICKET_ECONOMIC_AUTO_EXPORT=false
    TICKET_ECONOMIC_LAYOUT=19
    TICKET_ECONOMIC_PRODUCT=1000
    
  3. Restart Services

    docker-compose restart api
    
  4. Smoke Test

    # Test health endpoint
    curl http://localhost:8001/health
    
    # Test prepaid card creation
    curl -X POST http://localhost:8001/api/v1/prepaid-cards ...
    
  5. Enable Write Mode (Production)

    # Efter testing i DRY_RUN mode:
    TICKET_ECONOMIC_READ_ONLY=false
    TICKET_ECONOMIC_DRY_RUN=false
    

🎯 Success Criteria

  • Klippekort oprettelse skaber lokal ordre automatisk
  • Worklog export grupperer automatisk per kunde
  • Preview viser multi-kunde cards korrekt
  • Execute håndterer partial failures (markerer succesfulde)
  • Economic_order_number vises i worklog review tabel
  • Order type filter fungerer på orders siden
  • DRY_RUN mode logger men sender ikke til e-conomic
  • Ingen data sendes til e-conomic med default config (safety first)

Status: 📋 Klar til implementering
Estimeret tid: 6-8 timer (backend 3-4h, frontend 3-4h)
Prioritet: Høj (blokerer worklog fakturering)