bmc_hub/app/timetracking/backend/economic_export.py

453 lines
19 KiB
Python
Raw Normal View History

"""
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
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 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 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'):
raise HTTPException(
status_code=400,
detail=f"Customer {order['customer_name']} has no e-conomic customer number. Link customer to Hub customer first."
)
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": 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):
# 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,)
)
# 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()