- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview. - Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities. - Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management. - Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions. - Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths. - Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management. - Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql). - Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql). - Added a script to move time tracking section in detail view (move_time_section.py). - Created a test script for subscription processing (test_subscription_processing.py).
2218 lines
81 KiB
Python
2218 lines
81 KiB
Python
"""
|
|
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))
|