- Added FastAPI router for time tracking views including dashboard, wizard, and orders. - Created HTML templates for the time tracking wizard with responsive design and Bootstrap integration. - Developed SQL migration script for the time tracking module, including tables for customers, cases, time entries, orders, and audit logs. - Introduced a script to list all registered routes, focusing on time tracking routes. - Added test script to verify route registration and specifically check for time tracking routes.
301 lines
11 KiB
Python
301 lines
11 KiB
Python
"""
|
|
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
|
|
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}")
|
|
|
|
# TODO: Implementer rigtig e-conomic API call her
|
|
# Denne kode vil kun køre hvis READ_ONLY og DRY_RUN begge er False
|
|
|
|
# Build e-conomic draft order payload
|
|
economic_payload = {
|
|
"date": order['order_date'].isoformat(),
|
|
"currency": "DKK",
|
|
"exchangeRate": 100,
|
|
"netAmount": float(order['subtotal']),
|
|
"grossAmount": float(order['total_amount']),
|
|
"vatAmount": float(order['vat_amount']),
|
|
"notes": {
|
|
"heading": f"Tidsregistrering - {order['order_number']}",
|
|
"textLine1": order.get('notes', '')
|
|
},
|
|
"lines": [
|
|
{
|
|
"lineNumber": line['line_number'],
|
|
"description": line['description'],
|
|
"quantity": float(line['quantity']),
|
|
"unitNetPrice": float(line['unit_price']),
|
|
"totalNetAmount": float(line['line_total'])
|
|
}
|
|
for line in lines
|
|
]
|
|
}
|
|
|
|
# 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:
|
|
if response.status not in [200, 201]:
|
|
error_text = await response.text()
|
|
logger.error(f"❌ e-conomic export failed: {response.status} - {error_text}")
|
|
|
|
# Log failed export
|
|
audit.log_export_failed(
|
|
order_id=request.order_id,
|
|
error=error_text,
|
|
user_id=user_id
|
|
)
|
|
|
|
raise HTTPException(
|
|
status_code=response.status,
|
|
detail=f"e-conomic API error: {error_text}"
|
|
)
|
|
|
|
result_data = await response.json()
|
|
|
|
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()
|