331 lines
11 KiB
Python
331 lines
11 KiB
Python
|
|
"""
|
||
|
|
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()
|