""" 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, List, Dict, Any from decimal import Decimal from datetime import datetime from fastapi import HTTPException from app.core.database import execute_query, execute_update, execute_query_single 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_single(query, (customer_id,)) 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 (inkl. Hub Worklogs)""" try: # 1. Get base stats from module view query = """ SELECT s.*, c.hub_customer_id FROM tmodule_approval_stats s LEFT JOIN tmodule_customers c ON s.customer_id = c.id ORDER BY s.customer_name """ results = execute_query(query) stats_map = {row['customer_id']: dict(row) for row in results} # 2. Get pending count from Hub Worklogs # Filter logic: status='draft' in Hub = 'pending' in Wizard hub_query = """ SELECT mc.id as tmodule_customer_id, mc.name as customer_name, mc.vtiger_id as customer_vtiger_id, mc.uses_time_card, mc.hub_customer_id, count(*) as pending_count, sum(w.hours) as pending_hours FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id JOIN tmodule_customers mc ON mc.hub_customer_id = t.customer_id WHERE w.status = 'draft' GROUP BY mc.id, mc.name, mc.vtiger_id, mc.uses_time_card, mc.hub_customer_id """ hub_results = execute_query(hub_query) # 3. Merge stats for row in hub_results: tm_id = row['tmodule_customer_id'] if tm_id in stats_map: # Update existing stats_map[tm_id]['pending_count'] += row['pending_count'] stats_map[tm_id]['total_entries'] += row['pending_count'] # Optional: Add to total_original_hours if desired else: # New entry for customer only present in Hub worklogs stats_map[tm_id] = { "customer_id": tm_id, "hub_customer_id": row['hub_customer_id'], "customer_name": row['customer_name'], "customer_vtiger_id": row['customer_vtiger_id'] or '', "uses_time_card": row['uses_time_card'], "total_entries": row['pending_count'], "pending_count": row['pending_count'], "approved_count": 0, "rejected_count": 0, "billed_count": 0, "total_original_hours": 0, # Could use row['pending_hours'] "total_approved_hours": 0, "latest_work_date": None, "last_sync": None } return [TModuleApprovalStats(**s) for s in sorted(stats_map.values(), key=lambda x: x['customer_name'])] except Exception as e: logger.error(f"❌ Error getting all customer stats: {e}") return [] 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, exclude_time_card: bool = True ) -> TModuleWizardNextEntry: """ Hent næste pending tidsregistrering til godkendelse. Args: customer_id: Valgfri - filtrer til specifik kunde exclude_time_card: Ekskluder klippekort-kunder (default: true) 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_single(query, (customer_id,)) else: # Hent næste generelt if exclude_time_card: query = """ SELECT np.* FROM tmodule_next_pending np JOIN tmodule_customers c ON np.customer_id = c.id WHERE c.uses_time_card = false LIMIT 1 """ else: query = "SELECT * FROM tmodule_next_pending LIMIT 1" result = execute_query_single(query) 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_single(query, (approval.time_id,)) 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']}" ) # Check if already billed if entry.get('billed_via_thehub_id') is not None: raise HTTPException( status_code=400, detail="Cannot approve time entry that has already been billed" ) # Update entry logger.info(f"🔄 Updating time entry {approval.time_id} in database") update_query = """ UPDATE tmodule_times SET status = 'approved', approved_hours = %s, rounded_to = %s, approval_note = %s, billable = %s, is_travel = %s, approved_at = CURRENT_TIMESTAMP, approved_by = %s WHERE id = %s AND billed_via_thehub_id IS NULL """ execute_update( update_query, ( approval.approved_hours, approval.rounded_to, approval.approval_note, approval.billable, approval.is_travel, user_id, approval.time_id ) ) logger.info(f"✅ Database update successful for time entry {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_single(query, (approval.time_id,)) 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_single(query, (time_id,)) 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']}" ) # Check if already billed if entry.get('billed_via_thehub_id') is not None: raise HTTPException( status_code=400, detail="Cannot reject time entry that has already been billed" ) # 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 AND billed_via_thehub_id IS NULL """ 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_single(query, (time_id,)) 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 reset_to_pending( time_id: int, reason: Optional[str] = None, user_id: Optional[int] = None ) -> TModuleTimeWithContext: """ Nulstil en godkendt/afvist tidsregistrering tilbage til pending. Args: time_id: ID på tidsregistreringen reason: Årsag til nulstilling user_id: ID på brugeren der nulstiller 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_single(query, (time_id,)) if not entry: raise HTTPException(status_code=404, detail="Time entry not found") if entry['status'] == 'pending': raise HTTPException( status_code=400, detail="Time entry is already pending" ) if entry['status'] == 'billed': raise HTTPException( status_code=400, detail="Cannot reset billed entries" ) # Check if already billed via Hub order if entry.get('billed_via_thehub_id') is not None: raise HTTPException( status_code=400, detail="Cannot reset time entry that has been billed through Hub order" ) # Reset to pending - clear all approval data update_query = """ UPDATE tmodule_times SET status = 'pending', approved_hours = NULL, rounded_to = NULL, approval_note = %s, billable = true, approved_at = NULL, approved_by = NULL WHERE id = %s AND billed_via_thehub_id IS NULL """ execute_update(update_query, (reason, time_id)) # Log reset audit.log_event( event_type="reset_to_pending", entity_type="time_entry", entity_id=time_id, user_id=user_id, details={ "reason": reason or "Reset to pending", "timestamp": datetime.now().isoformat() } ) logger.info(f"🔄 Reset time entry {time_id} to pending: {reason}") # Return updated updated = execute_query_single(query, (time_id,)) return TModuleTimeWithContext(**updated) except HTTPException: raise except Exception as e: logger.error(f"❌ Error resetting time entry: {e}") raise HTTPException(status_code=500, detail=str(e)) @staticmethod def approve_case_entries( case_id: int, user_id: Optional[int] = None, exclude_time_card: bool = True ) -> Dict[str, Any]: """ Bulk-godkend alle pending tidsregistreringer for en case. Args: case_id: Case ID user_id: ID på brugeren der godkender exclude_time_card: Ekskluder klippekort-kunder Returns: Dict med statistik: approved_count, total_hours, etc. """ try: # Hent alle pending entries for case entries = WizardService.get_case_entries(case_id, exclude_time_card) if not entries: return { "approved_count": 0, "total_hours": 0.0, "case_id": case_id, "entries": [] } approved_entries = [] total_hours = 0.0 for entry in entries: # Auto-approve med samme timer som original (eller afrundet hvis enabled) from app.core.config import settings from decimal import Decimal approved_hours = Decimal(str(entry.original_hours)) # Afrund hvis enabled if settings.TIMETRACKING_AUTO_ROUND: increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) if settings.TIMETRACKING_ROUND_METHOD == "up": # Afrund op approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment elif settings.TIMETRACKING_ROUND_METHOD == "down": # Afrund ned approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment else: # Nærmeste approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment # Godkend entry approval = TModuleTimeApproval( time_id=entry.id, approved_hours=float(approved_hours) ) approved = WizardService.approve_time_entry(approval, user_id) approved_entries.append({ "id": approved.id, "original_hours": float(approved.original_hours), "approved_hours": float(approved.approved_hours) }) total_hours += float(approved.approved_hours) # Log bulk approval audit.log_event( entity_type="case", entity_id=str(case_id), event_type="bulk_approval", details={ "approved_count": len(approved_entries), "total_hours": total_hours }, user_id=user_id ) return { "approved_count": len(approved_entries), "total_hours": total_hours, "case_id": case_id, "entries": approved_entries } except HTTPException: raise except Exception as e: logger.error(f"❌ Error bulk approving case: {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 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' AND t.billed_via_thehub_id IS NULL ORDER BY t.worked_date LIMIT 1 """ case = execute_query_single(query, (customer_id,)) 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)) @staticmethod def get_case_entries( case_id: int, exclude_time_card: bool = True ) -> List[TModuleTimeWithContext]: """ Hent alle pending tidsregistreringer for en specifik case. Bruges til at vise alle timelogs i samme case samtidig. Args: case_id: Case ID exclude_time_card: Ekskluder klippekort-kunder Returns: Liste af tidsregistreringer for casen """ try: if exclude_time_card: query = """ SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, t.original_hours, t.worked_date, t.user_name, t.status, t.approved_hours, t.rounded_to, t.approval_note, t.billable, t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, t.created_at, t.updated_at, t.last_synced_at, COALESCE(NULLIF(TRIM(c.title), ''), c.vtiger_data->>'title', 'Ingen titel')::VARCHAR(500) AS case_title, c.description AS case_description, c.status AS case_status, c.vtiger_id AS case_vtiger_id, cust.name AS customer_name, cust.hourly_rate AS customer_rate, CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, cont.user_company AS contact_company, c.vtiger_data AS case_vtiger_data FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_customers cust ON t.customer_id = cust.id LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' WHERE t.case_id = %s AND t.status = 'pending' AND t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0' AND t.billed_via_thehub_id IS NULL AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') AND cust.uses_time_card = false ORDER BY t.worked_date, t.id """ else: query = """ SELECT t.id, t.vtiger_id, t.case_id, t.customer_id, t.description, t.original_hours, t.worked_date, t.user_name, t.status, t.approved_hours, t.rounded_to, t.approval_note, t.billable, t.approved_at, t.approved_by, t.vtiger_data, t.sync_hash, t.created_at, t.updated_at, t.last_synced_at, COALESCE(NULLIF(TRIM(c.title), ''), c.vtiger_data->>'title', 'Ingen titel')::VARCHAR(500) AS case_title, c.description AS case_description, c.status AS case_status, c.vtiger_id AS case_vtiger_id, cust.name AS customer_name, cust.hourly_rate AS customer_rate, CONCAT(cont.first_name, ' ', cont.last_name) AS contact_name, cont.user_company AS contact_company, c.vtiger_data AS case_vtiger_data FROM tmodule_times t JOIN tmodule_cases c ON t.case_id = c.id JOIN tmodule_customers cust ON t.customer_id = cust.id LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id' WHERE t.case_id = %s AND t.status = 'pending' AND t.billable = true AND t.vtiger_data->>'cf_timelog_invoiced' = '0' AND t.billed_via_thehub_id IS NULL AND (t.vtiger_data->>'cf_timelog_rounduptimespent' IS NULL OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '0' OR t.vtiger_data->>'cf_timelog_rounduptimespent' = '') AND (t.vtiger_data->>'cf_timelog_billedviathehubid' IS NULL OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '0' OR t.vtiger_data->>'cf_timelog_billedviathehubid' = '') ORDER BY t.worked_date, t.id """ results = execute_query(query, (case_id,)) return [TModuleTimeWithContext(**row) for row in results] except Exception as e: logger.error(f"❌ Error getting case entries: {e}") raise HTTPException(status_code=500, detail=str(e)) @staticmethod def get_case_details(case_id: int) -> Dict[str, Any]: """ Hent komplet case information inkl. alle timelogs og kommentarer. Returns: Dict med case info, timelogs (alle statuses), og kommentarer fra vtiger_data """ try: # Hent case info case_query = """ SELECT id, vtiger_id, title, description, status, vtiger_data, customer_id FROM tmodule_cases WHERE id = %s """ case = execute_query_single(case_query, (case_id,)) if not case: raise HTTPException(status_code=404, detail="Case not found") # Hent ALLE timelogs for casen (ikke kun pending) timelogs_query = """ SELECT t.*, COALESCE(NULLIF(TRIM(c.title), ''), c.vtiger_data->>'title', 'Ingen titel')::VARCHAR(500) AS case_title, c.status AS case_status, c.vtiger_id AS case_vtiger_id, 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.case_id = %s ORDER BY t.worked_date DESC, t.created_at DESC """ timelogs = execute_query(timelogs_query, (case_id,)) # Parse case comments from vtiger_data JSON case_comments = [] if case.get('vtiger_data'): vtiger_data = case['vtiger_data'] # vTiger gemmer comments som en array i JSON if isinstance(vtiger_data, dict): raw_comments = vtiger_data.get('comments', []) or vtiger_data.get('modcomments', []) for comment in raw_comments: if isinstance(comment, dict): case_comments.append({ 'id': comment.get('modcommentsid', comment.get('id')), 'comment_text': comment.get('commentcontent', comment.get('comment', '')), 'creator_name': comment.get('assigned_user_id', comment.get('creator', 'Unknown')), 'created_at': comment.get('createdtime', comment.get('created_at', '')) }) return { 'case_id': case['id'], 'case_vtiger_id': case['vtiger_id'], 'case_title': case['title'], 'case_description': case['description'], 'case_status': case['status'], 'timelogs': [TModuleTimeWithContext(**t) for t in timelogs], 'case_comments': case_comments } except HTTPException: raise except Exception as e: logger.error(f"❌ Error getting case details: {e}") raise HTTPException(status_code=500, detail=str(e)) # Singleton instance wizard = WizardService()