""" 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()