# 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
Hvis udfyldt oprettes der automatisk en lokal ordre
``` **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
Ordrer
``` **Opdatér tabel columns (omkring linje 100):** ```html Order # Type Kunde Beskrivelse Beløb Status Oprettet E-conomic # Handlinger ``` **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 = 'Klippekort'; } else if (order.order_type === 'timetracking') { typeBadge = 'Timetracking'; } else { typeBadge = 'Manuel'; } const row = ` ${order.id} ${typeBadge} ${order.customer_name} ${order.description || '-'} ${order.amount.toFixed(2)} DKK ${getStatusBadge(order.status)} ${formatDate(order.created_at)} ${order.economic_order_number || '-'} ${order.status === 'pending' ? ` ` : ''} `; 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 Ticket Kunde Agent Timer Timepris Beløb Faktureringsmetode E-conomic # Dato Status Handlinger ``` **Tilføj checkbox i hver row (i renderWorklogs()):** ```javascript function renderWorklogs(worklogs) { const tbody = document.getElementById('worklogTableBody'); tbody.innerHTML = ''; worklogs.forEach(wl => { const row = ` #${wl.ticket_id} ${wl.customer_name} ${wl.agent_name} ${wl.hours} ${wl.hourly_rate} DKK ${(wl.hours * wl.hourly_rate).toFixed(2)} DKK ${getBillingMethodBadge(wl.billing_method)} ${wl.economic_order_number ? `#${wl.economic_order_number}` : '-'} ${formatDate(wl.work_date)} ${getStatusBadge(wl.status)} `; tbody.innerHTML += row; }); } ``` **Tilføj export knap i toolbar:** ```html
Worklog Review
``` **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 += `
${customer.customer_name}
Timer: ${customer.total_hours}
Beløb: ${customer.total_amount.toFixed(2)} DKK
Antal worklogs: ${customer.worklog_count}
`; }); 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 += '
✅ Succesfulde eksporter
'; result.success.forEach(s => { html += `
${s.customer_name} - Ordre #${s.order_number} - ${s.total_hours} timer - ${s.total_amount.toFixed(2)} DKK
`; }); } // Failed cards (red) if (result.failed.length > 0) { html += '
❌ Fejlede eksporter
'; result.failed.forEach(f => { html += `
${f.customer_name}
${f.error}
`; }); } 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 ``` ## 🧪 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)