- 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.
1053 lines
35 KiB
Markdown
1053 lines
35 KiB
Markdown
# 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
|
|
|
|
```sql
|
|
-- 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):
|
|
|
|
```sql
|
|
-- 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)
|
|
|
|
```python
|
|
# ========== 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:**
|
|
|
|
```python
|
|
# 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:**
|
|
|
|
```python
|
|
@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:**
|
|
|
|
```python
|
|
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:**
|
|
|
|
```html
|
|
<!-- 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:**
|
|
|
|
```javascript
|
|
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):**
|
|
|
|
```html
|
|
<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):**
|
|
|
|
```html
|
|
<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:**
|
|
|
|
```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:**
|
|
|
|
```html
|
|
<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()):**
|
|
|
|
```javascript
|
|
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:**
|
|
|
|
```html
|
|
<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:**
|
|
|
|
```javascript
|
|
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:**
|
|
|
|
```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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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
|
|
|
|
```bash
|
|
# 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**
|
|
```bash
|
|
docker-compose exec db psql -U postgres -d bmc_hub -f /app/migrations/066_add_order_type_and_reference.sql
|
|
```
|
|
|
|
2. **Update Config**
|
|
```bash
|
|
# 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**
|
|
```bash
|
|
docker-compose restart api
|
|
```
|
|
|
|
4. **Smoke Test**
|
|
```bash
|
|
# 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)**
|
|
```bash
|
|
# 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](https://restdocs.e-conomic.com/)
|
|
- [E-conomic Orders Endpoint](https://restdocs.e-conomic.com/#orders-drafts)
|
|
- [Timetracking Module Reference](app/timetracking/backend/router.py)
|
|
- [Economic Export Service Reference](app/ticket/backend/economic_export.py)
|
|
- [Economic Service Reference](app/services/economic_service.py)
|
|
|
|
---
|
|
|
|
**Status:** 📋 Klar til implementering
|
|
**Estimeret tid:** 6-8 timer (backend 3-4h, frontend 3-4h)
|
|
**Prioritet:** Høj (blokerer worklog fakturering)
|