""" 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 from app.timetracking.backend.models import ( TModuleEconomicExportRequest, TModuleEconomicExportResult ) from app.timetracking.backend.audit import audit 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 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(order_query, (request.order_id,), fetchone=True) if not order: raise HTTPException(status_code=404, detail="Order not found") # 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 vTiger customer customer_number_query = """ SELECT economic_customer_number FROM tmodule_customers WHERE id = %s """ customer_data = execute_query(customer_number_query, (order['customer_id'],), fetchone=True) if not customer_data or not customer_data.get('economic_customer_number'): raise HTTPException( status_code=400, detail=f"Customer {order['customer_name']} has no e-conomic customer number" ) 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, "customer": { "customerNumber": customer_number }, "recipient": { "name": order['customer_name'], "vatZone": { "vatZoneNumber": 1 # Domestic Denmark } }, "paymentTerms": { "paymentTermsNumber": 1 # Default payment terms }, "layout": { "layoutNumber": 19 # Default layout }, "notes": { "heading": f"Tidsregistrering - {order['order_number']}" }, "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): economic_line = { "lineNumber": idx, "sortKey": idx, "description": line['description'], "quantity": float(line['quantity']), "unitNetPrice": float(line['unit_price']), "unit": { "unitNumber": 1 # Default unit (stk/pcs) } } # Add product if specified if line.get('product_number'): product_number = str(line['product_number'])[:25] # Max 25 chars economic_line['product'] = { "productNumber": product_number } # 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)}") economic_draft_id = result_data.get('draftOrderNumber') economic_order_number = result_data.get('orderNumber', str(economic_draft_id)) # Update order med e-conomic IDs execute_update( """UPDATE tmodule_orders SET economic_draft_id = %s, economic_order_number = %s, exported_at = CURRENT_TIMESTAMP, exported_by = %s, status = 'exported' WHERE id = %s""", (economic_draft_id, economic_order_number, user_id, request.order_id) ) # 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()