bmc_hub/app/timetracking/backend/wizard.py

331 lines
11 KiB
Python
Raw Normal View History

"""
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 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 tidsregistreringen
reason: Årsag til afvisning
user_id: ID 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()