""" 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, Dict, Any from datetime import datetime, date from calendar import monthrange from fastapi import APIRouter, HTTPException, Depends, Body, Query from fastapi.responses import JSONResponse from app.core.database import execute_query, execute_update, execute_query_single from app.timetracking.backend.models import ( TModuleSyncStats, TModuleApprovalStats, TModuleWizardNextEntry, TModuleWizardProgress, TModuleTimeApproval, TModuleTimeWithContext, TModuleOrder, TModuleOrderWithLines, TModuleEconomicExportRequest, TModuleEconomicExportResult, TModuleMetadata, TModuleUninstallRequest, TModuleUninstallResult, TModuleBulkRateUpdate, ServiceContractWizardData, ServiceContractWizardAction, ServiceContractWizardSummary, TimologTransferRequest, TimologTransferResult, ) 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 from app.services.customer_consistency import CustomerConsistencyService from app.timetracking.backend.service_contract_wizard import ServiceContractWizardService from app.services.vtiger_service import get_vtiger_service from app.ticket.backend.klippekort_service import KlippekortService from app.core.auth_dependencies import get_optional_user logger = logging.getLogger(__name__) router = APIRouter(prefix="/timetracking") # ============================================================================ # SYNC ENDPOINTS # ============================================================================ @router.post("/sync", response_model=TModuleSyncStats, tags=["Sync"]) async def sync_from_vtiger( user_id: Optional[int] = None, fetch_comments: bool = False ): """ 🔍 Synkroniser data fra vTiger (READ-ONLY). Henter: - Accounts (kunder) - HelpDesk (cases) - ModComments (tidsregistreringer) Gemmes i tmodule_* tabeller (isoleret). Args: user_id: ID på bruger der kører sync fetch_comments: Hent også interne kommentarer (langsomt - ~0.4s pr case) """ try: logger.info("🚀 Starting vTiger sync...") result = await vtiger_service.full_sync(user_id=user_id, fetch_comments=fetch_comments) # Auto-link customers after sync to update links based on fresh vTiger data logger.info("🔗 Auto-linking customers after sync...") try: link_query = "SELECT * FROM link_tmodule_customers_to_hub()" link_results = execute_query(link_query) logger.info(f"✅ Linked {len(link_results)} customers automatically") except Exception as link_error: logger.warning(f"⚠️ Customer linking failed (non-critical): {link_error}") return result except Exception as e: logger.error(f"❌ Sync failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/sync/case/{case_id}/comments", tags=["Sync"]) async def sync_case_comments(case_id: int): """ 🔍 Synkroniser kommentarer for en specifik case fra vTiger. Bruges til on-demand opdatering når man ser på en case i wizard. """ try: # Hent case fra database case = execute_query_single( "SELECT vtiger_id FROM tmodule_cases WHERE id = %s", (case_id,)) if not case: raise HTTPException(status_code=404, detail="Case not found") # Sync comments result = await vtiger_service.sync_case_comments(case['vtiger_id']) if not result['success']: raise HTTPException(status_code=500, detail=result.get('error', 'Failed to sync comments')) return result except HTTPException: raise except Exception as e: logger.error(f"❌ Failed to sync comments for case {case_id}: {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)) @router.post("/sync/fix-empty-case-titles", tags=["Sync"]) async def fix_empty_case_titles(): """ 🔧 Opdater tomme case titles fra vtiger_data. Henter titles fra vtiger_data JSON for cases der har tom eller generisk title. """ try: logger.info("🔧 Fixing empty case titles...") # Find cases med tomme eller generiske titles cases_query = """ SELECT id, vtiger_id, title, vtiger_data->>'title' as vtiger_title, vtiger_data->>'ticket_title' as vtiger_ticket_title FROM tmodule_cases WHERE title IS NULL OR TRIM(title) = '' OR LOWER(title) IN ('ingen titel', 'no title', 'none') """ cases = execute_query(cases_query) if not cases: return { "success": True, "message": "No cases with empty titles found", "updated": 0 } updated_count = 0 for case in cases: # Prioriter title fra vtiger_data new_title = case.get('vtiger_title') or case.get('vtiger_ticket_title') if new_title and new_title.strip(): execute_query( "UPDATE tmodule_cases SET title = %s WHERE id = %s", (new_title, case['id']) ) updated_count += 1 logger.info(f"✅ Updated case {case['vtiger_id']}: '{new_title}'") return { "success": True, "message": f"Updated {updated_count} cases", "total_checked": len(cases), "updated": updated_count } except Exception as e: logger.error(f"❌ Fix empty titles failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/sync/manual-link-customer", tags=["Sync"]) async def manual_link_customer(tmodule_customer_id: int, hub_customer_id: int): """ 🔗 Manually link en tmodule_customer til en Hub customer. Brug dette når auto-linking fejler pga. navn-forskelle eller andre edge cases. """ try: logger.info(f"🔗 Manual linking tmodule {tmodule_customer_id} → Hub {hub_customer_id}") # Get Hub customer's economic number hub_query = "SELECT economic_customer_number FROM customers WHERE id = %s" hub_result = execute_query(hub_query, (hub_customer_id,)) if not hub_result: raise HTTPException(status_code=404, detail=f"Hub customer {hub_customer_id} not found") economic_number = hub_result[0]['economic_customer_number'] # Update tmodule_customer update_query = """ UPDATE tmodule_customers SET hub_customer_id = %s, economic_customer_number = %s, updated_at = NOW() WHERE id = %s RETURNING id, name, hub_customer_id, economic_customer_number """ result = execute_query(update_query, (hub_customer_id, economic_number, tmodule_customer_id)) if not result: raise HTTPException(status_code=404, detail=f"tmodule_customer {tmodule_customer_id} not found") logger.info(f"✅ Manual link complete: {result[0]}") return { "success": True, "linked_customer": result[0] } except HTTPException: raise except Exception as e: logger.error(f"❌ Manual linking failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/sync/relink-customers", tags=["Sync"]) async def relink_customers(): """ 🔗 Re-link alle tmodule_customers til Hub customers. Bruger linking funktion der prioriterer: 1. economic_customer_number match (mest præcis) 2. Navn match (fallback) Kør efter vTiger sync for at opdatere kunde-links baseret på nyeste data fra vTiger (cf_854 economic numbers). """ try: logger.info("🔗 Starting customer re-linking...") # Call database function query = "SELECT * FROM link_tmodule_customers_to_hub()" results = execute_query(query) # Count by action type stats = { "economic_matches": 0, "name_matches": 0, "total_linked": len(results) } for result in results: if result['action'] == 'economic_number_match': stats["economic_matches"] += 1 elif result['action'] == 'name_match': stats["name_matches"] += 1 logger.info(f"✅ Customer linking complete: {stats}") return { "success": True, "stats": stats, "linked_customers": results } except Exception as e: logger.error(f"❌ Customer linking failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/debug/raw-stats", tags=["Debug"]) async def get_debug_raw_stats(): """ 🔍 DEBUG: Vis rå statistik fra databasen uden filtering. Bruges til at diagnosticere hvorfor timelogs ikke vises. """ try: # Total counts without any filtering total_query = """ SELECT (SELECT COUNT(*) FROM tmodule_customers) as total_customers, (SELECT COUNT(*) FROM tmodule_cases) as total_cases, (SELECT COUNT(*) FROM tmodule_times) as total_times, (SELECT COUNT(*) FROM tmodule_times WHERE billable = true) as billable_times, (SELECT COUNT(*) FROM tmodule_times WHERE status = 'pending') as pending_times, (SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' = '0') as not_invoiced_times, (SELECT COUNT(*) FROM tmodule_times WHERE vtiger_data->>'cf_timelog_invoiced' IS NULL) as null_invoiced_times, (SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending') as billable_pending, (SELECT COUNT(*) FROM tmodule_times WHERE billable = true AND status = 'pending' AND vtiger_data->>'cf_timelog_invoiced' = '0') as filtered_pending """ totals = execute_query_single(total_query) # Sample timelogs to see actual data sample_query = """ SELECT id, vtiger_id, description, worked_date, original_hours, status, billable, vtiger_data->>'cf_timelog_invoiced' as cf_timelog_invoiced, vtiger_data->>'isbillable' as is_billable_field, customer_id, case_id FROM tmodule_times ORDER BY worked_date DESC LIMIT 10 """ samples = execute_query(sample_query) # Check what invoice statuses actually exist invoice_status_query = """ SELECT vtiger_data->>'cf_timelog_invoiced' as invoice_status, COUNT(*) as count FROM tmodule_times GROUP BY vtiger_data->>'cf_timelog_invoiced' ORDER BY count DESC """ invoice_statuses = execute_query(invoice_status_query) return { "totals": totals, "sample_timelogs": samples, "invoice_statuses": invoice_statuses, "explanation": { "issue": "If not_invoiced_times is 0 but total_times > 0, then the cf_timelog_invoiced field is not '0' in vTiger", "solution": "The SQL views filter on cf_timelog_invoiced = '0' but vTiger might use different values", "check": "Look at invoice_statuses to see what values actually exist" } } except Exception as e: logger.error(f"❌ Debug query failed: {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, exclude_time_card: bool = True ): """ Hent næste pending tidsregistrering til godkendelse. Query params: - customer_id: Filtrer til specifik kunde (optional) - exclude_time_card: Ekskluder klippekort-kunder (default: true) """ try: return wizard.get_next_pending_entry( customer_id=customer_id, exclude_time_card=exclude_time_card ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/approve/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def approve_time_entry( time_id: int, request: Dict[str, Any] = Body(...), user_id: Optional[int] = None ): """ Godkend en tidsregistrering. Path params: - time_id: ID på tidsregistreringen Body: - billable_hours: Timer efter godkendelse (optional) - hourly_rate: Timepris i DKK (optional) - is_travel: True hvis kørsel (optional) - approval_note: Godkendelsesnote (optional) - rounding_method: "up", "down", "nearest" (optional) """ try: from app.core.config import settings from decimal import Decimal # SPECIAL HANDLER FOR HUB WORKLOGS (Negative IDs) if time_id < 0: worklog_id = abs(time_id) logger.info(f"🔄 Approving Hub Worklog {worklog_id}") w_entry = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,)) if not w_entry: raise HTTPException(status_code=404, detail="Worklog not found") billable_hours = request.get('billable_hours') approved_hours = Decimal(str(billable_hours)) if billable_hours is not None else Decimal(str(w_entry['hours'])) is_billable = request.get('billable', True) new_billing = 'invoice' if is_billable else 'internal' execute_query(""" UPDATE tticket_worklog SET hours = %s, billing_method = %s, status = 'billable' WHERE id = %s """, (approved_hours, new_billing, worklog_id)) return { "id": time_id, "worked_date": w_entry['work_date'], "original_hours": w_entry['hours'], "status": "approved", # Mock fields for schema validation "customer_id": 0, "case_id": 0, "description": w_entry['description'], "case_title": "Ticket Worklog", "customer_name": "Hub Customer", "created_at": w_entry['created_at'], "last_synced_at": datetime.now(), "approved_hours": approved_hours } # Hent timelog 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") # Beregn approved_hours billable_hours = request.get('billable_hours') rounding_method = request.get('rounding_method') if billable_hours is None: approved_hours = Decimal(str(entry['original_hours'])) # Auto-afrund hvis enabled if settings.TIMETRACKING_AUTO_ROUND: increment = Decimal(str(settings.TIMETRACKING_ROUND_INCREMENT)) method = rounding_method or settings.TIMETRACKING_ROUND_METHOD if method == "up": approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_UP') * increment elif method == "down": approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_DOWN') * increment else: approved_hours = (approved_hours / increment).quantize(Decimal('1'), rounding='ROUND_HALF_UP') * increment rounded_to = increment else: rounded_to = None else: approved_hours = Decimal(str(billable_hours)) rounded_to = None # Note: hourly_rate is stored on customer level (tmodule_customers.hourly_rate), not on time entries # Frontend sends it for calculation display but we don't store it per time entry # Godkend med alle felter logger.info(f"🔍 Creating approval for time_id={time_id}: approved_hours={approved_hours}, rounded_to={rounded_to}, is_travel={request.get('is_travel', False)}, billable={request.get('billable', True)}") approval = TModuleTimeApproval( time_id=time_id, approved_hours=approved_hours, rounded_to=rounded_to, approval_note=request.get('approval_note'), billable=request.get('billable', True), # Accept from request, default til fakturerbar is_travel=request.get('is_travel', False) ) logger.info(f"✅ Approval object created successfully") return wizard.approve_time_entry(approval, user_id=user_id) except HTTPException: raise except Exception as e: logger.error(f"❌ Error approving entry {time_id}: {e}", exc_info=True) 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, request: Dict[str, Any] = Body(None), # Allow body reason: Optional[str] = None, user_id: Optional[int] = None ): """Afvis en tidsregistrering""" try: # Handle body extraction if reason is missing from query if not reason and request and 'rejection_note' in request: reason = request['rejection_note'] if time_id < 0: worklog_id = abs(time_id) # Retrieve to confirm existence w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,)) if not w: raise HTTPException(status_code=404, detail="Entry not found") execute_query("UPDATE tticket_worklog SET status = 'rejected' WHERE id = %s", (worklog_id,)) return { "id": time_id, "status": "rejected", "original_hours": w['hours'], "worked_date": w['work_date'], # Mock fields for schema validation "customer_id": 0, "case_id": 0, "description": w.get('description', ''), "case_title": "Ticket Worklog", "customer_name": "Hub Customer", "created_at": w['created_at'], "last_synced_at": datetime.now(), "billable": False } 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)) from app.timetracking.backend.models import TModuleWizardEditRequest @router.patch("/wizard/entry/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def update_entry_details( time_id: int, request: TModuleWizardEditRequest ): """ Opdater detaljer på en tidsregistrering (før godkendelse). Tillader ændring af beskrivelse, antal timer og faktureringsmetode. """ try: from decimal import Decimal # 1. Handling Hub Worklogs (Negative IDs) if time_id < 0: worklog_id = abs(time_id) w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,)) if not w: raise HTTPException(status_code=404, detail="Worklog not found") updates = [] params = [] if request.description is not None: updates.append("description = %s") params.append(request.description) if request.original_hours is not None: updates.append("hours = %s") params.append(request.original_hours) if request.billing_method is not None: updates.append("billing_method = %s") params.append(request.billing_method) if updates: params.append(worklog_id) execute_query(f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s", tuple(params)) w = execute_query_single("SELECT * FROM tticket_worklog WHERE id = %s", (worklog_id,)) return { "id": time_id, "worked_date": w['work_date'], "original_hours": w['hours'], "status": "pending", # Always return as pending/draft context here "description": w['description'], "customer_id": 0, "case_id": 0, "case_title": "Updated", "customer_name": "Hub Customer", "created_at": w['created_at'], "last_synced_at": datetime.now(), "billable": True } # 2. Handling Module Times (Positive IDs) else: t = execute_query_single("SELECT * FROM tmodule_times WHERE id = %s", (time_id,)) if not t: raise HTTPException(status_code=404, detail="Time entry not found") updates = [] params = [] if request.description is not None: updates.append("description = %s") params.append(request.description) if request.original_hours is not None: updates.append("original_hours = %s") params.append(request.original_hours) if request.billable is not None: updates.append("billable = %s") params.append(request.billable) if updates: params.append(time_id) execute_query(f"UPDATE tmodule_times SET {', '.join(updates)} WHERE id = %s", tuple(params)) # Fetch fresh with context for response query = """ SELECT t.*, c.title as case_title, c.status as case_status, cust.name as customer_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.id = %s """ return execute_query_single(query, (time_id,)) except Exception as e: logger.error(f"❌ Failed to update entry {time_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/reset/{time_id}", response_model=TModuleTimeWithContext, tags=["Wizard"]) async def reset_to_pending( time_id: int, reason: Optional[str] = None, user_id: Optional[int] = None ): """ Nulstil en godkendt/afvist tidsregistrering tilbage til pending. Query params: - reason: Årsag til nulstilling (optional) - user_id: ID på brugeren der nulstiller (optional) """ try: return wizard.reset_to_pending(time_id, reason=reason, user_id=user_id) except HTTPException: raise except Exception as e: logger.error(f"❌ Reset failed: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/case/{case_id}/entries", response_model=List[TModuleTimeWithContext], tags=["Wizard"]) async def get_case_entries( case_id: int, exclude_time_card: bool = True ): """ Hent alle pending timelogs for en case. Bruges til at vise alle tidsregistreringer i samme case grupperet. """ try: return wizard.get_case_entries(case_id, exclude_time_card=exclude_time_card) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/wizard/case/{case_id}/details", tags=["Wizard"]) async def get_case_details(case_id: int): """ Hent komplet case information inkl. alle timelogs og kommentarer. Returnerer: - case_id, case_title, case_description, case_status - timelogs: ALLE tidsregistreringer (pending, approved, rejected) - case_comments: Kommentarer fra vTiger """ try: return wizard.get_case_details(case_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.post("/wizard/case/{case_id}/approve-all", tags=["Wizard"]) async def approve_all_case_entries( case_id: int, user_id: Optional[int] = None, exclude_time_card: bool = True ): """ Bulk-godkend alle pending timelogs for en case. Afr under automatisk efter configured settings. """ try: return wizard.approve_case_entries( case_id=case_id, user_id=user_id, exclude_time_card=exclude_time_card ) 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.get("/customers/{customer_id}/data-consistency", tags=["Customers", "Data Consistency"]) async def check_tmodule_customer_data_consistency(customer_id: int): """ 🔍 Check data consistency across Hub, vTiger, and e-conomic for tmodule_customer. Before creating order, verify customer data is in sync across all systems. Maps tmodule_customers.hub_customer_id to the consistency service. Returns discrepancies found between the three systems. """ try: from app.core.config import settings if not settings.AUTO_CHECK_CONSISTENCY: return { "enabled": False, "message": "Data consistency checking is disabled" } # Get tmodule_customer and find linked hub customer tmodule_customer = execute_query_single( "SELECT * FROM tmodule_customers WHERE id = %s", (customer_id,) ) if not tmodule_customer: raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers") # Get linked hub customer ID hub_customer_id = tmodule_customer.get('hub_customer_id') if not hub_customer_id: return { "enabled": True, "customer_id": customer_id, "discrepancy_count": 0, "discrepancies": {}, "systems_available": { "hub": False, "vtiger": bool(tmodule_customer.get('vtiger_id')), "economic": False }, "message": "Customer not linked to Hub - cannot check consistency" } # Use Hub customer ID for consistency check consistency_service = CustomerConsistencyService() # Fetch data from all systems all_data = await consistency_service.fetch_all_data(hub_customer_id) # Compare data discrepancies = consistency_service.compare_data(all_data) # Count actual discrepancies discrepancy_count = sum( 1 for field_data in discrepancies.values() if field_data['discrepancy'] ) return { "enabled": True, "customer_id": customer_id, "hub_customer_id": hub_customer_id, "discrepancy_count": discrepancy_count, "discrepancies": discrepancies, "systems_available": { "hub": True, "vtiger": all_data.get('vtiger') is not None, "economic": all_data.get('economic') is not None } } except Exception as e: logger.error(f"❌ Failed to check consistency for tmodule_customer {customer_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/customers/{customer_id}/sync-field", tags=["Customers", "Data Consistency"]) async def sync_tmodule_customer_field( customer_id: int, field_name: str = Body(..., description="Hub field name to sync"), source_system: str = Body(..., description="Source system: hub, vtiger, or economic"), source_value: str = Body(..., description="The correct value to sync") ): """ 🔄 Sync a single field across all systems for tmodule_customer. Takes the correct value from one system and updates the others. Maps tmodule_customers.hub_customer_id to the consistency service. """ try: from app.core.config import settings # Validate source system if source_system not in ['hub', 'vtiger', 'economic']: raise HTTPException( status_code=400, detail=f"Invalid source_system: {source_system}. Must be hub, vtiger, or economic" ) # Get tmodule_customer and find linked hub customer tmodule_customer = execute_query_single( "SELECT * FROM tmodule_customers WHERE id = %s", (customer_id,) ) if not tmodule_customer: raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers") # Get linked hub customer ID hub_customer_id = tmodule_customer.get('hub_customer_id') if not hub_customer_id: raise HTTPException( status_code=400, detail="Customer not linked to Hub - cannot sync fields" ) consistency_service = CustomerConsistencyService() # Perform sync on the linked Hub customer results = await consistency_service.sync_field( customer_id=hub_customer_id, field_name=field_name, source_system=source_system, source_value=source_value ) logger.info(f"✅ Field '{field_name}' synced from {source_system}: {results}") return { "success": True, "field": field_name, "source": source_system, "value": source_value, "results": results } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"❌ Failed to sync field for tmodule_customer {customer_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @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)) @router.post("/orders/{order_id}/unlock", tags=["Orders"]) async def unlock_order( order_id: int, admin_code: str, user_id: Optional[int] = None ): """ 🔓 Lås en eksporteret ordre op for ændringer (ADMIN ONLY). Kræver: 1. Korrekt admin unlock code (fra TIMETRACKING_ADMIN_UNLOCK_CODE) 2. Ordren skal være slettet fra e-conomic først Query params: - admin_code: Admin unlock kode """ try: from app.core.config import settings # Verify admin code if not settings.TIMETRACKING_ADMIN_UNLOCK_CODE: raise HTTPException( status_code=500, detail="Admin unlock code ikke konfigureret i systemet" ) if admin_code != settings.TIMETRACKING_ADMIN_UNLOCK_CODE: logger.warning(f"⚠️ Ugyldig unlock code forsøg for ordre {order_id}") raise HTTPException(status_code=403, detail="Ugyldig admin kode") # Get order order = order_service.get_order_with_lines(order_id) if order.status != 'exported': raise HTTPException( status_code=400, detail="Kun eksporterede ordrer kan låses op" ) if not order.economic_draft_id: raise HTTPException( status_code=400, detail="Ordre har ingen e-conomic ID" ) # Check if order still exists in e-conomic try: draft_exists = await economic_service.check_draft_exists(order.economic_draft_id) if draft_exists: raise HTTPException( status_code=400, detail=f"⚠️ Ordren findes stadig i e-conomic (Draft #{order.economic_draft_id}). Slet den i e-conomic først!" ) except Exception as e: logger.error(f"❌ Kunne ikke tjekke e-conomic status: {e}") raise HTTPException( status_code=500, detail=f"Kunne ikke verificere e-conomic status: {str(e)}" ) # Unlock order - set status back to draft update_query = """ UPDATE tmodule_orders SET status = 'draft', economic_draft_id = NULL, exported_at = NULL WHERE id = %s RETURNING * """ result = execute_query_single(update_query, (order_id,)) # Log unlock audit.log_event( event_type="order_unlocked", entity_type="order", entity_id=order_id, user_id=user_id, details={ "previous_economic_id": order.economic_draft_id, "unlocked_by_admin": True } ) logger.info(f"🔓 Order {order_id} unlocked by admin (user {user_id})") return { "success": True, "message": "Ordre låst op - kan nu redigeres eller slettes", "order_id": order_id } except HTTPException: raise except Exception as e: logger.error(f"❌ Unlock failed: {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_single( "SELECT * FROM tmodule_metadata ORDER BY id DESC LIMIT 1") 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_single(tables_query) 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_single( f"SELECT COUNT(*) as count FROM tmodule_{table_name}") 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=500, content={"status": "error", "message": str(e)} ) @router.get("/config", tags=["Admin"]) async def get_config(): """Hent modul konfiguration""" from app.core.config import settings return { "default_hourly_rate": float(settings.TIMETRACKING_DEFAULT_HOURLY_RATE), "auto_round": settings.TIMETRACKING_AUTO_ROUND, "round_increment": float(settings.TIMETRACKING_ROUND_INCREMENT), "round_method": settings.TIMETRACKING_ROUND_METHOD, "vtiger_read_only": settings.TIMETRACKING_VTIGER_READ_ONLY, "vtiger_dry_run": settings.TIMETRACKING_VTIGER_DRY_RUN, "economic_read_only": settings.TIMETRACKING_ECONOMIC_READ_ONLY, "economic_dry_run": settings.TIMETRACKING_ECONOMIC_DRY_RUN } # ============================================================================ # CUSTOMER MANAGEMENT ENDPOINTS # ============================================================================ @router.patch("/customers/{customer_id}/hourly-rate", tags=["Customers"]) async def update_customer_hourly_rate(customer_id: int, hourly_rate: float, user_id: Optional[int] = None): """ Opdater timepris for en kunde. Args: customer_id: Kunde ID hourly_rate: Ny timepris i DKK (f.eks. 850.00) """ try: from decimal import Decimal # Validate rate if hourly_rate < 0: raise HTTPException(status_code=400, detail="Hourly rate must be positive") rate_decimal = Decimal(str(hourly_rate)) # Update customer hourly rate execute_update( "UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (rate_decimal, customer_id) ) # Audit log audit.log_event( entity_type="customer", entity_id=str(customer_id), event_type="hourly_rate_updated", details={"hourly_rate": float(hourly_rate)}, user_id=user_id ) # Return updated customer customer = execute_query_single( "SELECT id, name, hourly_rate FROM tmodule_customers WHERE id = %s", (customer_id,)) if not customer: raise HTTPException(status_code=404, detail="Customer not found") return { "customer_id": customer_id, "name": customer['name'], "hourly_rate": float(customer['hourly_rate']) if customer['hourly_rate'] else None, "updated": True } except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating hourly rate: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/customers/bulk-update-rate", tags=["Customers"]) async def bulk_update_customer_hourly_rates( request: TModuleBulkRateUpdate, user_id: Optional[int] = None ): """ Opdater timepris for flere kunder på én gang. Request body: customer_ids: Liste af kunde-ID'er hourly_rate: Ny timepris i DKK (f.eks. 850.00) Returns: Antal opdaterede kunder """ try: # Validate inputs (already validated by Pydantic) customer_ids = request.customer_ids rate_decimal = request.hourly_rate # Update all selected customers execute_update( "UPDATE tmodule_customers SET hourly_rate = %s, updated_at = CURRENT_TIMESTAMP WHERE id = ANY(%s)", (rate_decimal, customer_ids) ) # Count affected rows updated_count = len(customer_ids) # Audit log for each customer for customer_id in customer_ids: audit.log_event( entity_type="customer", entity_id=str(customer_id), event_type="hourly_rate_updated", details={"hourly_rate": float(rate_decimal), "bulk_update": True}, user_id=user_id ) logger.info(f"✅ Bulk updated hourly rate for {updated_count} customers to {rate_decimal} DKK") return { "updated": updated_count, "hourly_rate": float(rate_decimal) } except HTTPException: raise except Exception as e: logger.error(f"❌ Error in bulk hourly rate update: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.patch("/customers/{customer_id}/time-card", tags=["Customers"]) async def toggle_customer_time_card(customer_id: int, enabled: bool, user_id: Optional[int] = None): """ Skift klippekort-status for kunde. Klippekort-kunder faktureres eksternt og skal kunne skjules i godkendelsesflow. """ try: # Update customer time card flag execute_update( "UPDATE tmodule_customers SET uses_time_card = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s", (enabled, customer_id) ) # Audit log audit.log_event( entity_type="customer", entity_id=str(customer_id), event_type="time_card_toggled", details={"enabled": enabled}, user_id=user_id ) # Return updated customer customer = execute_query_single( "SELECT * FROM tmodule_customers WHERE id = %s", (customer_id,)) if not customer: raise HTTPException(status_code=404, detail="Customer not found") return { "customer_id": customer_id, "name": customer['name'], "uses_time_card": customer['uses_time_card'], "updated": True } except HTTPException: raise except Exception as e: logger.error(f"❌ Error toggling time card: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/customers", tags=["Customers"]) async def list_customers( include_time_card: bool = True, only_with_entries: bool = False ): """ List kunder med filtrering. Query params: - include_time_card: Inkluder klippekort-kunder (default: true) - only_with_entries: Kun kunder med pending tidsregistreringer (default: false) """ try: if only_with_entries: # Use view that includes entry counts query = """ SELECT customer_id, customer_name, customer_vtiger_id, uses_time_card, total_entries, pending_count FROM tmodule_approval_stats WHERE total_entries > 0 """ if not include_time_card: query += " AND uses_time_card = false" query += " ORDER BY customer_name" customers = execute_query_single(query) else: # Simple customer list query = "SELECT * FROM tmodule_customers" if not include_time_card: query += " WHERE uses_time_card = false" query += " ORDER BY name" customers = execute_query(query) return {"customers": customers, "total": len(customers)} except Exception as e: logger.error(f"❌ Error listing customers: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/customers/{customer_id}/times", tags=["Customers"]) async def get_customer_time_entries(customer_id: int, status: Optional[str] = None): """ Hent alle tidsregistreringer for en kunde. Path params: - customer_id: Kunde ID Query params: - status: Filtrer på status (pending, approved, rejected, billed) """ try: query = """ SELECT t.*, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, c.vtiger_id AS case_vtiger_id, c.description AS case_description, c.priority AS case_priority, c.module_type AS case_type, cust.name AS customer_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.customer_id = %s AND t.billed_via_thehub_id IS NULL AND (t.vtiger_data->>'cf_timelog_invoiced' IS NULL OR t.vtiger_data->>'cf_timelog_invoiced' = '0' OR t.vtiger_data->>'cf_timelog_invoiced' = '') 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' = '') """ params = [customer_id] if status: query += " AND t.status = %s" params.append(status) query += " ORDER BY t.worked_date DESC, t.id DESC" times = execute_query(query, tuple(params)) # 🔗 Combine with Hub Worklogs (tticket_worklog) # Only if we can find the linked Hub Customer ID try: cust_res = execute_query_single( "SELECT hub_customer_id, name FROM tmodule_customers WHERE id = %s", (customer_id,) ) if cust_res and cust_res.get('hub_customer_id'): hub_id = cust_res['hub_customer_id'] hub_name = cust_res['name'] # Fetch worklogs w_query = """ SELECT (w.id * -1) as id, w.work_date as worked_date, w.hours as original_hours, w.description, CASE WHEN w.status = 'draft' THEN 'pending' ELSE w.status END as status, -- Ticket info as Case info t.subject as case_title, t.ticket_number as case_vtiger_id, t.description as case_description, CASE WHEN t.priority = 'urgent' THEN 'Høj' ELSE 'Normal' END as case_priority, w.work_type as case_type, -- Customer info %s as customer_name, %s as customer_id, -- Logic CASE WHEN w.billing_method IN ('internal', 'warranty') THEN false ELSE true END as billable, false as is_travel, -- Extra context for frontend flags if needed w.billing_method as _billing_method FROM tticket_worklog w JOIN tticket_tickets t ON w.ticket_id = t.id WHERE t.customer_id = %s """ w_params = [hub_name, customer_id, hub_id] if status: if status == 'pending': w_query += " AND w.status = 'draft'" else: w_query += " AND w.status = %s" w_params.append(status) w_times = execute_query(w_query, tuple(w_params)) if w_times: times.extend(w_times) # Re-sort combined list times.sort(key=lambda x: (x.get('worked_date') or '', x.get('id')), reverse=True) except Exception as e: logger.error(f"⚠️ Failed to fetch hub worklogs for wizard: {e}") # Continue with just tmodule times return {"times": times, "total": len(times)} except Exception as e: logger.error(f"Error fetching customer time entries: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/times", tags=["Times"]) async def list_time_entries( limit: int = 100, offset: int = 0, status: Optional[str] = None, customer_id: Optional[int] = None, user_name: Optional[str] = None, search: Optional[str] = None ): """ Hent liste af tidsregistreringer med filtre. """ try: query = """ SELECT t.*, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, c.priority AS case_priority, cust.name AS customer_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE 1=1 """ params = [] if status: query += " AND t.status = %s" params.append(status) if customer_id: query += " AND t.customer_id = %s" params.append(customer_id) if user_name: query += " AND t.user_name ILIKE %s" params.append(f"%{user_name}%") if search: query += """ AND ( t.description ILIKE %s OR cust.name ILIKE %s OR c.title ILIKE %s )""" wildcard = f"%{search}%" params.extend([wildcard, wildcard, wildcard]) query += " ORDER BY t.worked_date DESC, t.id DESC LIMIT %s OFFSET %s" params.extend([limit, offset]) times = execute_query(query, tuple(params)) return {"times": times} except Exception as e: logger.error(f"Error listing times: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/times/{time_id}", tags=["Times"]) async def get_time_entry(time_id: int): """ Hent en specifik tidsregistrering. Path params: - time_id: Time entry ID """ try: query = """ SELECT t.*, COALESCE(c.vtiger_data->>'case_no', c.title)::VARCHAR(500) AS case_title, c.vtiger_id AS case_vtiger_id, c.description AS case_description, cust.name AS customer_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN tmodule_customers cust ON t.customer_id = cust.id WHERE t.id = %s """ time_entry = execute_query_single(query, (time_id,)) if not time_entry: raise HTTPException(status_code=404, detail="Time entry not found") return time_entry except HTTPException: raise except Exception as e: logger.error(f"Error fetching time entry {time_id}: {e}") raise HTTPException(status_code=500, detail=str(e)) except Exception as e: logger.error(f"❌ Error getting customer time entries: {e}") raise HTTPException(status_code=500, detail=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) 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)) # ============================================================================ # INTERNAL / HUB INTEGRATION ENDPOINTS # ============================================================================ @router.get("/entries/sag/{sag_id}", tags=["Internal"]) async def get_time_entries_for_sag(sag_id: int): """Get time entries linked to a Hub Sag (Case).""" try: query = """ SELECT * FROM tmodule_times WHERE sag_id = %s ORDER BY worked_date DESC, created_at DESC """ results = execute_query(query, (sag_id,)) return results except Exception as e: logger.error(f"❌ Error fetching time entries for sag {sag_id}: {e}") raise HTTPException(status_code=500, detail="Failed to fetch time entries") @router.post("/entries/internal", tags=["Internal"]) async def create_internal_time_entry( entry: Dict[str, Any] = Body(...), current_user: Optional[dict] = Depends(get_optional_user) ): """ Create a time entry manually (Internal/Hub). Requires: sag_id, original_hours Optional: customer_id (auto-resolved from sag if missing), solution_id """ try: sag_id = entry.get("sag_id") solution_id = entry.get("solution_id") customer_id = entry.get("customer_id") description = entry.get("description") hours = entry.get("original_hours") worked_date = entry.get("worked_date") or datetime.now().date() default_user_name = ( (current_user or {}).get("username") or (current_user or {}).get("full_name") or "Hub User" ) user_name = entry.get("user_name") or default_user_name prepaid_card_id = entry.get("prepaid_card_id") fixed_price_agreement_id = entry.get("fixed_price_agreement_id") work_type = entry.get("work_type", "support") is_internal = entry.get("is_internal", False) if not sag_id or not hours: raise HTTPException(status_code=400, detail="sag_id and original_hours required") hours_decimal = float(hours) # Auto-resolve customer if missing if not customer_id: # Get Hub Customer ID from Sag (fallback to linked customers) sag = execute_query_single("SELECT customer_id FROM sag_sager WHERE id = %s", (sag_id,)) if not sag: raise HTTPException(status_code=404, detail="Sag not found") hub_customer_id = sag.get("customer_id") if not hub_customer_id: linked_customer = execute_query_single( "SELECT customer_id FROM sag_kunder WHERE sag_id = %s AND deleted_at IS NULL ORDER BY id ASC LIMIT 1", (sag_id,) ) hub_customer_id = linked_customer.get("customer_id") if linked_customer else None if hub_customer_id: # Find matching tmodule_customer tm_cust = execute_query_single("SELECT id FROM tmodule_customers WHERE hub_customer_id = %s", (hub_customer_id,)) if tm_cust: customer_id = tm_cust["id"] else: raise HTTPException( status_code=400, detail=f"Customer (Hub ID: {hub_customer_id}) not found in Time Module. Please sync customers." ) else: raise HTTPException(status_code=400, detail="Sag has no customer linked") # Handle Prepaid Card billing_method = entry.get('billing_method', 'invoice') status = 'pending' billable = True if is_internal: billing_method = 'internal' billable = False elif prepaid_card_id: # Verify card card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,)) if not card: raise HTTPException(status_code=404, detail="Prepaid card not found") rounding_minutes = int(card.get('rounding_minutes') or 0) rounded_hours = hours_decimal rounded_to = None if rounding_minutes > 0: from decimal import Decimal, ROUND_CEILING interval = Decimal(rounding_minutes) / Decimal(60) rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval) rounded_to = float(interval) if float(card['remaining_hours']) < rounded_hours: # Optional: Allow overdraft? For now, block. raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})") # Deduct hours (remaining_hours is generated; update used_hours instead) new_used = float(card['used_hours']) + rounded_hours execute_update( "UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s", (new_used, prepaid_card_id) ) updated_card = execute_query_single( "SELECT remaining_hours FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,) ) if updated_card and float(updated_card['remaining_hours']) <= 0: execute_update( "UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s", (prepaid_card_id,) ) if rounded_to: entry['approved_hours'] = rounded_hours entry['rounded_to'] = rounded_to logger.info(f"💳 Deducted {rounded_hours} hours from prepaid card {prepaid_card_id}") billing_method = 'prepaid' status = 'billed' # Mark as processed/billed so it skips invoicing elif fixed_price_agreement_id: # Verify agreement agreement = execute_query_single(""" SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours FROM customer_fixed_price_agreements a LEFT JOIN fixed_price_billing_periods bp ON ( a.id = bp.agreement_id AND bp.period_start <= CURRENT_DATE AND bp.period_end >= CURRENT_DATE ) WHERE a.id = %s AND a.status = 'active' """, (fixed_price_agreement_id,)) if not agreement: raise HTTPException(status_code=404, detail="Fixed-price agreement not found or inactive") if not agreement.get('period_id'): # Auto-create billing period for current month today = datetime.now().date() period_start = date(today.year, today.month, 1) last_day = monthrange(today.year, today.month)[1] period_end = date(today.year, today.month, last_day) # Full month amount (auto-created periods are always full months) base_amount = float(agreement['monthly_hours']) * float(agreement['hourly_rate']) included_hours = float(agreement['monthly_hours']) execute_query(""" INSERT INTO fixed_price_billing_periods ( agreement_id, period_start, period_end, included_hours, base_amount, status ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING * """, ( fixed_price_agreement_id, period_start, period_end, included_hours, base_amount, 'active' )) # Re-query to get the new period agreement = execute_query_single(""" SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours FROM customer_fixed_price_agreements a LEFT JOIN fixed_price_billing_periods bp ON ( a.id = bp.agreement_id AND bp.period_start <= CURRENT_DATE AND bp.period_end >= CURRENT_DATE ) WHERE a.id = %s AND a.status = 'active' """, (fixed_price_agreement_id,)) logger.info(f"📅 Auto-created billing period for agreement {fixed_price_agreement_id}: {period_start} to {period_end}") # Apply rounding rounding_minutes = int(agreement.get('rounding_minutes') or 0) rounded_hours = hours_decimal rounded_to = None if rounding_minutes > 0: from decimal import Decimal, ROUND_CEILING interval = Decimal(rounding_minutes) / Decimal(60) rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval) rounded_to = float(interval) # Update period used_hours new_used = float(agreement['used_hours'] or 0) + rounded_hours execute_update( "UPDATE fixed_price_billing_periods SET used_hours = %s WHERE id = %s", (new_used, agreement['period_id']) ) # Check if overtime if new_used > float(agreement['included_hours']): overtime = new_used - float(agreement['included_hours']) logger.warning(f"⚠️ Fixed-price agreement {fixed_price_agreement_id} has {overtime:.2f}h overtime") # Update period status to pending_approval if not already execute_update(""" UPDATE fixed_price_billing_periods SET status = 'pending_approval' WHERE id = %s AND status = 'active' """, (agreement['period_id'],)) if rounded_to: entry['approved_hours'] = rounded_hours entry['rounded_to'] = rounded_to logger.info(f"📋 Logged {rounded_hours} hours to fixed-price agreement {fixed_price_agreement_id}") billing_method = 'fixed_price' status = 'billed' # Tracked in agreement, not invoiced separately elif billing_method == 'internal' or billing_method == 'warranty': billable = False query = """ INSERT INTO tmodule_times ( sag_id, solution_id, customer_id, description, original_hours, worked_date, user_name, status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type, approved_hours, rounded_to ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s ) RETURNING * """ params = ( sag_id, solution_id, customer_id, description, hours, worked_date, user_name, status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type, entry.get('approved_hours'), entry.get('rounded_to') ) result = execute_query(query, params) if result: return result[0] raise HTTPException(status_code=500, detail="Failed to create entry") except HTTPException: raise except Exception as e: logger.error(f"❌ Error creating internal time entry: {e}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # SERVICE CONTRACT MIGRATION WIZARD ENDPOINTS # ============================================================================ @router.get("/service-contracts", response_model=List[Dict[str, Any]], tags=["Service Contracts"]) async def list_service_contracts(): """ Fetch active service contracts from vTiger for wizard dropdown Returns: List of contracts: id, contract_number, subject, account_id, account_name """ try: contracts = await ServiceContractWizardService.get_active_contracts() logger.info(f"✅ Retrieved {len(contracts)} service contracts") return contracts except Exception as e: logger.error(f"❌ Error fetching service contracts: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/service-contracts/wizard/load", response_model=ServiceContractWizardData, tags=["Service Contracts"]) async def load_contract_wizard_data( request_data: Dict[str, Any] = Body(...), dry_run: bool = Query(False) ): """ Load contract data for wizard: cases + timelogs + available klippekort Args: contract_id: vTiger service contract ID (from body) - required account_id: vTiger account ID (from body) - optional, will be looked up if empty dry_run: Preview mode (no changes) (from query param) Returns: Contract data with items to process """ try: contract_id = request_data.get('contract_id') account_id = request_data.get('account_id', '') if not contract_id: logger.error("❌ contract_id is required in request body") raise HTTPException(status_code=400, detail="contract_id is required") logger.info(f"📥 Loading contract data: {contract_id} (dry_run={dry_run})") contract_data = await ServiceContractWizardService.load_contract_detailed_data( contract_id, account_id ) if not contract_data: raise HTTPException(status_code=404, detail="Service contract not found or no cases/timelogs") return ServiceContractWizardData(**contract_data) except HTTPException: raise except Exception as e: logger.error(f"❌ Error loading contract data: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/service-contracts/wizard/archive-case", response_model=ServiceContractWizardAction, tags=["Service Contracts"]) async def archive_case_in_wizard( request_data: Dict[str, Any] = Body(...), dry_run: bool = Query(False) ): """ Archive a single case to tticket_archived_tickets Args: case_id: vTiger case ID (from body) case_data: Complete case data from vTiger (from body) contract_id: Service contract ID (for reference) (from body) dry_run: Preview mode (from query param) Returns: Action result with success/failure """ try: case_id = request_data.get('case_id') case_data = request_data.get('case_data', {}) contract_id = request_data.get('contract_id', '') if not case_id or not case_data: raise HTTPException(status_code=400, detail="case_id and case_data are required") logger.info(f"🔄 Archiving case {case_id} (dry_run={dry_run})") success, message, archived_id = ServiceContractWizardService.archive_case( case_data, contract_id, dry_run=dry_run ) action = ServiceContractWizardAction( type='archive', item_id=case_id, title=case_data.get('title', case_data.get('subject', 'Untitled')), success=success, message=message, dry_run=dry_run, result_id=archived_id ) return action except HTTPException: raise except Exception as e: logger.error(f"❌ Error archiving case: {e}") case_id = request_data.get('case_id', 'unknown') action = ServiceContractWizardAction( type='archive', item_id=case_id, title="Unknown", success=False, message=f"Error: {str(e)}", dry_run=dry_run, result_id=None ) return action @router.post("/service-contracts/wizard/transfer-timelog", response_model=ServiceContractWizardAction, tags=["Service Contracts"]) async def transfer_timelog_in_wizard( request: TimologTransferRequest ): """ Transfer timelog hours to customer's klippekort Args: request: TimologTransferRequest with timelog, card, customer IDs + dry_run flag Returns: Action result with success/failure """ try: logger.info(f"🔄 Transferring timelog {request.timelog_id} to card {request.card_id} (dry_run={request.dry_run})") # Fetch complete timelog data from vTiger vtiger_svc = get_vtiger_service() timelog = await vtiger_svc.query(f"SELECT * FROM Timelog WHERE id='{request.timelog_id}';") if not timelog: raise HTTPException(status_code=404, detail=f"Timelog {request.timelog_id} not found") timelog_data = timelog[0] success, message, transaction = ServiceContractWizardService.transfer_timelog_to_klippekort( timelog_data, request.card_id, request.customer_id, request.contract_id, dry_run=request.dry_run ) action = ServiceContractWizardAction( type='transfer', item_id=request.timelog_id, title=f"{timelog_data.get('hours', 0)}h - {timelog_data.get('description', '')}", success=success, message=message, dry_run=request.dry_run, result_id=transaction.get('id') if transaction else None ) return action except HTTPException: raise except Exception as e: logger.error(f"❌ Error transferring timelog: {e}") action = ServiceContractWizardAction( type='transfer', item_id=request.timelog_id, title="Unknown", success=False, message=f"Error: {str(e)}", dry_run=request.dry_run, result_id=None ) return action @router.get("/service-contracts/wizard/customer-cards/{customer_id}", response_model=List[Dict[str, Any]], tags=["Service Contracts"]) async def get_customer_klippekort_cards(customer_id: int): """ Get active klippekort cards for a customer (for wizard dropdown) Args: customer_id: Hub customer ID Returns: List of active prepaid cards """ try: cards = KlippekortService.get_active_cards_for_customer(customer_id) logger.info(f"✅ Retrieved {len(cards)} active cards for customer {customer_id}") return cards except Exception as e: logger.error(f"❌ Error fetching customer cards: {e}") raise HTTPException(status_code=500, detail=str(e))