From 34555d1e368d610c8cb6746bca08896a8729e8bb Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 9 Dec 2025 22:46:30 +0100 Subject: [PATCH] feat(timetracking): Implement time tracking module with frontend views, HTML templates, and database migrations - 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. --- app/core/config.py | 21 + app/timetracking/__init__.py | 24 + app/timetracking/backend/__init__.py | 5 + app/timetracking/backend/audit.py | 280 ++++++++ app/timetracking/backend/economic_export.py | 300 +++++++++ app/timetracking/backend/models.py | 410 ++++++++++++ app/timetracking/backend/order_service.py | 403 ++++++++++++ app/timetracking/backend/router.py | 489 ++++++++++++++ app/timetracking/backend/vtiger_sync.py | 673 ++++++++++++++++++++ app/timetracking/backend/wizard.py | 330 ++++++++++ app/timetracking/frontend/__init__.py | 1 + app/timetracking/frontend/dashboard.html | 479 ++++++++++++++ app/timetracking/frontend/orders.html | 469 ++++++++++++++ app/timetracking/frontend/views.py | 62 ++ app/timetracking/frontend/wizard.html | 617 ++++++++++++++++++ list_routes.py | 16 + main.py | 4 + migrations/013_timetracking_module.sql | 416 ++++++++++++ test_routes.py | 17 + 19 files changed, 5016 insertions(+) create mode 100644 app/timetracking/__init__.py create mode 100644 app/timetracking/backend/__init__.py create mode 100644 app/timetracking/backend/audit.py create mode 100644 app/timetracking/backend/economic_export.py create mode 100644 app/timetracking/backend/models.py create mode 100644 app/timetracking/backend/order_service.py create mode 100644 app/timetracking/backend/router.py create mode 100644 app/timetracking/backend/vtiger_sync.py create mode 100644 app/timetracking/backend/wizard.py create mode 100644 app/timetracking/frontend/__init__.py create mode 100644 app/timetracking/frontend/dashboard.html create mode 100644 app/timetracking/frontend/orders.html create mode 100644 app/timetracking/frontend/views.py create mode 100644 app/timetracking/frontend/wizard.html create mode 100644 list_routes.py create mode 100644 migrations/013_timetracking_module.sql create mode 100644 test_routes.py diff --git a/app/core/config.py b/app/core/config.py index 074e65f..17a59b7 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,6 +33,27 @@ class Settings(BaseSettings): ECONOMIC_READ_ONLY: bool = True ECONOMIC_DRY_RUN: bool = True + # vTiger CRM Integration + VTIGER_URL: str = "" + VTIGER_USERNAME: str = "" + VTIGER_API_KEY: str = "" + VTIGER_PASSWORD: str = "" # Fallback hvis API key ikke virker + + # Time Tracking Module - vTiger Integration (Isoleret) + TIMETRACKING_VTIGER_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til vTiger + TIMETRACKING_VTIGER_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at synkronisere + + # Time Tracking Module - e-conomic Integration (Isoleret) + TIMETRACKING_ECONOMIC_READ_ONLY: bool = True # 🚨 SAFETY: Bloker ALLE skrivninger til e-conomic + TIMETRACKING_ECONOMIC_DRY_RUN: bool = True # 🚨 SAFETY: Log uden at eksportere + TIMETRACKING_EXPORT_TYPE: str = "draft" # draft|booked (draft er sikrest) + + # Time Tracking Module - Business Logic + TIMETRACKING_DEFAULT_HOURLY_RATE: float = 850.00 # DKK pr. time (fallback) + TIMETRACKING_AUTO_ROUND: bool = False # Auto-afrund til nærmeste 0.5 time + TIMETRACKING_ROUND_INCREMENT: float = 0.5 # Afrundingsinterval (0.25, 0.5, 1.0) + TIMETRACKING_REQUIRE_APPROVAL: bool = True # Kræv manuel godkendelse (ikke auto-approve) + # Ollama AI Integration OLLAMA_ENDPOINT: str = "http://ai_direct.cs.blaahund.dk" OLLAMA_MODEL: str = "qwen2.5-coder:7b" # qwen2.5-coder fungerer bedre til JSON udtrækning diff --git a/app/timetracking/__init__.py b/app/timetracking/__init__.py new file mode 100644 index 0000000..a690f58 --- /dev/null +++ b/app/timetracking/__init__.py @@ -0,0 +1,24 @@ +""" +Time Tracking & Billing Module (Isoleret) +========================================== + +Dette modul er 100% isoleret fra resten af BMC Hub. +- Alle data gemmes i tmodule_* tabeller +- Ingen ændringer sker på eksisterende Hub-data +- Modulet kan slettes fuldstændigt uden sideeffekter + +Formål: +- Importere tidsregistreringer fra vTiger (read-only) +- Manuel godkendelse via wizard +- Generere ordrer fra godkendte tider +- Eksportere til e-conomic som draft orders + +Safety Flags (altid aktiveret som standard): +- TIMETRACKING_VTIGER_READ_ONLY = True +- TIMETRACKING_VTIGER_DRY_RUN = True +- TIMETRACKING_ECONOMIC_READ_ONLY = True +- TIMETRACKING_ECONOMIC_DRY_RUN = True +""" + +__version__ = "1.0.0" +__author__ = "BMC Hub Development Team" diff --git a/app/timetracking/backend/__init__.py b/app/timetracking/backend/__init__.py new file mode 100644 index 0000000..2fb6f2b --- /dev/null +++ b/app/timetracking/backend/__init__.py @@ -0,0 +1,5 @@ +"""Time Tracking Module - Backend""" + +from .router import router + +__all__ = ["router"] diff --git a/app/timetracking/backend/audit.py b/app/timetracking/backend/audit.py new file mode 100644 index 0000000..f5a7b42 --- /dev/null +++ b/app/timetracking/backend/audit.py @@ -0,0 +1,280 @@ +""" +Audit Logging Service for Time Tracking Module +=============================================== + +Fuld sporbarhed af alle handlinger i modulet. +Alle events logges til tmodule_sync_log for compliance. +""" + +import logging +import json +from datetime import datetime +from typing import Optional, Dict, Any +from app.core.database import execute_insert +from app.timetracking.backend.models import TModuleSyncLogCreate + +logger = logging.getLogger(__name__) + + +class AuditLogger: + """ + Service til at logge alle module events. + + Event Types: + - sync_started, sync_completed, sync_failed + - approval, rejection, bulk_approval + - order_created, order_updated, order_cancelled + - export_started, export_completed, export_failed + - module_installed, module_uninstalled + """ + + @staticmethod + def log_event( + event_type: str, + entity_type: Optional[str] = None, + entity_id: Optional[int] = None, + user_id: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None + ) -> int: + """ + Log en event til tmodule_sync_log. + + Args: + event_type: Type af event (sync_started, approval, etc.) + entity_type: Type af entitet (time_entry, order, customer, case) + entity_id: ID på entiteten + user_id: ID på brugeren der foretog handlingen + details: Ekstra detaljer som JSON + ip_address: IP-adresse + user_agent: User agent string + + Returns: + ID på den oprettede log-entry + """ + try: + # Konverter details til JSON-string hvis det er en dict + details_json = json.dumps(details) if details else None + + query = """ + INSERT INTO tmodule_sync_log + (event_type, entity_type, entity_id, user_id, details, ip_address, user_agent) + VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s) + RETURNING id + """ + + log_id = execute_insert( + query, + (event_type, entity_type, entity_id, user_id, details_json, ip_address, user_agent) + ) + + logger.debug(f"📝 Logged event: {event_type} (ID: {log_id})") + return log_id + + except Exception as e: + logger.error(f"❌ Failed to log event {event_type}: {e}") + # Don't raise - audit logging failure shouldn't break main flow + return 0 + + @staticmethod + def log_sync_started(user_id: Optional[int] = None) -> int: + """Log start af vTiger sync""" + return AuditLogger.log_event( + event_type="sync_started", + user_id=user_id, + details={"timestamp": datetime.now().isoformat()} + ) + + @staticmethod + def log_sync_completed( + stats: Dict[str, Any], + user_id: Optional[int] = None + ) -> int: + """Log succesfuld sync med statistik""" + return AuditLogger.log_event( + event_type="sync_completed", + user_id=user_id, + details=stats + ) + + @staticmethod + def log_sync_failed( + error: str, + user_id: Optional[int] = None + ) -> int: + """Log fejlet sync""" + return AuditLogger.log_event( + event_type="sync_failed", + user_id=user_id, + details={"error": error, "timestamp": datetime.now().isoformat()} + ) + + @staticmethod + def log_approval( + time_id: int, + original_hours: float, + approved_hours: float, + rounded_to: Optional[float], + note: Optional[str], + user_id: Optional[int] = None + ) -> int: + """Log godkendelse af tidsregistrering""" + return AuditLogger.log_event( + event_type="approval", + entity_type="time_entry", + entity_id=time_id, + user_id=user_id, + details={ + "original_hours": original_hours, + "approved_hours": approved_hours, + "rounded_to": rounded_to, + "note": note, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_rejection( + time_id: int, + reason: Optional[str], + user_id: Optional[int] = None + ) -> int: + """Log afvisning af tidsregistrering""" + return AuditLogger.log_event( + event_type="rejection", + entity_type="time_entry", + entity_id=time_id, + user_id=user_id, + details={ + "reason": reason, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_order_created( + order_id: int, + customer_id: int, + total_hours: float, + total_amount: float, + line_count: int, + user_id: Optional[int] = None + ) -> int: + """Log oprettelse af ordre""" + return AuditLogger.log_event( + event_type="order_created", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={ + "customer_id": customer_id, + "total_hours": total_hours, + "total_amount": total_amount, + "line_count": line_count, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_order_updated( + order_id: int, + changes: Dict[str, Any], + user_id: Optional[int] = None + ) -> int: + """Log opdatering af ordre""" + return AuditLogger.log_event( + event_type="order_updated", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={ + "changes": changes, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_order_cancelled( + order_id: int, + reason: Optional[str], + user_id: Optional[int] = None + ) -> int: + """Log annullering af ordre""" + return AuditLogger.log_event( + event_type="order_cancelled", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={ + "reason": reason, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_export_started( + order_id: int, + user_id: Optional[int] = None + ) -> int: + """Log start af e-conomic export""" + return AuditLogger.log_event( + event_type="export_started", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={"timestamp": datetime.now().isoformat()} + ) + + @staticmethod + def log_export_completed( + order_id: int, + economic_draft_id: Optional[int], + economic_order_number: Optional[str], + dry_run: bool, + user_id: Optional[int] = None + ) -> int: + """Log succesfuld export til e-conomic""" + return AuditLogger.log_event( + event_type="export_completed", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={ + "economic_draft_id": economic_draft_id, + "economic_order_number": economic_order_number, + "dry_run": dry_run, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_export_failed( + order_id: int, + error: str, + user_id: Optional[int] = None + ) -> int: + """Log fejlet export til e-conomic""" + return AuditLogger.log_event( + event_type="export_failed", + entity_type="order", + entity_id=order_id, + user_id=user_id, + details={ + "error": error, + "timestamp": datetime.now().isoformat() + } + ) + + @staticmethod + def log_module_uninstalled(user_id: Optional[int] = None) -> int: + """Log modul-uninstall""" + return AuditLogger.log_event( + event_type="module_uninstalled", + user_id=user_id, + details={"timestamp": datetime.now().isoformat()} + ) + + +# Singleton instance +audit = AuditLogger() diff --git a/app/timetracking/backend/economic_export.py b/app/timetracking/backend/economic_export.py new file mode 100644 index 0000000..1289f35 --- /dev/null +++ b/app/timetracking/backend/economic_export.py @@ -0,0 +1,300 @@ +""" +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() diff --git a/app/timetracking/backend/models.py b/app/timetracking/backend/models.py new file mode 100644 index 0000000..07dbb83 --- /dev/null +++ b/app/timetracking/backend/models.py @@ -0,0 +1,410 @@ +""" +Pydantic Models for Time Tracking Module +========================================= + +Alle models repræsenterer data fra tmodule_* tabeller. +Ingen afhængigheder til eksisterende Hub-models. +""" + +from datetime import date, datetime +from decimal import Decimal +from typing import List, Optional +from pydantic import BaseModel, Field, field_validator + + +# ============================================================================ +# KUNDE MODELS +# ============================================================================ + +class TModuleCustomerBase(BaseModel): + """Base model for customer""" + vtiger_id: str = Field(..., description="vTiger Account ID") + name: str = Field(..., min_length=1, max_length=255) + email: Optional[str] = Field(None, max_length=255) + hourly_rate: Optional[Decimal] = Field(None, ge=0, description="DKK pr. time") + hub_customer_id: Optional[int] = Field(None, description="Reference til customers.id (read-only)") + + +class TModuleCustomerCreate(TModuleCustomerBase): + """Model for creating a customer""" + vtiger_data: Optional[dict] = Field(default_factory=dict) + + +class TModuleCustomer(TModuleCustomerBase): + """Full customer model with DB fields""" + id: int + sync_hash: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + last_synced_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================ +# CASE MODELS +# ============================================================================ + +class TModuleCaseBase(BaseModel): + """Base model for case/project""" + vtiger_id: str = Field(..., description="vTiger HelpDesk/ProjectTask ID") + customer_id: int = Field(..., gt=0) + title: str = Field(..., min_length=1, max_length=500) + description: Optional[str] = None + status: Optional[str] = Field(None, max_length=50) + priority: Optional[str] = Field(None, max_length=50) + module_type: Optional[str] = Field(None, max_length=50, description="HelpDesk, ProjectTask, etc.") + + +class TModuleCaseCreate(TModuleCaseBase): + """Model for creating a case""" + vtiger_data: Optional[dict] = Field(default_factory=dict) + + +class TModuleCase(TModuleCaseBase): + """Full case model with DB fields""" + id: int + sync_hash: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + last_synced_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================ +# TIDSREGISTRERING MODELS +# ============================================================================ + +class TModuleTimeBase(BaseModel): + """Base model for time entry""" + vtiger_id: str = Field(..., description="vTiger ModComments ID") + case_id: int = Field(..., gt=0) + customer_id: int = Field(..., gt=0) + description: Optional[str] = None + original_hours: Decimal = Field(..., gt=0, description="Original timer fra vTiger") + worked_date: Optional[date] = None + user_name: Optional[str] = Field(None, max_length=255, description="vTiger bruger") + + +class TModuleTimeCreate(TModuleTimeBase): + """Model for creating a time entry""" + vtiger_data: Optional[dict] = Field(default_factory=dict) + + +class TModuleTimeUpdate(BaseModel): + """Model for updating time entry (godkendelse)""" + approved_hours: Optional[Decimal] = Field(None, gt=0) + rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval") + approval_note: Optional[str] = None + billable: Optional[bool] = None + status: Optional[str] = Field(None, pattern="^(pending|approved|rejected|billed)$") + + +class TModuleTimeApproval(BaseModel): + """Model for wizard approval action""" + time_id: int = Field(..., gt=0) + approved_hours: Decimal = Field(..., gt=0, description="Timer efter godkendelse") + rounded_to: Optional[Decimal] = Field(None, ge=0.25, description="Afrundingsinterval brugt") + approval_note: Optional[str] = Field(None, description="Brugerens note") + billable: bool = Field(True, description="Skal faktureres?") + + @field_validator('approved_hours') + @classmethod + def validate_approved_hours(cls, v: Decimal) -> Decimal: + if v <= 0: + raise ValueError("Approved hours must be positive") + # Max 24 timer pr. dag er rimeligt + if v > 24: + raise ValueError("Approved hours cannot exceed 24 per entry") + return v + + +class TModuleTime(TModuleTimeBase): + """Full time entry model with DB fields""" + id: int + status: str = Field("pending", pattern="^(pending|approved|rejected|billed)$") + approved_hours: Optional[Decimal] = None + rounded_to: Optional[Decimal] = None + approval_note: Optional[str] = None + billable: bool = True + approved_at: Optional[datetime] = None + approved_by: Optional[int] = None + sync_hash: Optional[str] = None + created_at: datetime + updated_at: Optional[datetime] = None + last_synced_at: datetime + + class Config: + from_attributes = True + + +class TModuleTimeWithContext(TModuleTime): + """Time entry with case and customer context (for wizard)""" + case_title: str + case_status: Optional[str] = None + customer_name: str + customer_rate: Optional[Decimal] = None + + +# ============================================================================ +# ORDRE MODELS +# ============================================================================ + +class TModuleOrderLineBase(BaseModel): + """Base model for order line""" + line_number: int = Field(..., gt=0) + description: str = Field(..., min_length=1) + quantity: Decimal = Field(..., gt=0, description="Timer") + unit_price: Decimal = Field(..., ge=0, description="DKK pr. time") + line_total: Decimal = Field(..., ge=0, description="Total for linje") + case_id: Optional[int] = Field(None, gt=0) + time_entry_ids: List[int] = Field(default_factory=list) + product_number: Optional[str] = Field(None, max_length=50) + account_number: Optional[str] = Field(None, max_length=50) + + +class TModuleOrderLineCreate(TModuleOrderLineBase): + """Model for creating order line""" + pass + + +class TModuleOrderLine(TModuleOrderLineBase): + """Full order line model""" + id: int + order_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class TModuleOrderBase(BaseModel): + """Base model for order""" + customer_id: int = Field(..., gt=0) + hub_customer_id: Optional[int] = Field(None, gt=0) + order_date: date = Field(default_factory=date.today) + total_hours: Decimal = Field(0, ge=0) + hourly_rate: Decimal = Field(..., gt=0, description="DKK pr. time") + subtotal: Decimal = Field(0, ge=0) + vat_rate: Decimal = Field(Decimal("25.00"), ge=0, le=100, description="Moms %") + vat_amount: Decimal = Field(0, ge=0) + total_amount: Decimal = Field(0, ge=0) + notes: Optional[str] = None + + +class TModuleOrderCreate(TModuleOrderBase): + """Model for creating order""" + lines: List[TModuleOrderLineCreate] = Field(default_factory=list) + + +class TModuleOrderUpdate(BaseModel): + """Model for updating order""" + status: Optional[str] = Field(None, pattern="^(draft|exported|sent|cancelled)$") + notes: Optional[str] = None + + +class TModuleOrder(TModuleOrderBase): + """Full order model with DB fields""" + id: int + order_number: Optional[str] = None + status: str = Field("draft", pattern="^(draft|exported|sent|cancelled)$") + economic_draft_id: Optional[int] = None + economic_order_number: Optional[str] = None + exported_at: Optional[datetime] = None + exported_by: Optional[int] = None + created_at: datetime + updated_at: Optional[datetime] = None + created_by: Optional[int] = None + + class Config: + from_attributes = True + + +class TModuleOrderWithLines(TModuleOrder): + """Order with lines included""" + lines: List[TModuleOrderLine] = Field(default_factory=list) + + +class TModuleOrderDetails(BaseModel): + """Order details from view (aggregated data)""" + order_id: int + order_number: Optional[str] = None + order_date: date + order_status: str + total_hours: Decimal + total_amount: Decimal + economic_draft_id: Optional[int] = None + customer_name: str + customer_vtiger_id: str + line_count: int + time_entry_count: int + + class Config: + from_attributes = True + + +# ============================================================================ +# STATISTIK & WIZARD MODELS +# ============================================================================ + +class TModuleApprovalStats(BaseModel): + """Approval statistics per customer (from view)""" + customer_id: int + customer_name: str + customer_vtiger_id: str + total_entries: int + pending_count: int + approved_count: int + rejected_count: int + billed_count: int + total_original_hours: Optional[Decimal] = None + total_approved_hours: Optional[Decimal] = None + latest_work_date: Optional[date] = None + last_sync: Optional[datetime] = None + + class Config: + from_attributes = True + + +class TModuleWizardProgress(BaseModel): + """Progress for wizard flow""" + customer_id: int + customer_name: str + total_entries: int + approved_entries: int + pending_entries: int + rejected_entries: int + current_case_id: Optional[int] = None + current_case_title: Optional[str] = None + progress_percent: float = Field(0, ge=0, le=100) + + @field_validator('progress_percent', mode='before') + @classmethod + def calculate_progress(cls, v, info): + """Auto-calculate progress if not provided""" + if v == 0 and 'total_entries' in info.data and info.data['total_entries'] > 0: + approved = info.data.get('approved_entries', 0) + rejected = info.data.get('rejected_entries', 0) + total = info.data['total_entries'] + return round(((approved + rejected) / total) * 100, 2) + return v + + +class TModuleWizardNextEntry(BaseModel): + """Next entry for wizard approval""" + has_next: bool + time_entry: Optional[TModuleTimeWithContext] = None + progress: Optional[TModuleWizardProgress] = None + + +# ============================================================================ +# SYNC & AUDIT MODELS +# ============================================================================ + +class TModuleSyncStats(BaseModel): + """Statistics from sync operation""" + customers_imported: int = 0 + customers_updated: int = 0 + customers_skipped: int = 0 + cases_imported: int = 0 + cases_updated: int = 0 + cases_skipped: int = 0 + times_imported: int = 0 + times_updated: int = 0 + times_skipped: int = 0 + errors: int = 0 + duration_seconds: float = 0.0 + started_at: datetime + completed_at: Optional[datetime] = None + + +class TModuleSyncLogCreate(BaseModel): + """Model for creating audit log entry""" + event_type: str = Field(..., max_length=50) + entity_type: Optional[str] = Field(None, max_length=50) + entity_id: Optional[int] = None + user_id: Optional[int] = None + details: Optional[dict] = Field(default_factory=dict) + ip_address: Optional[str] = None + user_agent: Optional[str] = None + + +class TModuleSyncLog(TModuleSyncLogCreate): + """Full audit log model""" + id: int + created_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================ +# e-conomic EXPORT MODELS +# ============================================================================ + +class TModuleEconomicExportRequest(BaseModel): + """Request for exporting order to e-conomic""" + order_id: int = Field(..., gt=0) + force: bool = Field(False, description="Force export even if already exported") + + +class TModuleEconomicExportResult(BaseModel): + """Result of e-conomic export""" + success: bool + dry_run: bool = False + order_id: int + economic_draft_id: Optional[int] = None + economic_order_number: Optional[str] = None + message: str + details: Optional[dict] = None + + +# ============================================================================ +# MODULE METADATA +# ============================================================================ + +class TModuleMetadata(BaseModel): + """Module metadata""" + id: int + module_version: str + installed_at: datetime + installed_by: Optional[int] = None + last_sync_at: Optional[datetime] = None + is_active: bool + settings: dict = Field(default_factory=dict) + + class Config: + from_attributes = True + + +class TModuleUninstallRequest(BaseModel): + """Request for module uninstall""" + confirm: bool = Field(..., description="Must be True to proceed") + delete_all_data: bool = Field(..., description="Confirm deletion of ALL module data") + + @field_validator('confirm') + @classmethod + def validate_confirm(cls, v: bool) -> bool: + if not v: + raise ValueError("You must confirm uninstall by setting confirm=True") + return v + + @field_validator('delete_all_data') + @classmethod + def validate_delete(cls, v: bool) -> bool: + if not v: + raise ValueError("You must confirm data deletion by setting delete_all_data=True") + return v + + +class TModuleUninstallResult(BaseModel): + """Result of module uninstall""" + success: bool + message: str + tables_dropped: List[str] = Field(default_factory=list) + views_dropped: List[str] = Field(default_factory=list) + functions_dropped: List[str] = Field(default_factory=list) + rows_deleted: int = 0 diff --git a/app/timetracking/backend/order_service.py b/app/timetracking/backend/order_service.py new file mode 100644 index 0000000..a5b67d1 --- /dev/null +++ b/app/timetracking/backend/order_service.py @@ -0,0 +1,403 @@ +""" +Order Generation Service for Time Tracking Module +================================================== + +Aggreger godkendte tidsregistreringer til customer orders. +Beregn totals, moms, og opret order lines. +""" + +import logging +from typing import List, Optional +from decimal import Decimal +from datetime import date + +from fastapi import HTTPException +from app.core.config import settings +from app.core.database import execute_query, execute_insert, execute_update +from app.timetracking.backend.models import ( + TModuleOrder, + TModuleOrderWithLines, + TModuleOrderLine, + TModuleOrderCreate, + TModuleOrderLineCreate +) +from app.timetracking.backend.audit import audit + +logger = logging.getLogger(__name__) + + +class OrderService: + """Service for generating orders from approved time entries""" + + @staticmethod + def _get_hourly_rate(customer_id: int, hub_customer_id: Optional[int]) -> Decimal: + """ + Hent timepris for kunde. + + Prioritet: + 1. customer_id (tmodule_customers.hourly_rate) + 2. hub_customer_id (customers.hourly_rate) + 3. Default fra settings + """ + try: + # Check module customer + query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s" + result = execute_query(query, (customer_id,), fetchone=True) + + if result and result.get('hourly_rate'): + rate = result['hourly_rate'] + logger.info(f"✅ Using tmodule customer rate: {rate} DKK") + return Decimal(str(rate)) + + # Check Hub customer if linked + if hub_customer_id: + query = "SELECT hourly_rate FROM customers WHERE id = %s" + result = execute_query(query, (hub_customer_id,), fetchone=True) + + if result and result.get('hourly_rate'): + rate = result['hourly_rate'] + logger.info(f"✅ Using Hub customer rate: {rate} DKK") + return Decimal(str(rate)) + + # Fallback to default + default_rate = Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE)) + logger.warning(f"⚠️ No customer rate found, using default: {default_rate} DKK") + return default_rate + + except Exception as e: + logger.error(f"❌ Error getting hourly rate: {e}") + # Safe fallback + return Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE)) + + @staticmethod + def generate_order_for_customer( + customer_id: int, + user_id: Optional[int] = None + ) -> TModuleOrderWithLines: + """ + Generer ordre for alle godkendte tider for en kunde. + + Args: + customer_id: ID fra tmodule_customers + user_id: ID på brugeren der opretter + + Returns: + Order med lines + """ + try: + # Hent customer info + customer = execute_query( + "SELECT * FROM tmodule_customers WHERE id = %s", + (customer_id,), + fetchone=True + ) + + if not customer: + raise HTTPException(status_code=404, detail="Customer not found") + + # Hent godkendte tider for kunden + query = """ + SELECT t.*, c.title as case_title + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + WHERE t.customer_id = %s + AND t.status = 'approved' + AND t.billable = true + ORDER BY c.id, t.worked_date + """ + approved_times = execute_query(query, (customer_id,)) + + if not approved_times: + raise HTTPException( + status_code=400, + detail="No approved billable time entries found for customer" + ) + + logger.info(f"📊 Found {len(approved_times)} approved time entries") + + # Get hourly rate + hourly_rate = OrderService._get_hourly_rate( + customer_id, + customer.get('hub_customer_id') + ) + + # Group by case + case_groups = {} + for time_entry in approved_times: + case_id = time_entry['case_id'] + if case_id not in case_groups: + case_groups[case_id] = { + 'case_title': time_entry['case_title'], + 'entries': [] + } + case_groups[case_id]['entries'].append(time_entry) + + # Build order lines + order_lines = [] + line_number = 1 + total_hours = Decimal('0') + + for case_id, group in case_groups.items(): + # Sum hours for this case + case_hours = sum( + Decimal(str(entry['approved_hours'])) + for entry in group['entries'] + ) + + # Build description + entry_count = len(group['entries']) + description = f"{group['case_title']} ({entry_count} tidsregistreringer)" + + # Calculate line total + line_total = case_hours * hourly_rate + + # Collect time entry IDs + time_entry_ids = [entry['id'] for entry in group['entries']] + + order_lines.append(TModuleOrderLineCreate( + line_number=line_number, + description=description, + quantity=case_hours, + unit_price=hourly_rate, + line_total=line_total, + case_id=case_id, + time_entry_ids=time_entry_ids + )) + + total_hours += case_hours + line_number += 1 + + # Calculate totals + subtotal = total_hours * hourly_rate + vat_rate = Decimal('25.00') # Danish VAT + vat_amount = (subtotal * vat_rate / Decimal('100')).quantize(Decimal('0.01')) + total_amount = subtotal + vat_amount + + logger.info(f"💰 Order totals: {total_hours}h × {hourly_rate} = {subtotal} + {vat_amount} moms = {total_amount} DKK") + + # Create order + order_id = execute_insert( + """INSERT INTO tmodule_orders + (customer_id, hub_customer_id, order_date, total_hours, hourly_rate, + subtotal, vat_rate, vat_amount, total_amount, status, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)""", + ( + customer_id, + customer.get('hub_customer_id'), + date.today(), + total_hours, + hourly_rate, + subtotal, + vat_rate, + vat_amount, + total_amount, + user_id + ) + ) + + logger.info(f"✅ Created order {order_id}") + + # Create order lines + created_lines = [] + for line in order_lines: + line_id = execute_insert( + """INSERT INTO tmodule_order_lines + (order_id, case_id, line_number, description, quantity, unit_price, + line_total, time_entry_ids) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""", + ( + order_id, + line.case_id, + line.line_number, + line.description, + line.quantity, + line.unit_price, + line.line_total, + line.time_entry_ids + ) + ) + created_lines.append(line_id) + + logger.info(f"✅ Created {len(created_lines)} order lines") + + # Update time entries to 'billed' status + time_entry_ids = [ + entry_id + for line in order_lines + for entry_id in line.time_entry_ids + ] + + if time_entry_ids: + placeholders = ','.join(['%s'] * len(time_entry_ids)) + execute_update( + f"""UPDATE tmodule_times + SET status = 'billed' + WHERE id IN ({placeholders})""", + time_entry_ids + ) + logger.info(f"✅ Marked {len(time_entry_ids)} time entries as billed") + + # Log order creation + audit.log_order_created( + order_id=order_id, + customer_id=customer_id, + total_hours=float(total_hours), + total_amount=float(total_amount), + line_count=len(order_lines), + user_id=user_id + ) + + # Return full order with lines + return OrderService.get_order_with_lines(order_id) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error generating order: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_order_with_lines(order_id: int) -> TModuleOrderWithLines: + """Hent ordre med linjer""" + try: + # Get order + order_query = "SELECT * FROM tmodule_orders WHERE id = %s" + order = execute_query(order_query, (order_id,), fetchone=True) + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + # Get lines + lines_query = """ + SELECT * FROM tmodule_order_lines + WHERE order_id = %s + ORDER BY line_number + """ + lines = execute_query(lines_query, (order_id,)) + + return TModuleOrderWithLines( + **order, + lines=[TModuleOrderLine(**line) for line in lines] + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error getting order: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def list_orders( + customer_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = 100 + ) -> List[TModuleOrder]: + """List orders med filtrering""" + try: + conditions = [] + params = [] + + if customer_id: + conditions.append("customer_id = %s") + params.append(customer_id) + + if status: + conditions.append("status = %s") + params.append(status) + + where_clause = " WHERE " + " AND ".join(conditions) if conditions else "" + + query = f""" + SELECT * FROM tmodule_orders + {where_clause} + ORDER BY order_date DESC, id DESC + LIMIT %s + """ + params.append(limit) + + orders = execute_query(query, params if params else None) + + return [TModuleOrder(**order) for order in orders] + + except Exception as e: + logger.error(f"❌ Error listing orders: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def cancel_order( + order_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None + ) -> TModuleOrder: + """Annuller en ordre""" + try: + # Check order exists and is not exported + order = execute_query( + "SELECT * FROM tmodule_orders WHERE id = %s", + (order_id,), + fetchone=True + ) + + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order['status'] == 'cancelled': + raise HTTPException(status_code=400, detail="Order already cancelled") + + if order['status'] == 'exported': + raise HTTPException( + status_code=400, + detail="Cannot cancel exported order" + ) + + # Update status + execute_update( + "UPDATE tmodule_orders SET status = 'cancelled', notes = %s WHERE id = %s", + (reason, order_id) + ) + + # Reset time entries back to approved + lines = execute_query( + "SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s", + (order_id,) + ) + + all_time_ids = [] + for line in lines: + if line.get('time_entry_ids'): + all_time_ids.extend(line['time_entry_ids']) + + if all_time_ids: + placeholders = ','.join(['%s'] * len(all_time_ids)) + execute_update( + f"UPDATE tmodule_times SET status = 'approved' WHERE id IN ({placeholders})", + all_time_ids + ) + + # Log cancellation + audit.log_order_cancelled( + order_id=order_id, + reason=reason, + user_id=user_id + ) + + logger.info(f"❌ Cancelled order {order_id}") + + # Return updated order + updated = execute_query( + "SELECT * FROM tmodule_orders WHERE id = %s", + (order_id,), + fetchone=True + ) + + return TModuleOrder(**updated) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error cancelling order: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Singleton instance +order_service = OrderService() diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py new file mode 100644 index 0000000..d717dc0 --- /dev/null +++ b/app/timetracking/backend/router.py @@ -0,0 +1,489 @@ +""" +Main API Router for Time Tracking Module +========================================= + +Samler alle endpoints for modulet. +Isoleret routing uden påvirkning af existing Hub endpoints. +""" + +import logging +from typing import Optional, List + +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import JSONResponse + +from app.core.database import execute_query, execute_update +from app.timetracking.backend.models import ( + TModuleSyncStats, + TModuleApprovalStats, + TModuleWizardNextEntry, + TModuleWizardProgress, + TModuleTimeApproval, + TModuleTimeWithContext, + TModuleOrder, + TModuleOrderWithLines, + TModuleEconomicExportRequest, + TModuleEconomicExportResult, + TModuleMetadata, + TModuleUninstallRequest, + TModuleUninstallResult +) +from app.timetracking.backend.vtiger_sync import vtiger_service +from app.timetracking.backend.wizard import wizard +from app.timetracking.backend.order_service import order_service +from app.timetracking.backend.economic_export import economic_service +from app.timetracking.backend.audit import audit + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# ============================================================================ +# SYNC ENDPOINTS +# ============================================================================ + +@router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) +async def sync_from_vtiger(user_id: Optional[int] = None): + """ + 🔍 Synkroniser data fra vTiger (READ-ONLY). + + Henter: + - Accounts (kunder) + - HelpDesk (cases) + - ModComments (tidsregistreringer) + + Gemmes i tmodule_* tabeller (isoleret). + """ + try: + logger.info("🚀 Starting vTiger sync...") + result = await vtiger_service.full_sync(user_id=user_id) + return result + except Exception as e: + logger.error(f"❌ Sync failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/sync/test-connection", tags=["Sync"]) +async def test_vtiger_connection(): + """Test forbindelse til vTiger""" + try: + is_connected = await vtiger_service.test_connection() + return { + "connected": is_connected, + "service": "vTiger CRM", + "read_only": vtiger_service.read_only, + "dry_run": vtiger_service.dry_run + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# WIZARD / APPROVAL ENDPOINTS +# ============================================================================ + +@router.get("/wizard/stats", response_model=List[TModuleApprovalStats], tags=["Wizard"]) +async def get_all_customer_stats(): + """Hent approval statistik for alle kunder""" + try: + return wizard.get_all_customers_stats() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/wizard/next", response_model=TModuleWizardNextEntry, tags=["Wizard"]) +async def get_next_pending_entry(customer_id: Optional[int] = None): + """ + Hent næste pending tidsregistrering til godkendelse. + + Query params: + - customer_id: Valgfri - filtrer til specifik kunde + """ + try: + return wizard.get_next_pending_entry(customer_id=customer_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/wizard/approve", response_model=TModuleTimeWithContext, tags=["Wizard"]) +async def approve_time_entry( + approval: TModuleTimeApproval, + user_id: Optional[int] = None +): + """ + Godkend en tidsregistrering. + + Body: + - time_id: ID på tidsregistreringen + - approved_hours: Timer efter godkendelse (kan være afrundet) + - rounded_to: Afrundingsinterval (0.5, 1.0, etc.) + - approval_note: Valgfri note + - billable: Skal faktureres? (default: true) + """ + try: + return wizard.approve_time_entry(approval, user_id=user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/wizard/reject/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) +async def reject_time_entry( + time_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None +): + """Afvis en tidsregistrering""" + try: + return wizard.reject_time_entry(time_id, reason=reason, user_id=user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/wizard/progress/{customer_id}", response_model=TModuleWizardProgress, tags=["Wizard"]) +async def get_customer_progress(customer_id: int): + """Hent wizard progress for en kunde""" + try: + return wizard.get_customer_progress(customer_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# ORDER ENDPOINTS +# ============================================================================ + +@router.post("/orders/generate/{customer_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) +async def generate_order(customer_id: int, user_id: Optional[int] = None): + """ + Generer ordre for alle godkendte tider for en kunde. + + Aggregerer: + - Alle godkendte tidsregistreringer + - Grupperet efter case + - Beregner totals med moms + + Markerer tidsregistreringer som 'billed'. + """ + try: + return order_service.generate_order_for_customer(customer_id, user_id=user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/orders", response_model=List[TModuleOrder], tags=["Orders"]) +async def list_orders( + customer_id: Optional[int] = None, + status: Optional[str] = None, + limit: int = 100 +): + """ + List ordrer med filtrering. + + Query params: + - customer_id: Filtrer til specifik kunde + - status: Filtrer på status (draft, exported, sent, cancelled) + - limit: Max antal resultater (default: 100) + """ + try: + return order_service.list_orders( + customer_id=customer_id, + status=status, + limit=limit + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/orders/{order_id}", response_model=TModuleOrderWithLines, tags=["Orders"]) +async def get_order(order_id: int): + """Hent ordre med linjer""" + try: + return order_service.get_order_with_lines(order_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/orders/{order_id}/cancel", response_model=TModuleOrder, tags=["Orders"]) +async def cancel_order( + order_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None +): + """ + Annuller en ordre. + + Kun muligt for draft orders (ikke exported). + Resetter tidsregistreringer tilbage til 'approved'. + """ + try: + return order_service.cancel_order(order_id, reason=reason, user_id=user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# e-conomic EXPORT ENDPOINTS +# ============================================================================ + +@router.post("/export", response_model=TModuleEconomicExportResult, tags=["Export"]) +async def export_to_economic( + request: TModuleEconomicExportRequest, + user_id: Optional[int] = None +): + """ + 🚨 Eksporter ordre til e-conomic som draft order. + + SAFETY FLAGS: + - TIMETRACKING_ECONOMIC_READ_ONLY (default: True) + - TIMETRACKING_ECONOMIC_DRY_RUN (default: True) + + Hvis begge er enabled, køres kun dry-run simulation. + + Body: + - order_id: ID på ordren + - force: Re-eksporter selvom allerede eksporteret (default: false) + """ + try: + return await economic_service.export_order(request, user_id=user_id) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/export/test-connection", tags=["Export"]) +async def test_economic_connection(): + """Test forbindelse til e-conomic""" + try: + is_connected = await economic_service.test_connection() + return { + "connected": is_connected, + "service": "e-conomic", + "read_only": economic_service.read_only, + "dry_run": economic_service.dry_run, + "export_type": economic_service.export_type + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================ +# MODULE METADATA & ADMIN ENDPOINTS +# ============================================================================ + +@router.get("/metadata", response_model=TModuleMetadata, tags=["Admin"]) +async def get_module_metadata(): + """Hent modul metadata""" + try: + result = execute_query( + "SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1", + fetchone=True + ) + + if not result: + raise HTTPException(status_code=404, detail="Module metadata not found") + + return TModuleMetadata(**result) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/health", tags=["Admin"]) +async def module_health(): + """Module health check""" + try: + # Check database tables exist + tables_query = """ + SELECT COUNT(*) as count FROM information_schema.tables + WHERE table_name LIKE 'tmodule_%' + """ + result = execute_query(tables_query, fetchone=True) + table_count = result['count'] if result else 0 + + # Get stats - count each table separately + try: + stats = { + "customers": 0, + "cases": 0, + "times": 0, + "orders": 0 + } + + for table_name in ["customers", "cases", "times", "orders"]: + count_result = execute_query( + f"SELECT COUNT(*) as count FROM tmodule_{table_name}", + fetchone=True + ) + stats[table_name] = count_result['count'] if count_result else 0 + + except Exception as e: + stats = {"error": str(e)} + + return { + "status": "healthy" if table_count >= 6 else "degraded", + "module": "Time Tracking & Billing", + "version": "1.0.0", + "tables": table_count, + "statistics": stats, + "safety": { + "vtiger_read_only": vtiger_service.read_only, + "vtiger_dry_run": vtiger_service.dry_run, + "economic_read_only": economic_service.read_only, + "economic_dry_run": economic_service.dry_run + } + } + except Exception as e: + logger.error(f"Health check error: {e}") + return JSONResponse( + status_code=503, + content={ + "status": "unhealthy", + "error": str(e) + } + ) + + +@router.delete("/admin/uninstall", response_model=TModuleUninstallResult, tags=["Admin"]) +async def uninstall_module( + request: TModuleUninstallRequest, + user_id: Optional[int] = None +): + """ + 🚨 SLET MODULET FULDSTÆNDIGT. + + ADVARSEL: Dette sletter ALLE data i modulet! + Kan ikke fortrydes. + + Body: + - confirm: SKAL være true + - delete_all_data: SKAL være true + """ + try: + # Validate request (Pydantic validators already check this) + if not request.confirm or not request.delete_all_data: + raise HTTPException( + status_code=400, + detail="You must confirm uninstall and data deletion" + ) + + logger.warning(f"⚠️ UNINSTALLING TIME TRACKING MODULE (user_id: {user_id})") + + # Log uninstall + audit.log_module_uninstalled(user_id=user_id) + + # Execute DROP script + uninstall_script = """ + DROP VIEW IF EXISTS tmodule_order_details CASCADE; + DROP VIEW IF EXISTS tmodule_next_pending CASCADE; + DROP VIEW IF EXISTS tmodule_approval_stats CASCADE; + + DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders; + DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders; + DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times; + DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases; + DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers; + + DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE; + DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE; + + DROP TABLE IF EXISTS tmodule_sync_log CASCADE; + DROP TABLE IF EXISTS tmodule_order_lines CASCADE; + DROP TABLE IF EXISTS tmodule_orders CASCADE; + DROP TABLE IF EXISTS tmodule_times CASCADE; + DROP TABLE IF EXISTS tmodule_cases CASCADE; + DROP TABLE IF EXISTS tmodule_customers CASCADE; + DROP TABLE IF EXISTS tmodule_metadata CASCADE; + """ + + # Count rows before deletion + try: + count_query = """ + SELECT + (SELECT COUNT(*) FROM tmodule_customers) + + (SELECT COUNT(*) FROM tmodule_cases) + + (SELECT COUNT(*) FROM tmodule_times) + + (SELECT COUNT(*) FROM tmodule_orders) + + (SELECT COUNT(*) FROM tmodule_order_lines) + + (SELECT COUNT(*) FROM tmodule_sync_log) as total + """ + count_result = execute_query(count_query, fetchone=True) + total_rows = count_result['total'] if count_result else 0 + except: + total_rows = 0 + + # Execute uninstall (split into separate statements) + from app.core.database import get_db_connection + import psycopg2 + + conn = get_db_connection() + cursor = conn.cursor() + + dropped_items = { + "views": [], + "triggers": [], + "functions": [], + "tables": [] + } + + try: + # Drop views + for view in ["tmodule_order_details", "tmodule_next_pending", "tmodule_approval_stats"]: + cursor.execute(f"DROP VIEW IF EXISTS {view} CASCADE") + dropped_items["views"].append(view) + + # Drop triggers + triggers = [ + ("tmodule_orders_generate_number", "tmodule_orders"), + ("tmodule_orders_update", "tmodule_orders"), + ("tmodule_times_update", "tmodule_times"), + ("tmodule_cases_update", "tmodule_cases"), + ("tmodule_customers_update", "tmodule_customers") + ] + for trigger_name, table_name in triggers: + cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name} ON {table_name}") + dropped_items["triggers"].append(trigger_name) + + # Drop functions + for func in ["tmodule_generate_order_number", "tmodule_update_timestamp"]: + cursor.execute(f"DROP FUNCTION IF EXISTS {func}() CASCADE") + dropped_items["functions"].append(func) + + # Drop tables + for table in [ + "tmodule_sync_log", + "tmodule_order_lines", + "tmodule_orders", + "tmodule_times", + "tmodule_cases", + "tmodule_customers", + "tmodule_metadata" + ]: + cursor.execute(f"DROP TABLE IF EXISTS {table} CASCADE") + dropped_items["tables"].append(table) + + conn.commit() + + logger.warning(f"✅ Module uninstalled - deleted {total_rows} rows") + + return TModuleUninstallResult( + success=True, + message=f"Time Tracking Module successfully uninstalled. Deleted {total_rows} rows.", + tables_dropped=dropped_items["tables"], + views_dropped=dropped_items["views"], + functions_dropped=dropped_items["functions"], + rows_deleted=total_rows + ) + + except Exception as e: + conn.rollback() + raise e + finally: + cursor.close() + conn.close() + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Uninstall failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/app/timetracking/backend/vtiger_sync.py b/app/timetracking/backend/vtiger_sync.py new file mode 100644 index 0000000..65a7db0 --- /dev/null +++ b/app/timetracking/backend/vtiger_sync.py @@ -0,0 +1,673 @@ +""" +vTiger Sync Service for Time Tracking Module +============================================= + +🚨 KRITISK: Denne service må KUN læse data fra vTiger. +Ingen opdateringer, ingen statusændringer, ingen skrivninger. + +Formål: +- Hent ModComments (tidsregistreringer) fra vTiger +- Hent HelpDesk/ProjectTask (cases) fra vTiger +- Hent Accounts (kunder) fra vTiger +- Gem alt i tmodule_* tabeller (isoleret) + +Safety Flags: +- TIMETRACKING_VTIGER_READ_ONLY = True (default) +- TIMETRACKING_VTIGER_DRY_RUN = True (default) +""" + +import logging +import hashlib +import json +from datetime import datetime +from typing import List, Dict, Optional, Any +from decimal import Decimal + +import aiohttp +from fastapi import HTTPException + +from app.core.config import settings +from app.core.database import execute_query, execute_insert, execute_update +from app.timetracking.backend.models import TModuleSyncStats +from app.timetracking.backend.audit import audit + +logger = logging.getLogger(__name__) + + +class TimeTrackingVTigerService: + """ + vTiger integration for Time Tracking Module. + + 🔒 READ-ONLY service - ingen skrivninger til vTiger tilladt. + """ + + def __init__(self): + self.base_url = settings.VTIGER_URL + self.username = settings.VTIGER_USERNAME + self.api_key = settings.VTIGER_API_KEY + self.password = settings.VTIGER_PASSWORD + self.rest_endpoint = f"{self.base_url}/restapi/v1/vtiger/default" + + # Safety flags + self.read_only = settings.TIMETRACKING_VTIGER_READ_ONLY + self.dry_run = settings.TIMETRACKING_VTIGER_DRY_RUN + + # Log safety status + if self.read_only: + logger.warning("🔒 TIMETRACKING vTiger READ-ONLY mode: Enabled") + if self.dry_run: + logger.warning("🏃 TIMETRACKING vTiger DRY-RUN mode: Enabled") + + if not self.read_only: + logger.error("⚠️ WARNING: TIMETRACKING vTiger READ-ONLY disabled! This violates module isolation!") + + def _get_auth(self) -> aiohttp.BasicAuth: + """Get HTTP Basic Auth""" + # Prefer API key over password + auth_value = self.api_key if self.api_key else self.password + return aiohttp.BasicAuth(self.username, auth_value) + + def _calculate_hash(self, data: Dict[str, Any]) -> str: + """Calculate SHA256 hash of data for change detection""" + # Sort keys for consistent hashing + json_str = json.dumps(data, sort_keys=True) + return hashlib.sha256(json_str.encode()).hexdigest() + + async def _retrieve(self, record_id: str) -> Dict: + """ + Retrieve full record details via vTiger REST API. + This gets ALL fields including relationships that query doesn't return. + + 🔍 READ-ONLY operation + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.rest_endpoint}/retrieve", + params={"id": record_id}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=30) + ) as response: + text = await response.text() + + if response.status != 200: + logger.error(f"❌ vTiger retrieve failed: HTTP {response.status} for {record_id}") + return {} + + try: + data = json.loads(text) + except json.JSONDecodeError: + logger.error(f"❌ Invalid JSON in retrieve response for {record_id}") + return {} + + if not data.get('success'): + logger.error(f"❌ vTiger retrieve failed for {record_id}: {data.get('error', {})}") + return {} + + return data.get('result', {}) + + except Exception as e: + logger.error(f"❌ Error retrieving {record_id}: {e}") + return {} + + async def _query(self, query_string: str) -> List[Dict]: + """ + Execute SQL-like query against vTiger REST API. + + 🔍 READ-ONLY operation + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.rest_endpoint}/query", + params={"query": query_string}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=60) + ) as response: + text = await response.text() + + if response.status != 200: + logger.error(f"❌ vTiger query failed: HTTP {response.status}") + logger.error(f"Query: {query_string}") + logger.error(f"Response body: {text[:1000]}") + logger.error(f"Response headers: {dict(response.headers)}") + raise HTTPException( + status_code=response.status, + detail=f"vTiger API error: {text[:200]}" + ) + + # vTiger returns text/json, not application/json + try: + data = json.loads(text) + except json.JSONDecodeError as e: + logger.error(f"❌ Invalid JSON in query response: {text[:200]}") + raise HTTPException(status_code=500, detail="Invalid JSON from vTiger") + + # Check vTiger success flag + if not data.get('success'): + error_msg = data.get('error', {}).get('message', 'Unknown error') + logger.error(f"❌ vTiger query failed: {error_msg}") + logger.error(f"Query: {query_string}") + raise HTTPException(status_code=400, detail=f"vTiger error: {error_msg}") + + result = data.get('result', []) + logger.info(f"✅ Query returned {len(result)} records") + return result + + except aiohttp.ClientError as e: + logger.error(f"❌ vTiger connection error: {e}") + raise HTTPException(status_code=503, detail=f"Cannot connect to vTiger: {str(e)}") + + async def _retrieve(self, record_id: str) -> Dict: + """ + Retrieve single record by ID. + + 🔍 READ-ONLY operation + """ + try: + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.rest_endpoint}/retrieve", + params={"id": record_id}, + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=30) + ) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"❌ vTiger retrieve failed: {response.status} - {error_text}") + return {} + + data = json.loads(await response.text()) + + if not data.get('success', False): + return {} + + return data.get('result', {}) + + except aiohttp.ClientError as e: + logger.error(f"❌ vTiger retrieve error: {e}") + return {} + + async def test_connection(self) -> bool: + """Test vTiger connection""" + try: + logger.info("🔍 Testing vTiger connection...") + + async with aiohttp.ClientSession() as session: + async with session.get( + f"{self.rest_endpoint}/me", + auth=self._get_auth(), + timeout=aiohttp.ClientTimeout(total=10) + ) as response: + if response.status == 200: + logger.info("✅ vTiger connection successful") + return True + else: + logger.error(f"❌ vTiger connection failed: {response.status}") + return False + + except Exception as e: + logger.error(f"❌ vTiger connection error: {e}") + return False + + async def sync_customers(self, limit: int = 1000) -> Dict[str, int]: + """ + Sync Accounts (customers) from vTiger to tmodule_customers. + + Returns: {imported: X, updated: Y, skipped: Z} + """ + logger.info("🔍 Syncing customers from vTiger...") + + stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} + + try: + # Query vTiger for active accounts + # Start with simplest query to debug + query = "SELECT * FROM Accounts;" + accounts = await self._query(query) + + logger.info(f"📥 Fetched {len(accounts)} accounts from vTiger") + + for account in accounts: + try: + vtiger_id = account.get('id', '') + if not vtiger_id: + logger.warning("⚠️ Skipping account without ID") + stats["skipped"] += 1 + continue + + # Calculate hash for change detection + data_hash = self._calculate_hash(account) + + # Check if exists + existing = execute_query( + "SELECT id, sync_hash FROM tmodule_customers WHERE vtiger_id = %s", + (vtiger_id,), + fetchone=True + ) + + if existing: + # Check if data changed + if existing['sync_hash'] == data_hash: + logger.debug(f"⏭️ No changes for customer {vtiger_id}") + stats["skipped"] += 1 + continue + + # Update existing + execute_update( + """UPDATE tmodule_customers + SET name = %s, email = %s, vtiger_data = %s::jsonb, + sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s""", + ( + account.get('accountname', 'Unknown'), + account.get('email1', None), + json.dumps(account), + data_hash, + vtiger_id + ) + ) + logger.debug(f"✏️ Updated customer {vtiger_id}") + stats["updated"] += 1 + else: + # Insert new + execute_insert( + """INSERT INTO tmodule_customers + (vtiger_id, name, email, vtiger_data, sync_hash, last_synced_at) + VALUES (%s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", + ( + vtiger_id, + account.get('accountname', 'Unknown'), + account.get('email1', None), + json.dumps(account), + data_hash + ) + ) + logger.debug(f"➕ Imported customer {vtiger_id}") + stats["imported"] += 1 + + except Exception as e: + logger.error(f"❌ Error processing account {account.get('id', 'unknown')}: {e}") + stats["errors"] += 1 + + logger.info(f"✅ Customer sync complete: {stats}") + return stats + + except Exception as e: + logger.error(f"❌ Customer sync failed: {e}") + raise + + async def sync_cases(self, limit: int = 5000) -> Dict[str, int]: + """ + Sync HelpDesk tickets (cases) from vTiger to tmodule_cases. + + Returns: {imported: X, updated: Y, skipped: Z} + """ + logger.info(f"🔍 Syncing up to {limit} cases from vTiger...") + + stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} + + try: + # vTiger API doesn't support OFFSET - use id-based pagination instead + all_tickets = [] + last_id = "0x0" # Start from beginning + batch_size = 100 # Conservative batch size to avoid timeouts + max_batches = limit // batch_size + 1 + + for batch_num in range(max_batches): + # Use id > last_id for pagination (vTiger format: 39x1234) + query = f"SELECT * FROM Cases WHERE id > '{last_id}' ORDER BY id LIMIT {batch_size};" + batch = await self._query(query) + + if not batch: # No more records + break + + all_tickets.extend(batch) + last_id = batch[-1].get('id', last_id) # Get last ID for next iteration + + logger.info(f"📥 Fetched {len(batch)} cases (total: {len(all_tickets)}, last_id: {last_id})") + + if len(batch) < batch_size: # Last batch + break + + if len(all_tickets) >= limit: # Reached limit + break + + tickets = all_tickets[:limit] # Trim to requested limit + logger.info(f"✅ Total fetched: {len(tickets)} HelpDesk tickets from vTiger") + + for ticket in tickets: + try: + vtiger_id = ticket.get('id', '') + if not vtiger_id: + stats["skipped"] += 1 + continue + + # Get related account (customer) + account_id = ticket.get('parent_id', '') + if not account_id: + logger.warning(f"⚠️ HelpDesk {vtiger_id} has no parent account") + stats["skipped"] += 1 + continue + + # Find customer in our DB + customer = execute_query( + "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + (account_id,), + fetchone=True + ) + + if not customer: + logger.warning(f"⚠️ Customer {account_id} not found - sync customers first") + stats["skipped"] += 1 + continue + + customer_id = customer['id'] + data_hash = self._calculate_hash(ticket) + + # Check if exists + existing = execute_query( + "SELECT id, sync_hash FROM tmodule_cases WHERE vtiger_id = %s", + (vtiger_id,), + fetchone=True + ) + + if existing: + if existing['sync_hash'] == data_hash: + stats["skipped"] += 1 + continue + + # Update + execute_update( + """UPDATE tmodule_cases + SET customer_id = %s, title = %s, description = %s, + status = %s, priority = %s, module_type = %s, + vtiger_data = %s::jsonb, sync_hash = %s, + last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s""", + ( + customer_id, + ticket.get('ticket_title', 'No Title'), + ticket.get('description', None), + ticket.get('ticketstatus', None), + ticket.get('ticketpriorities', None), + 'HelpDesk', + json.dumps(ticket), + data_hash, + vtiger_id + ) + ) + stats["updated"] += 1 + else: + # Insert + case_id = execute_insert( + """INSERT INTO tmodule_cases + (vtiger_id, customer_id, title, description, status, + priority, module_type, vtiger_data, sync_hash, last_synced_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, CURRENT_TIMESTAMP)""", + ( + vtiger_id, + customer_id, + ticket.get('ticket_title', 'No Title'), + ticket.get('description', None), + ticket.get('ticketstatus', None), + ticket.get('ticketpriorities', None), + 'HelpDesk', + json.dumps(ticket), + data_hash + ) + ) + stats["imported"] += 1 + + except Exception as e: + logger.error(f"❌ Error processing case {ticket.get('id', 'unknown')}: {e}") + stats["errors"] += 1 + + logger.info(f"✅ Case sync complete: {stats}") + return stats + + except Exception as e: + logger.error(f"❌ Case sync failed: {e}") + raise + + async def sync_time_entries(self, limit: int = 3000) -> Dict[str, int]: + """ + Sync time entries from vTiger Timelog module to tmodule_times. + + vTiger's Timelog module contains detailed time entries with: + - timelognumber: Unique ID (TL1234) + - duration: Time in seconds + - relatedto: Reference to Case/Account + - isbillable: Billable flag + """ + logger.info(f"🔍 Syncing up to {limit} time entries from vTiger Timelog...") + + stats = {"imported": 0, "updated": 0, "skipped": 0, "errors": 0} + + try: + # vTiger API doesn't support OFFSET - use id-based pagination instead + all_timelogs = [] + last_id = "0x0" # Start from beginning + batch_size = 100 # Conservative batch size + max_batches = limit // batch_size + 1 + + for batch_num in range(max_batches): + # Use id > last_id for pagination (vTiger format: 43x1234) + query = f"SELECT * FROM Timelog WHERE timelog_status = 'Completed' AND id > '{last_id}' ORDER BY id LIMIT {batch_size};" + batch = await self._query(query) + + if not batch: # No more records + break + + all_timelogs.extend(batch) + last_id = batch[-1].get('id', last_id) # Get last ID for next iteration + + logger.info(f"📥 Fetched {len(batch)} timelogs (total: {len(all_timelogs)}, last_id: {last_id})") + + if len(batch) < batch_size: # Last batch + break + + if len(all_timelogs) >= limit: # Reached limit + break + + timelogs = all_timelogs[:limit] # Trim to requested limit + logger.info(f"✅ Total fetched: {len(timelogs)} Timelog entries from vTiger") + + # NOTE: retrieve API is too slow for batch operations (1500+ individual calls) + # We'll work with query data and accept that relatedto might be empty for some + + for timelog in timelogs: + try: + vtiger_id = timelog.get('id', '') + if not vtiger_id: + stats["skipped"] += 1 + continue + + # Get duration in hours (stored as seconds in vTiger) + duration_seconds = float(timelog.get('duration', 0) or 0) + if duration_seconds <= 0: + logger.debug(f"⏭️ Skipping timelog {vtiger_id} - no duration") + stats["skipped"] += 1 + continue + + hours = Decimal(str(duration_seconds / 3600.0)) # Convert seconds to hours + + # Get related entity (Case or Account) + related_to = timelog.get('relatedto', '') + if not related_to: + logger.warning(f"⚠️ Timelog {vtiger_id} has no relatedto - RAW DATA: {timelog}") + stats["skipped"] += 1 + continue + + # Try to find case first, then account + case = execute_query( + "SELECT id, customer_id FROM tmodule_cases WHERE vtiger_id = %s", + (related_to,), + fetchone=True + ) + + if case: + case_id = case['id'] + customer_id = case['customer_id'] + else: + # Try to find customer directly + customer = execute_query( + "SELECT id FROM tmodule_customers WHERE vtiger_id = %s", + (related_to,), + fetchone=True + ) + + if not customer: + logger.debug(f"⏭️ Related entity {related_to} not found") + stats["skipped"] += 1 + continue + + customer_id = customer['id'] + case_id = None # No specific case, just customer + + data_hash = self._calculate_hash(timelog) + + # Check if exists + existing = execute_query( + "SELECT id, sync_hash FROM tmodule_times WHERE vtiger_id = %s", + (vtiger_id,), + fetchone=True + ) + + if existing: + if existing['sync_hash'] == data_hash: + stats["skipped"] += 1 + continue + + # Update only if NOT yet approved + result = execute_update( + """UPDATE tmodule_times + SET description = %s, original_hours = %s, worked_date = %s, + user_name = %s, billable = %s, vtiger_data = %s::jsonb, + sync_hash = %s, last_synced_at = CURRENT_TIMESTAMP + WHERE vtiger_id = %s AND status = 'pending'""", + ( + timelog.get('name', ''), + hours, + timelog.get('startedon', None), + timelog.get('assigned_user_id', None), + timelog.get('isbillable', '0') == '1', + json.dumps(timelog), + data_hash, + vtiger_id + ) + ) + + if result > 0: + stats["updated"] += 1 + else: + logger.debug(f"⏭️ Time entry {vtiger_id} already approved") + stats["skipped"] += 1 + else: + # Insert new + execute_insert( + """INSERT INTO tmodule_times + (vtiger_id, case_id, customer_id, description, original_hours, + worked_date, user_name, billable, vtiger_data, sync_hash, + status, last_synced_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s::jsonb, %s, 'pending', CURRENT_TIMESTAMP)""", + ( + vtiger_id, + case_id, + customer_id, + timelog.get('name', ''), + hours, + timelog.get('startedon', None), + timelog.get('assigned_user_id', None), + timelog.get('isbillable', '0') == '1', + json.dumps(timelog), + data_hash + ) + ) + stats["imported"] += 1 + + except Exception as e: + logger.error(f"❌ Error processing timelog {timelog.get('id', 'unknown')}: {e}") + stats["errors"] += 1 + + logger.info(f"✅ Time entry sync complete: {stats}") + return stats + + except Exception as e: + logger.error(f"❌ Time entry sync failed: {e}") + raise + + async def full_sync( + self, + user_id: Optional[int] = None + ) -> TModuleSyncStats: + """ + Perform full sync of all data from vTiger. + + Order: Customers -> Cases -> Time Entries (dependencies) + """ + logger.info("🚀 Starting FULL vTiger sync...") + + start_time = datetime.now() + + # Log sync started + audit.log_sync_started(user_id=user_id) + + try: + # Test connection first + if not await self.test_connection(): + raise HTTPException( + status_code=503, + detail="Cannot connect to vTiger - check credentials" + ) + + # Sync in order of dependencies + customer_stats = await self.sync_customers() + case_stats = await self.sync_cases() + time_stats = await self.sync_time_entries() + + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + + # Build result + result = TModuleSyncStats( + customers_imported=customer_stats["imported"], + customers_updated=customer_stats["updated"], + customers_skipped=customer_stats["skipped"], + cases_imported=case_stats["imported"], + cases_updated=case_stats["updated"], + cases_skipped=case_stats["skipped"], + times_imported=time_stats["imported"], + times_updated=time_stats["updated"], + times_skipped=time_stats["skipped"], + errors=( + customer_stats["errors"] + + case_stats["errors"] + + time_stats["errors"] + ), + duration_seconds=duration, + started_at=start_time, + completed_at=end_time + ) + + # Log completion + audit.log_sync_completed( + stats=result.model_dump(), + user_id=user_id + ) + + logger.info(f"✅ Full sync completed in {duration:.2f}s") + logger.info(f"📊 Customers: {customer_stats['imported']} new, {customer_stats['updated']} updated") + logger.info(f"📊 Cases: {case_stats['imported']} new, {case_stats['updated']} updated") + logger.info(f"📊 Times: {time_stats['imported']} new, {time_stats['updated']} updated") + + return result + + except Exception as e: + logger.error(f"❌ Full sync failed: {e}") + audit.log_sync_failed(error=str(e), user_id=user_id) + raise + + +# Singleton instance +vtiger_service = TimeTrackingVTigerService() diff --git a/app/timetracking/backend/wizard.py b/app/timetracking/backend/wizard.py new file mode 100644 index 0000000..ab4d30c --- /dev/null +++ b/app/timetracking/backend/wizard.py @@ -0,0 +1,330 @@ +""" +Wizard Service for Time Tracking Module +======================================== + +Step-by-step approval flow for time entries. +Brugeren godkender én tidsregistrering ad gangen. +""" + +import logging +from typing import Optional +from decimal import Decimal +from datetime import datetime + +from fastapi import HTTPException +from app.core.database import execute_query, execute_update +from app.timetracking.backend.models import ( + TModuleTimeWithContext, + TModuleTimeApproval, + TModuleWizardProgress, + TModuleWizardNextEntry, + TModuleApprovalStats +) +from app.timetracking.backend.audit import audit + +logger = logging.getLogger(__name__) + + +class WizardService: + """Service for managing wizard-based approval flow""" + + @staticmethod + def get_customer_stats(customer_id: int) -> Optional[TModuleApprovalStats]: + """Hent approval statistics for en kunde""" + try: + query = """ + SELECT * FROM tmodule_approval_stats + WHERE customer_id = %s + """ + result = execute_query(query, (customer_id,), fetchone=True) + + if not result: + return None + + return TModuleApprovalStats(**result) + + except Exception as e: + logger.error(f"❌ Error getting customer stats: {e}") + return None + + @staticmethod + def get_all_customers_stats() -> list[TModuleApprovalStats]: + """Hent approval statistics for alle kunder""" + try: + query = "SELECT * FROM tmodule_approval_stats ORDER BY customer_name" + results = execute_query(query) + + return [TModuleApprovalStats(**row) for row in results] + + except Exception as e: + logger.error(f"❌ Error getting all customer stats: {e}") + return [] + + @staticmethod + def get_next_pending_entry( + customer_id: Optional[int] = None + ) -> TModuleWizardNextEntry: + """ + Hent næste pending tidsregistrering til godkendelse. + + Args: + customer_id: Valgfri - filtrer til specifik kunde + + Returns: + TModuleWizardNextEntry med has_next=True hvis der er flere + """ + try: + if customer_id: + # Hent næste for specifik kunde + query = """ + SELECT * FROM tmodule_next_pending + WHERE customer_id = %s + LIMIT 1 + """ + result = execute_query(query, (customer_id,), fetchone=True) + else: + # Hent næste generelt + query = "SELECT * FROM tmodule_next_pending LIMIT 1" + result = execute_query(query, fetchone=True) + + if not result: + # Ingen flere entries + return TModuleWizardNextEntry( + has_next=False, + time_entry=None, + progress=None + ) + + # Build entry with context + entry = TModuleTimeWithContext(**result) + + # Get progress if customer_id known + progress = None + cust_id = customer_id or entry.customer_id + if cust_id: + stats = WizardService.get_customer_stats(cust_id) + if stats: + progress = TModuleWizardProgress( + customer_id=stats.customer_id, + customer_name=stats.customer_name, + total_entries=stats.total_entries, + approved_entries=stats.approved_count, + pending_entries=stats.pending_count, + rejected_entries=stats.rejected_count, + current_case_id=entry.case_id, + current_case_title=entry.case_title + ) + + return TModuleWizardNextEntry( + has_next=True, + time_entry=entry, + progress=progress + ) + + except Exception as e: + logger.error(f"❌ Error getting next entry: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def approve_time_entry( + approval: TModuleTimeApproval, + user_id: Optional[int] = None + ) -> TModuleTimeWithContext: + """ + Godkend en tidsregistrering. + + Args: + approval: Approval data med time_id og approved_hours + user_id: ID på brugeren der godkender + + Returns: + Opdateret tidsregistrering + """ + try: + # Hent original entry + query = """ + SELECT t.*, c.title as case_title, c.status as case_status, + cust.name as customer_name, cust.hourly_rate as customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.id = %s + """ + entry = execute_query(query, (approval.time_id,), fetchone=True) + + if not entry: + raise HTTPException(status_code=404, detail="Time entry not found") + + if entry['status'] != 'pending': + raise HTTPException( + status_code=400, + detail=f"Time entry already {entry['status']}" + ) + + # Update entry + update_query = """ + UPDATE tmodule_times + SET status = 'approved', + approved_hours = %s, + rounded_to = %s, + approval_note = %s, + billable = %s, + approved_at = CURRENT_TIMESTAMP, + approved_by = %s + WHERE id = %s + """ + + execute_update( + update_query, + ( + approval.approved_hours, + approval.rounded_to, + approval.approval_note, + approval.billable, + user_id, + approval.time_id + ) + ) + + # Log approval + audit.log_approval( + time_id=approval.time_id, + original_hours=float(entry['original_hours']), + approved_hours=float(approval.approved_hours), + rounded_to=float(approval.rounded_to) if approval.rounded_to else None, + note=approval.approval_note, + user_id=user_id + ) + + logger.info( + f"✅ Approved time entry {approval.time_id}: " + f"{entry['original_hours']}h → {approval.approved_hours}h" + ) + + # Return updated entry + updated = execute_query(query, (approval.time_id,), fetchone=True) + return TModuleTimeWithContext(**updated) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error approving time entry: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def reject_time_entry( + time_id: int, + reason: Optional[str] = None, + user_id: Optional[int] = None + ) -> TModuleTimeWithContext: + """ + Afvis en tidsregistrering. + + Args: + time_id: ID på tidsregistreringen + reason: Årsag til afvisning + user_id: ID på brugeren der afviser + + Returns: + Opdateret tidsregistrering + """ + try: + # Check exists + query = """ + SELECT t.*, c.title as case_title, c.status as case_status, + cust.name as customer_name, cust.hourly_rate as customer_rate + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + JOIN tmodule_customers cust ON t.customer_id = cust.id + WHERE t.id = %s + """ + entry = execute_query(query, (time_id,), fetchone=True) + + if not entry: + raise HTTPException(status_code=404, detail="Time entry not found") + + if entry['status'] != 'pending': + raise HTTPException( + status_code=400, + detail=f"Time entry already {entry['status']}" + ) + + # Update to rejected + update_query = """ + UPDATE tmodule_times + SET status = 'rejected', + approval_note = %s, + billable = false, + approved_at = CURRENT_TIMESTAMP, + approved_by = %s + WHERE id = %s + """ + + execute_update(update_query, (reason, user_id, time_id)) + + # Log rejection + audit.log_rejection( + time_id=time_id, + reason=reason, + user_id=user_id + ) + + logger.info(f"❌ Rejected time entry {time_id}: {reason}") + + # Return updated + updated = execute_query(query, (time_id,), fetchone=True) + return TModuleTimeWithContext(**updated) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error rejecting time entry: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @staticmethod + def get_customer_progress(customer_id: int) -> TModuleWizardProgress: + """Hent wizard progress for en kunde""" + try: + stats = WizardService.get_customer_stats(customer_id) + + if not stats: + raise HTTPException(status_code=404, detail="Customer not found") + + # Get current case if any pending entries + current_case_id = None + current_case_title = None + + if stats.pending_count > 0: + query = """ + SELECT DISTINCT c.id, c.title + FROM tmodule_times t + JOIN tmodule_cases c ON t.case_id = c.id + WHERE t.customer_id = %s AND t.status = 'pending' + ORDER BY t.worked_date + LIMIT 1 + """ + case = execute_query(query, (customer_id,), fetchone=True) + if case: + current_case_id = case['id'] + current_case_title = case['title'] + + return TModuleWizardProgress( + customer_id=stats.customer_id, + customer_name=stats.customer_name, + total_entries=stats.total_entries, + approved_entries=stats.approved_count, + pending_entries=stats.pending_count, + rejected_entries=stats.rejected_count, + current_case_id=current_case_id, + current_case_title=current_case_title + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"❌ Error getting customer progress: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# Singleton instance +wizard = WizardService() diff --git a/app/timetracking/frontend/__init__.py b/app/timetracking/frontend/__init__.py new file mode 100644 index 0000000..ce1e408 --- /dev/null +++ b/app/timetracking/frontend/__init__.py @@ -0,0 +1 @@ +"""Time Tracking Module - Frontend""" diff --git a/app/timetracking/frontend/dashboard.html b/app/timetracking/frontend/dashboard.html new file mode 100644 index 0000000..98c9e0b --- /dev/null +++ b/app/timetracking/frontend/dashboard.html @@ -0,0 +1,479 @@ + + + + + + + + + + {{ page_title }} - BMC Hub + + + + + + + + + +
+ +
+
+

+ Tidsregistrering +

+

Synkroniser, godkend og fakturer tidsregistreringer fra vTiger

+
+
+ + +
+
+ +
+
+ + +
+
+
+

-

+

Kunder med tider

+
+
+
+
+

-

+

Afventer godkendelse

+
+
+
+
+

-

+

Godkendte

+
+
+
+
+

-

+

Timer godkendt

+
+
+
+ + +
+
+
+
+
+
+
Synkronisering
+

Hent nye tidsregistreringer fra vTiger

+
+
+ +
+
+
+
+
+
+
+ + +
+
+
+
+
Kunder med åbne tidsregistreringer
+
+
+
+
+ Indlæser... +
+
+
+ + + + + + + + + + + + + +
KundeTotal TimerAfventerGodkendtAfvistHandlinger
+
+
+ +

Ingen tidsregistreringer fundet

+ +
+
+
+
+
+
+ + + + + diff --git a/app/timetracking/frontend/orders.html b/app/timetracking/frontend/orders.html new file mode 100644 index 0000000..ad676f3 --- /dev/null +++ b/app/timetracking/frontend/orders.html @@ -0,0 +1,469 @@ + + + + + + Ordrer - BMC Hub + + + + + + + + + +
+ +
+
+
+
+

+ Ordrer +

+

Oversigt over genererede ordrer og eksport til e-conomic

+
+ + Tilbage + +
+
+
+ + +
+
+ +
+
+ + +
+
+
+
+
Alle Ordrer
+ +
+
+
+
+ Indlæser... +
+
+ +
+ + + + + + + + + + + + + + +
Ordrenr.KundeDatoLinjerTotalStatusHandlinger
+
+ +
+ +

Ingen ordrer endnu

+ + Godkend tider først + +
+
+
+
+
+
+ + + + + + + + diff --git a/app/timetracking/frontend/views.py b/app/timetracking/frontend/views.py new file mode 100644 index 0000000..60982f2 --- /dev/null +++ b/app/timetracking/frontend/views.py @@ -0,0 +1,62 @@ +""" +Frontend Views Router for Time Tracking Module +=============================================== + +HTML page handlers for time tracking UI. +""" + +import logging +import os +from pathlib import Path +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse, FileResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Path to templates - use absolute path from environment +BASE_DIR = Path(os.getenv("APP_ROOT", "/app")) +TEMPLATE_DIR = BASE_DIR / "app" / "timetracking" / "frontend" + + +@router.get("/timetracking", response_class=HTMLResponse, name="timetracking_dashboard") +async def timetracking_dashboard(request: Request): + """Time Tracking Dashboard - oversigt og sync""" + template_path = TEMPLATE_DIR / "dashboard.html" + logger.info(f"Serving dashboard from: {template_path}") + + # Force no-cache headers to prevent browser caching + response = FileResponse(template_path) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +@router.get("/timetracking/wizard", response_class=HTMLResponse, name="timetracking_wizard") +async def timetracking_wizard(request: Request): + """Time Tracking Wizard - step-by-step approval""" + template_path = TEMPLATE_DIR / "wizard.html" + logger.info(f"Serving wizard from: {template_path}") + + # Force no-cache headers + response = FileResponse(template_path) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response + + +@router.get("/timetracking/orders", response_class=HTMLResponse, name="timetracking_orders") +async def timetracking_orders(request: Request): + """Order oversigt""" + template_path = TEMPLATE_DIR / "orders.html" + logger.info(f"Serving orders from: {template_path}") + + # Force no-cache headers + response = FileResponse(template_path) + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + return response diff --git a/app/timetracking/frontend/wizard.html b/app/timetracking/frontend/wizard.html new file mode 100644 index 0000000..918d7ce --- /dev/null +++ b/app/timetracking/frontend/wizard.html @@ -0,0 +1,617 @@ + + + + + + Godkend Tider - BMC Hub + + + + + + + + + +
+ +
+
+
+
+

+ Godkend Tidsregistreringer +

+

Gennemgå og godkend tider én ad gangen

+
+ + Tilbage til oversigt + +
+
+
+ + +
+
+
+
+
+
Indlæser...
+ 0 / 0 +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ Indlæser... +
+

Henter næste tidsregistrering...

+
+
+
+
+ + +
+
+
+
+
+

Tidsregistrering

+ Afventer godkendelse +
+ +
+ + Kunde + + - +
+ +
+ + Case + + - +
+ +
+ + Dato + + - +
+ +
+ + Original Timer + + - +
+ +
+ + Udført af + + - +
+ +
+ +
-
+
+ + +
+
+ Afrunding +
+
+
+ + +
+
+ + +
+
+
+
+ Fakturerbare timer: + - +
+
+
+
+
+
+ + +
+
+
+
Handlinger
+ + + + + +
+ +
+

+ + Godkend for at inkludere i fakturering +

+

+ + Afvisning kan ikke fortrydes +

+
+
+
+ + +
+
+
+ Kunde Status +
+
+
+ Timepris: +
-
+
+
+ Afventer godkendelse: +
-
+
+
+ Godkendte timer: +
-
+
+
+
+
+
+
+ + +
+
+
+
+ +

Alle tider gennemgået!

+

+ Der er ingen flere tidsregistreringer der afventer godkendelse. +

+ +
+
+
+
+
+ + + + + diff --git a/list_routes.py b/list_routes.py new file mode 100644 index 0000000..127249b --- /dev/null +++ b/list_routes.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +import main + +print("=" * 80) +print("ALL REGISTERED ROUTES") +print("=" * 80) + +for i, route in enumerate(main.app.routes): + if hasattr(route, 'path'): + print(f"{i+1:3}. {route.path:60}") + if 'time' in route.path.lower(): + print(f" ^^^ TIMETRACKING ROUTE ^^^") + else: + print(f"{i+1:3}. {route}") + +print(f"\n Total routes: {len(main.app.routes)}") diff --git a/main.py b/main.py index 13f4de4..7f5a19b 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,8 @@ from app.dashboard.backend import views as dashboard_views from app.dashboard.backend import router as dashboard_api from app.devportal.backend import router as devportal_api from app.devportal.backend import views as devportal_views +from app.timetracking.backend import router as timetracking_api +from app.timetracking.frontend import views as timetracking_views # Configure logging logging.basicConfig( @@ -103,6 +105,7 @@ app.include_router(billing_api.router, prefix="/api/v1", tags=["Billing"]) app.include_router(system_api.router, prefix="/api/v1", tags=["System"]) app.include_router(dashboard_api.router, prefix="/api/v1/dashboard", tags=["Dashboard"]) app.include_router(devportal_api.router, prefix="/api/v1/devportal", tags=["DEV Portal"]) +app.include_router(timetracking_api, prefix="/api/v1/timetracking", tags=["Time Tracking"]) # Frontend Routers app.include_router(auth_views.router, tags=["Frontend"]) @@ -113,6 +116,7 @@ app.include_router(vendors_views.router, tags=["Frontend"]) app.include_router(billing_views.router, tags=["Frontend"]) app.include_router(settings_views.router, tags=["Frontend"]) app.include_router(devportal_views.router, tags=["Frontend"]) +app.include_router(timetracking_views.router, tags=["Frontend"]) # Serve static files (UI) app.mount("/static", StaticFiles(directory="static", html=True), name="static") diff --git a/migrations/013_timetracking_module.sql b/migrations/013_timetracking_module.sql new file mode 100644 index 0000000..688c500 --- /dev/null +++ b/migrations/013_timetracking_module.sql @@ -0,0 +1,416 @@ +-- ============================================================================ +-- Migration 013: Tidsregistrering & Faktureringsmodul (Isoleret) +-- ============================================================================ +-- Dette modul er 100% isoleret og kan slettes uden at påvirke eksisterende data. +-- Alle tabeller har prefix 'tmodule_' for at markere tilhørsforhold til modulet. +-- Ved uninstall køres DROP-scriptet i bunden af denne fil. +-- ============================================================================ + +-- Metadata tabel til at tracke modulets tilstand +CREATE TABLE IF NOT EXISTS tmodule_metadata ( + id SERIAL PRIMARY KEY, + module_version VARCHAR(20) NOT NULL DEFAULT '1.0.0', + installed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + installed_by INTEGER, -- Reference til users.id (read-only, ingen FK) + last_sync_at TIMESTAMP, + is_active BOOLEAN DEFAULT true, + settings JSONB DEFAULT '{}'::jsonb +); + +-- Indsæt initial metadata +INSERT INTO tmodule_metadata (module_version, is_active) +VALUES ('1.0.0', true) +ON CONFLICT DO NOTHING; + +-- ============================================================================ +-- KUNDE-CACHE (read-only kopi fra vTiger for isolation) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_customers ( + id SERIAL PRIMARY KEY, + vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger Account ID + name VARCHAR(255) NOT NULL, + email VARCHAR(255), + hub_customer_id INTEGER, -- Reference til customers.id (OPTIONAL, read-only) + hourly_rate DECIMAL(10,2), -- Kan override Hub-rate + vtiger_data JSONB, -- Original vTiger data for reference + sync_hash VARCHAR(64), -- SHA256 af data for change detection + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tmodule_customers_vtiger ON tmodule_customers(vtiger_id); +CREATE INDEX idx_tmodule_customers_hub ON tmodule_customers(hub_customer_id); +CREATE INDEX idx_tmodule_customers_synced ON tmodule_customers(last_synced_at); + +-- ============================================================================ +-- CASE-CACHE (read-only kopi fra vTiger HelpDesk/ProjectTask) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_cases ( + id SERIAL PRIMARY KEY, + vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger HelpDesk/ProjectTask ID + customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL, + description TEXT, + status VARCHAR(50), + priority VARCHAR(50), + module_type VARCHAR(50), -- HelpDesk, ProjectTask, etc. + vtiger_data JSONB, + sync_hash VARCHAR(64), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX idx_tmodule_cases_vtiger ON tmodule_cases(vtiger_id); +CREATE INDEX idx_tmodule_cases_customer ON tmodule_cases(customer_id); +CREATE INDEX idx_tmodule_cases_status ON tmodule_cases(status); +CREATE INDEX idx_tmodule_cases_synced ON tmodule_cases(last_synced_at); + +-- ============================================================================ +-- TIDSREGISTRERINGER (read-only kopi fra vTiger ModComments) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_times ( + id SERIAL PRIMARY KEY, + vtiger_id VARCHAR(50) UNIQUE NOT NULL, -- vTiger ModComments ID + case_id INTEGER NOT NULL REFERENCES tmodule_cases(id) ON DELETE CASCADE, + customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE, + + -- Original vTiger data + description TEXT, + original_hours DECIMAL(5,2) NOT NULL, + worked_date DATE, + user_name VARCHAR(255), -- vTiger user (read-only) + + -- Godkendelsesdata (ændres kun i modulet) + status VARCHAR(20) DEFAULT 'pending', -- pending|approved|rejected|billed + approved_hours DECIMAL(5,2), + rounded_to DECIMAL(3,1), -- 0.5, 1.0, etc. + approval_note TEXT, + billable BOOLEAN DEFAULT true, + approved_at TIMESTAMP, + approved_by INTEGER, -- Reference til users.id (read-only) + + -- Metadata + vtiger_data JSONB, + sync_hash VARCHAR(64), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + -- Constraints + CONSTRAINT check_hours_positive CHECK (original_hours > 0), + CONSTRAINT check_approved_hours CHECK (approved_hours IS NULL OR approved_hours > 0), + CONSTRAINT check_status CHECK (status IN ('pending', 'approved', 'rejected', 'billed')) +); + +CREATE INDEX idx_tmodule_times_vtiger ON tmodule_times(vtiger_id); +CREATE INDEX idx_tmodule_times_case ON tmodule_times(case_id); +CREATE INDEX idx_tmodule_times_customer ON tmodule_times(customer_id); +CREATE INDEX idx_tmodule_times_status ON tmodule_times(status); +CREATE INDEX idx_tmodule_times_date ON tmodule_times(worked_date); +CREATE INDEX idx_tmodule_times_approved_by ON tmodule_times(approved_by); + +-- ============================================================================ +-- ORDRER (genereret fra godkendte tider) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_orders ( + id SERIAL PRIMARY KEY, + customer_id INTEGER NOT NULL REFERENCES tmodule_customers(id) ON DELETE CASCADE, + hub_customer_id INTEGER, -- Reference til customers.id (read-only) + + -- Order metadata + order_number VARCHAR(50), -- Auto-generated: TT-YYYYMMDD-XXX + order_date DATE DEFAULT CURRENT_DATE, + status VARCHAR(20) DEFAULT 'draft', -- draft|exported|sent|cancelled + + -- Beløb + total_hours DECIMAL(8,2) NOT NULL DEFAULT 0, + hourly_rate DECIMAL(10,2) NOT NULL, + subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, + vat_rate DECIMAL(5,2) DEFAULT 25.00, -- Danish VAT + vat_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + total_amount DECIMAL(12,2) NOT NULL DEFAULT 0, + + -- e-conomic integration + economic_draft_id INTEGER, + economic_order_number VARCHAR(50), + exported_at TIMESTAMP, + exported_by INTEGER, -- Reference til users.id (read-only) + + -- Metadata + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP, + created_by INTEGER, -- Reference til users.id (read-only) + + CONSTRAINT check_total_hours CHECK (total_hours >= 0), + CONSTRAINT check_amounts CHECK (subtotal >= 0 AND vat_amount >= 0 AND total_amount >= 0), + CONSTRAINT check_status CHECK (status IN ('draft', 'exported', 'sent', 'cancelled')) +); + +CREATE INDEX idx_tmodule_orders_customer ON tmodule_orders(customer_id); +CREATE INDEX idx_tmodule_orders_status ON tmodule_orders(status); +CREATE INDEX idx_tmodule_orders_date ON tmodule_orders(order_date); +CREATE INDEX idx_tmodule_orders_economic ON tmodule_orders(economic_draft_id); +CREATE UNIQUE INDEX idx_tmodule_orders_number ON tmodule_orders(order_number) WHERE order_number IS NOT NULL; + +-- ============================================================================ +-- ORDRE-LINJER (detaljer pr. case eller gruppering) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_order_lines ( + id SERIAL PRIMARY KEY, + order_id INTEGER NOT NULL REFERENCES tmodule_orders(id) ON DELETE CASCADE, + case_id INTEGER REFERENCES tmodule_cases(id) ON DELETE SET NULL, + + -- Linje-detaljer + line_number INTEGER NOT NULL, + description TEXT NOT NULL, + quantity DECIMAL(8,2) NOT NULL, -- Timer + unit_price DECIMAL(10,2) NOT NULL, + line_total DECIMAL(12,2) NOT NULL, + + -- Reference til tidsregistreringer + time_entry_ids INTEGER[], -- Array af tmodule_times.id + + -- e-conomic mapping + product_number VARCHAR(50), + account_number VARCHAR(50), + + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT check_line_number CHECK (line_number > 0), + CONSTRAINT check_quantity CHECK (quantity > 0), + CONSTRAINT check_amounts_line CHECK (unit_price >= 0 AND line_total >= 0) +); + +CREATE INDEX idx_tmodule_order_lines_order ON tmodule_order_lines(order_id); +CREATE INDEX idx_tmodule_order_lines_case ON tmodule_order_lines(case_id); + +-- ============================================================================ +-- AUDIT LOG (fuld sporbarhed af alle handlinger) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS tmodule_sync_log ( + id SERIAL PRIMARY KEY, + event_type VARCHAR(50) NOT NULL, -- sync_started|sync_completed|approval|rejection|export|uninstall + entity_type VARCHAR(50), -- time_entry|order|customer|case + entity_id INTEGER, + user_id INTEGER, -- Reference til users.id (read-only) + + -- Event-specifik data + details JSONB, + + -- Metadata + ip_address INET, + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT check_event_type CHECK (event_type IN ( + 'sync_started', 'sync_completed', 'sync_failed', + 'approval', 'rejection', 'bulk_approval', + 'order_created', 'order_updated', 'order_cancelled', + 'export_started', 'export_completed', 'export_failed', + 'module_installed', 'module_uninstalled' + )) +); + +CREATE INDEX idx_tmodule_sync_log_event ON tmodule_sync_log(event_type); +CREATE INDEX idx_tmodule_sync_log_entity ON tmodule_sync_log(entity_type, entity_id); +CREATE INDEX idx_tmodule_sync_log_user ON tmodule_sync_log(user_id); +CREATE INDEX idx_tmodule_sync_log_created ON tmodule_sync_log(created_at DESC); + +-- ============================================================================ +-- TRIGGERS FOR AUTO-UPDATE TIMESTAMPS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION tmodule_update_timestamp() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tmodule_customers_update + BEFORE UPDATE ON tmodule_customers + FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp(); + +CREATE TRIGGER tmodule_cases_update + BEFORE UPDATE ON tmodule_cases + FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp(); + +CREATE TRIGGER tmodule_times_update + BEFORE UPDATE ON tmodule_times + FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp(); + +CREATE TRIGGER tmodule_orders_update + BEFORE UPDATE ON tmodule_orders + FOR EACH ROW EXECUTE FUNCTION tmodule_update_timestamp(); + +-- ============================================================================ +-- AUTO-GENERATE ORDER NUMBERS +-- ============================================================================ + +CREATE OR REPLACE FUNCTION tmodule_generate_order_number() +RETURNS TRIGGER AS $$ +DECLARE + date_prefix VARCHAR(8); + seq_num INTEGER; + new_number VARCHAR(50); +BEGIN + IF NEW.order_number IS NULL THEN + -- Format: TT-YYYYMMDD-XXX + date_prefix := TO_CHAR(CURRENT_DATE, 'YYYYMMDD'); + + -- Find næste sekvensnummer for dagen + SELECT COALESCE(MAX( + CAST(SUBSTRING(order_number FROM 'TT-\d{8}-(\d+)') AS INTEGER) + ), 0) + 1 + INTO seq_num + FROM tmodule_orders + WHERE order_number LIKE 'TT-' || date_prefix || '-%'; + + new_number := 'TT-' || date_prefix || '-' || LPAD(seq_num::TEXT, 3, '0'); + NEW.order_number := new_number; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER tmodule_orders_generate_number + BEFORE INSERT ON tmodule_orders + FOR EACH ROW EXECUTE FUNCTION tmodule_generate_order_number(); + +-- ============================================================================ +-- VIEWS FOR COMMON QUERIES +-- ============================================================================ + +-- Oversigt over godkendelsesstatus pr. kunde +CREATE OR REPLACE VIEW tmodule_approval_stats AS +SELECT + c.id AS customer_id, + c.name AS customer_name, + c.vtiger_id AS customer_vtiger_id, + COUNT(t.id) AS total_entries, + COUNT(t.id) FILTER (WHERE t.status = 'pending') AS pending_count, + COUNT(t.id) FILTER (WHERE t.status = 'approved') AS approved_count, + COUNT(t.id) FILTER (WHERE t.status = 'rejected') AS rejected_count, + COUNT(t.id) FILTER (WHERE t.status = 'billed') AS billed_count, + SUM(t.original_hours) AS total_original_hours, + SUM(t.approved_hours) FILTER (WHERE t.status = 'approved') AS total_approved_hours, + MAX(t.worked_date) AS latest_work_date, + MAX(t.last_synced_at) AS last_sync +FROM tmodule_customers c +LEFT JOIN tmodule_times t ON c.id = t.customer_id +GROUP BY c.id, c.name, c.vtiger_id; + +-- Næste tid der skal godkendes (wizard helper) +CREATE OR REPLACE VIEW tmodule_next_pending AS +SELECT + t.*, + c.title AS case_title, + c.status AS case_status, + cust.name AS customer_name, + cust.hourly_rate AS customer_rate +FROM tmodule_times t +JOIN tmodule_cases c ON t.case_id = c.id +JOIN tmodule_customers cust ON t.customer_id = cust.id +WHERE t.status = 'pending' +ORDER BY cust.name, c.title, t.worked_date; + +-- Order summary med linjer +CREATE OR REPLACE VIEW tmodule_order_details AS +SELECT + o.id AS order_id, + o.order_number, + o.order_date, + o.status AS order_status, + o.total_hours, + o.total_amount, + o.economic_draft_id, + c.name AS customer_name, + c.vtiger_id AS customer_vtiger_id, + COUNT(DISTINCT l.id) AS line_count, + COUNT(DISTINCT t.id) AS time_entry_count +FROM tmodule_orders o +JOIN tmodule_customers c ON o.customer_id = c.id +LEFT JOIN tmodule_order_lines l ON o.id = l.order_id +LEFT JOIN tmodule_times t ON t.id = ANY(l.time_entry_ids) +GROUP BY o.id, o.order_number, o.order_date, o.status, o.total_hours, + o.total_amount, o.economic_draft_id, c.name, c.vtiger_id; + +-- ============================================================================ +-- COMMENTS FOR DOCUMENTATION +-- ============================================================================ + +COMMENT ON TABLE tmodule_metadata IS 'Metadata og konfiguration for tidsregistreringsmodulet'; +COMMENT ON TABLE tmodule_customers IS 'Read-only cache af vTiger kunder (isoleret kopi)'; +COMMENT ON TABLE tmodule_cases IS 'Read-only cache af vTiger cases/projekter (isoleret kopi)'; +COMMENT ON TABLE tmodule_times IS 'Tidsregistreringer importeret fra vTiger med godkendelsesstatus'; +COMMENT ON TABLE tmodule_orders IS 'Genererede ordrer fra godkendte tider'; +COMMENT ON TABLE tmodule_order_lines IS 'Ordre-linjer med reference til tidsregistreringer'; +COMMENT ON TABLE tmodule_sync_log IS 'Fuld audit log af alle modulhandlinger'; + +COMMENT ON COLUMN tmodule_times.status IS 'pending=Afventer godkendelse, approved=Godkendt, rejected=Afvist, billed=Faktureret'; +COMMENT ON COLUMN tmodule_times.approved_hours IS 'Timer efter brugerens godkendelse og evt. afrunding'; +COMMENT ON COLUMN tmodule_times.rounded_to IS 'Afrundingsinterval brugt (0.5, 1.0, etc.)'; +COMMENT ON COLUMN tmodule_orders.status IS 'draft=Kladde, exported=Sendt til e-conomic, sent=Sendt til kunde, cancelled=Annulleret'; + +-- ============================================================================ +-- INITIAL DATA LOG +-- ============================================================================ + +INSERT INTO tmodule_sync_log (event_type, details) +VALUES ( + 'module_installed', + jsonb_build_object( + 'version', '1.0.0', + 'migration', '013_timetracking_module.sql', + 'timestamp', CURRENT_TIMESTAMP + ) +); + +-- ============================================================================ +-- UNINSTALL SCRIPT (bruges ved modul-sletning) +-- ============================================================================ +-- ADVARSEL: Dette script sletter ALLE data i modulet! +-- Kør kun hvis modulet skal fjernes fuldstændigt. +-- +-- For at uninstalle, kør følgende kommandoer i rækkefølge: +-- +-- DROP VIEW IF EXISTS tmodule_order_details CASCADE; +-- DROP VIEW IF EXISTS tmodule_next_pending CASCADE; +-- DROP VIEW IF EXISTS tmodule_approval_stats CASCADE; +-- +-- DROP TRIGGER IF EXISTS tmodule_orders_generate_number ON tmodule_orders; +-- DROP TRIGGER IF EXISTS tmodule_orders_update ON tmodule_orders; +-- DROP TRIGGER IF EXISTS tmodule_times_update ON tmodule_times; +-- DROP TRIGGER IF EXISTS tmodule_cases_update ON tmodule_cases; +-- DROP TRIGGER IF EXISTS tmodule_customers_update ON tmodule_customers; +-- +-- DROP FUNCTION IF EXISTS tmodule_generate_order_number() CASCADE; +-- DROP FUNCTION IF EXISTS tmodule_update_timestamp() CASCADE; +-- +-- DROP TABLE IF EXISTS tmodule_sync_log CASCADE; +-- DROP TABLE IF EXISTS tmodule_order_lines CASCADE; +-- DROP TABLE IF EXISTS tmodule_orders CASCADE; +-- DROP TABLE IF EXISTS tmodule_times CASCADE; +-- DROP TABLE IF EXISTS tmodule_cases CASCADE; +-- DROP TABLE IF EXISTS tmodule_customers CASCADE; +-- DROP TABLE IF EXISTS tmodule_metadata CASCADE; +-- +-- -- Log uninstall i system log hvis muligt +-- -- (Dette vil fejle hvis tmodule_sync_log er droppet, men det er OK) +-- DO $$ +-- BEGIN +-- IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'tmodule_sync_log') THEN +-- INSERT INTO tmodule_sync_log (event_type, details) +-- VALUES ('module_uninstalled', jsonb_build_object('timestamp', CURRENT_TIMESTAMP)); +-- END IF; +-- EXCEPTION WHEN OTHERS THEN +-- -- Ignorer fejl - tabellen er måske allerede slettet +-- NULL; +-- END $$; +-- +-- ============================================================================ diff --git a/test_routes.py b/test_routes.py new file mode 100644 index 0000000..d3a2739 --- /dev/null +++ b/test_routes.py @@ -0,0 +1,17 @@ +#!/usr/bin/env python3 +"""Test route registration""" + +import main + +frontend_routes = [r for r in main.app.routes if hasattr(r, 'path') and not r.path.startswith('/api')] +print(f"Found {len(frontend_routes)} frontend routes:") +for r in frontend_routes[:30]: + path = r.path if hasattr(r, 'path') else str(r) + endpoint_name = r.endpoint.__name__ if hasattr(r, 'endpoint') else 'N/A' + print(f" {path:50} -> {endpoint_name}") + +# Check timetracking specifically +timetracking_routes = [r for r in main.app.routes if hasattr(r, 'path') and 'timetracking' in r.path] +print(f"\nTimetracking routes: {len(timetracking_routes)}") +for r in timetracking_routes: + print(f" {r.path}")