bmc_hub/app/timetracking/backend/wizard.py

769 lines
31 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, 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 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 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_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 tidsregistreringen
reason: Årsag til nulstilling
user_id: ID 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 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()