- 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.
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:
- Eksporterer ticket worklogs til e-conomic som draft orders (ikke invoices)
- Opretter lokale ordrer ved klippekort køb (manuelt eksporteres senere)
- Auto-grupperer worklogs per kunde ved batch export
- Viser multi-kunde preview som Bootstrap cards
- 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_numbergemmes 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_ordersautomatisk - 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
-
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"
- Naviger til
-
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
- Naviger til
-
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
- Naviger til
🔐 Safety Checklist
TICKET_ECONOMIC_READ_ONLY=Trueby defaultTICKET_ECONOMIC_DRY_RUN=Trueby 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
-
Database Migration
docker-compose exec db psql -U postgres -d bmc_hub -f /app/migrations/066_add_order_type_and_reference.sql -
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 -
Restart Services
docker-compose restart api -
Smoke Test
# Test health endpoint curl http://localhost:8001/health # Test prepaid card creation curl -X POST http://localhost:8001/api/v1/prepaid-cards ... -
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)
📚 Reference Links
- E-conomic REST API Docs
- E-conomic Orders Endpoint
- Timetracking Module Reference
- Economic Export Service Reference
- Economic Service Reference
Status: 📋 Klar til implementering
Estimeret tid: 6-8 timer (backend 3-4h, frontend 3-4h)
Prioritet: Høj (blokerer worklog fakturering)