""" e-conomic Export Service for Time Tracking Module ================================================== 🚨 KRITISK: Denne service skal respektere safety flags. Eksporterer ordrer til e-conomic som draft orders. Safety Flags: - TIMETRACKING_ECONOMIC_READ_ONLY = True (default) - TIMETRACKING_ECONOMIC_DRY_RUN = True (default) """ import logging import json from typing import Optional, Dict, Any import aiohttp from fastapi import HTTPException from app.core.config import settings from app.core.database import execute_query, execute_update, execute_query_single from app.timetracking.backend.models import ( TModuleEconomicExportRequest, TModuleEconomicExportResult ) from app.timetracking.backend.audit import audit from app.timetracking.backend.vtiger_sync import vtiger_service logger = logging.getLogger(__name__) class EconomicExportService: """ e-conomic integration for Time Tracking Module. 🔒 SAFETY-FIRST service - all writes controlled by flags. """ def __init__(self): self.api_url = settings.ECONOMIC_API_URL self.app_secret_token = settings.ECONOMIC_APP_SECRET_TOKEN self.agreement_grant_token = settings.ECONOMIC_AGREEMENT_GRANT_TOKEN # Safety flags self.read_only = settings.TIMETRACKING_ECONOMIC_READ_ONLY self.dry_run = settings.TIMETRACKING_ECONOMIC_DRY_RUN self.export_type = settings.TIMETRACKING_EXPORT_TYPE # Log safety status if self.read_only: logger.warning("🔒 TIMETRACKING e-conomic READ-ONLY mode: Enabled") if self.dry_run: logger.warning("🏃 TIMETRACKING e-conomic DRY-RUN mode: Enabled") if not self.read_only: logger.error("⚠️ WARNING: TIMETRACKING e-conomic READ-ONLY disabled!") def _get_headers(self) -> Dict[str, str]: """Get e-conomic API headers""" return { 'X-AppSecretToken': self.app_secret_token, 'X-AgreementGrantToken': self.agreement_grant_token, 'Content-Type': 'application/json' } def _check_write_permission(self, operation: str) -> bool: """ Check om write operation er tilladt. Returns: True hvis operationen må udføres, False hvis blokeret """ if self.read_only: logger.error(f"🚫 BLOCKED: {operation} - READ_ONLY mode enabled") return False if self.dry_run: logger.warning(f"🏃 DRY-RUN: {operation} - Would execute but not sending") return False logger.warning(f"⚠️ EXECUTING WRITE: {operation}") return True async def test_connection(self) -> bool: """Test e-conomic connection""" try: logger.info("🔍 Testing e-conomic connection...") async with aiohttp.ClientSession() as session: async with session.get( f"{self.api_url}/self", headers=self._get_headers(), timeout=aiohttp.ClientTimeout(total=10) ) as response: if response.status == 200: logger.info("✅ e-conomic connection successful") return True else: logger.error(f"❌ e-conomic connection failed: {response.status}") return False except Exception as e: logger.error(f"❌ e-conomic connection error: {e}") return False async def check_draft_exists(self, draft_id: int) -> bool: """ Tjek om en draft order stadig eksisterer i e-conomic. Args: draft_id: e-conomic draft order nummer Returns: True hvis draft findes, False hvis slettet eller ikke fundet """ try: logger.info(f"🔍 Checking if draft {draft_id} exists in e-conomic...") async with aiohttp.ClientSession() as session: async with session.get( f"{self.api_url}/orders/drafts/{draft_id}", headers=self._get_headers(), timeout=aiohttp.ClientTimeout(total=10) ) as response: if response.status == 200: logger.info(f"✅ Draft {draft_id} exists in e-conomic") return True elif response.status == 404: logger.info(f"✅ Draft {draft_id} NOT found in e-conomic (deleted)") return False else: error_text = await response.text() logger.error(f"❌ e-conomic check failed ({response.status}): {error_text}") raise Exception(f"e-conomic API error: {response.status}") except aiohttp.ClientError as e: logger.error(f"❌ e-conomic connection error: {e}") raise Exception(f"Kunne ikke forbinde til e-conomic: {str(e)}") except Exception as e: logger.error(f"❌ Draft check error: {e}") raise async def export_order( self, request: TModuleEconomicExportRequest, user_id: Optional[int] = None ) -> TModuleEconomicExportResult: """ Eksporter ordre til e-conomic som draft order. Args: request: Export request med order_id user_id: ID på brugeren der eksporterer Returns: Export result med success status """ try: # Hent ordre med linjer order_query = """ SELECT o.*, c.name as customer_name, c.vtiger_id as customer_vtiger_id FROM tmodule_orders o JOIN tmodule_customers c ON o.customer_id = c.id WHERE o.id = %s """ order = execute_query_single(order_query, (request.order_id,)) if not order: raise HTTPException(status_code=404, detail="Order not found") # Check if order is posted (locked) if order['status'] == 'posted': raise HTTPException( status_code=403, detail=f"Ordre er bogført til e-conomic og kan ikke ændres. e-conomic ordre nr.: {order.get('economic_order_number')}" ) # Check if already exported if order['economic_draft_id'] and not request.force: raise HTTPException( status_code=400, detail=f"Order already exported (draft ID: {order['economic_draft_id']}). Use force=true to re-export." ) # Hent ordre-linjer lines_query = """ SELECT * FROM tmodule_order_lines WHERE order_id = %s ORDER BY line_number """ lines = execute_query(lines_query, (request.order_id,)) if not lines: raise HTTPException( status_code=400, detail="Order has no lines" ) # Log export start audit.log_export_started( order_id=request.order_id, user_id=user_id ) # Check safety flags operation = f"Export order {request.order_id} to e-conomic" if not self._check_write_permission(operation): # Dry-run or read-only mode result = TModuleEconomicExportResult( success=True, dry_run=True, order_id=request.order_id, economic_draft_id=None, economic_order_number=None, message=f"DRY-RUN: Would export order {order['order_number']} to e-conomic", details={ "order_number": order['order_number'], "customer_name": order['customer_name'], "total_amount": float(order['total_amount']), "line_count": len(lines), "read_only": self.read_only, "dry_run": self.dry_run } ) # Log dry-run completion audit.log_export_completed( order_id=request.order_id, economic_draft_id=None, economic_order_number=None, dry_run=True, user_id=user_id ) return result # REAL EXPORT (kun hvis safety flags er disabled) logger.warning(f"⚠️ REAL EXPORT STARTING for order {request.order_id}") # Hent e-conomic customer number fra Hub customers via hub_customer_id customer_number_query = """ SELECT c.economic_customer_number FROM tmodule_customers tc LEFT JOIN customers c ON tc.hub_customer_id = c.id WHERE tc.id = %s """ customer_data = execute_query_single(customer_number_query, (order['customer_id'],)) if not customer_data or not customer_data.get('economic_customer_number'): # Check if customer is linked at all check_link = execute_query_single( "SELECT hub_customer_id FROM tmodule_customers WHERE id = %s", (order['customer_id'],) ) if not check_link or not check_link.get('hub_customer_id'): raise HTTPException( status_code=400, detail=f"Customer '{order['customer_name']}' er ikke linket til en Hub kunde. Gå til vTiger kunder og link kunden først." ) else: raise HTTPException( status_code=400, detail=f"Customer '{order['customer_name']}' mangler e-conomic kundenummer. Opdater kunde i Customers modulet." ) customer_number = customer_data['economic_customer_number'] # Build e-conomic draft order payload economic_payload = { "date": order['order_date'].isoformat() if hasattr(order['order_date'], 'isoformat') else str(order['order_date']), "currency": "DKK", "exchangeRate": 100, "otherReference": order['order_number'], # Hub ordre nummer (TT-20251222-005) "customer": { "customerNumber": customer_number }, "recipient": { "name": order['customer_name'], "vatZone": { "vatZoneNumber": 1 # Domestic Denmark } }, "paymentTerms": { "paymentTermsNumber": 1 # Default payment terms }, "layout": { "layoutNumber": settings.TIMETRACKING_ECONOMIC_LAYOUT }, "notes": { "heading": "Tidsregistrering" }, "lines": [] } # Add notes if present if order.get('notes'): economic_payload['notes']['textLine1'] = order['notes'] # Build order lines for idx, line in enumerate(lines, start=1): # Format: "CC0042. 3 timer 1200,- 3600 / Fejlsøgning / 27.05.2025 - Kontaktnavn" # Extract case number and title from existing description desc_parts = line['description'].split(' - ', 1) case_number = desc_parts[0] if desc_parts else "" case_title = desc_parts[1] if len(desc_parts) > 1 else line['description'] # Build formatted description hours = float(line['quantity']) price = float(line['unit_price']) total = hours * price # Format date (Danish format DD.MM.YYYY) date_str = "" if line.get('time_date'): time_date = line['time_date'] if isinstance(time_date, str): from datetime import datetime time_date = datetime.fromisoformat(time_date).date() date_str = time_date.strftime("%d.%m.%Y") # Build description (case_number + task details) contact_part = f" - {line['case_contact']}" if line.get('case_contact') else "" travel_marker = " - (Udkørsel)" if line.get('is_travel') else "" formatted_desc = f"{case_number} / {case_title} / {date_str}{contact_part}{travel_marker}" economic_line = { "lineNumber": idx, "sortKey": idx, "description": formatted_desc, "quantity": hours, "unitNetPrice": price, "product": { "productNumber": line.get('product_number') or settings.TIMETRACKING_ECONOMIC_PRODUCT }, "unit": { "unitNumber": 2 # timer (unit 2 in e-conomic) } } # Add discount if present if line.get('discount_percentage'): economic_line['discountPercentage'] = float(line['discount_percentage']) economic_payload['lines'].append(economic_line) logger.info(f"📤 Sending to e-conomic: {json.dumps(economic_payload, indent=2, default=str)}") # Call e-conomic API async with aiohttp.ClientSession() as session: async with session.post( f"{self.api_url}/orders/drafts", headers=self._get_headers(), json=economic_payload, timeout=aiohttp.ClientTimeout(total=30) ) as response: response_text = await response.text() if response.status not in [200, 201]: logger.error(f"❌ e-conomic export failed: {response.status}") logger.error(f"Response: {response_text}") logger.error(f"Payload: {json.dumps(economic_payload, indent=2, default=str)}") # Try to parse error message try: error_data = json.loads(response_text) error_msg = error_data.get('message', response_text) # Parse detailed validation errors if present if 'errors' in error_data: error_details = [] for entity, entity_errors in error_data['errors'].items(): if isinstance(entity_errors, dict) and 'errors' in entity_errors: for err in entity_errors['errors']: field = err.get('propertyName', entity) msg = err.get('errorMessage', err.get('message', 'Unknown')) error_details.append(f"{field}: {msg}") if error_details: error_msg = '; '.join(error_details) except: error_msg = response_text # Log failed export audit.log_export_failed( order_id=request.order_id, error=error_msg, user_id=user_id ) raise HTTPException( status_code=response.status, detail=f"e-conomic API error: {error_msg}" ) result_data = await response.json() logger.info(f"✅ e-conomic response: {json.dumps(result_data, indent=2, default=str)}") # e-conomic returnerer orderNumber direkte for draft orders order_number = result_data.get('orderNumber') or result_data.get('draftOrderNumber') economic_draft_id = int(order_number) if order_number else None economic_order_number = str(order_number) if order_number else None # Update order med e-conomic IDs og status = posted (bogført) execute_update( """UPDATE tmodule_orders SET economic_draft_id = %s, economic_order_number = %s, exported_at = CURRENT_TIMESTAMP, exported_by = %s, status = 'posted' WHERE id = %s""", (economic_draft_id, economic_order_number, user_id, request.order_id) ) # Marker time entries som billed execute_update( """UPDATE tmodule_times SET status = 'billed' WHERE id IN ( SELECT UNNEST(time_entry_ids) FROM tmodule_order_lines WHERE order_id = %s )""", (request.order_id,) ) # Hent vTiger IDs for tidsregistreringerne vtiger_ids_query = """ SELECT t.vtiger_id FROM tmodule_times t WHERE t.id IN ( SELECT UNNEST(time_entry_ids) FROM tmodule_order_lines WHERE order_id = %s ) """ vtiger_time_records = execute_query(vtiger_ids_query, (request.order_id,)) vtiger_ids = [r['vtiger_id'] for r in vtiger_time_records] # Opdater Timelog records i vTiger med Hub ordre ID if vtiger_ids: logger.info(f"📝 Updating {len(vtiger_ids)} timelogs in vTiger with Hub order {request.order_id}...") try: vtiger_update_result = await vtiger_service.update_timelog_billed( vtiger_ids=vtiger_ids, hub_order_id=request.order_id ) logger.info(f"✅ vTiger update: {vtiger_update_result}") except Exception as vtiger_error: # Don't fail export if vTiger update fails - just log it logger.error(f"⚠️ Could not update vTiger timelogs: {vtiger_error}") # Log successful export audit.log_export_completed( order_id=request.order_id, economic_draft_id=economic_draft_id, economic_order_number=economic_order_number, dry_run=False, user_id=user_id ) logger.info(f"✅ Exported order {request.order_id} → e-conomic draft {economic_draft_id}") return TModuleEconomicExportResult( success=True, dry_run=False, order_id=request.order_id, economic_draft_id=economic_draft_id, economic_order_number=economic_order_number, message=f"Successfully exported to e-conomic draft {economic_draft_id}", details=result_data ) except HTTPException: raise except Exception as e: logger.error(f"❌ Error exporting order: {e}") # Log failed export audit.log_export_failed( order_id=request.order_id, error=str(e), user_id=user_id ) raise HTTPException(status_code=500, detail=str(e)) # Singleton instance economic_service = EconomicExportService()