- Fix parameter handling in approve_time_entry endpoint - Change from query params to body Dict[str, Any] - Send all required fields to wizard.approve_time_entry() - Calculate rounded_to if auto-rounding enabled - Add approval_note, billable, is_travel fields - Add Dict, Any imports
1096 lines
38 KiB
Python
1096 lines
38 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 fastapi import APIRouter, HTTPException, Depends
|
|
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
|
|
)
|
|
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
|
|
|
|
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)
|
|
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.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],
|
|
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
|
|
|
|
# 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
|
|
|
|
# Opdater med hourly_rate hvis angivet
|
|
hourly_rate = request.get('hourly_rate')
|
|
if hourly_rate is not None:
|
|
execute_update(
|
|
"UPDATE tmodule_times SET hourly_rate = %s WHERE id = %s",
|
|
(Decimal(str(hourly_rate)), time_id)
|
|
)
|
|
|
|
# Godkend med alle felter
|
|
approval = TModuleTimeApproval(
|
|
time_id=time_id,
|
|
approved_hours=float(approved_hours),
|
|
rounded_to=float(rounded_to) if rounded_to else None,
|
|
approval_note=request.get('approval_note'),
|
|
billable=True, # Default til fakturerbar
|
|
is_travel=request.get('is_travel', False)
|
|
)
|
|
|
|
return wizard.approve_time_entry(approval, user_id=user_id)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Error approving entry: {e}")
|
|
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,
|
|
reason: Optional[str] = None,
|
|
user_id: Optional[int] = None
|
|
):
|
|
"""Afvis en tidsregistrering"""
|
|
try:
|
|
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))
|
|
|
|
|
|
@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.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,
|
|
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
|
|
"""
|
|
|
|
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))
|
|
|
|
return {"times": times, "total": len(times)}
|
|
|
|
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))
|