- Added FastAPI router for time tracking views including dashboard, wizard, and orders. - Created HTML templates for the time tracking wizard with responsive design and Bootstrap integration. - Developed SQL migration script for the time tracking module, including tables for customers, cases, time entries, orders, and audit logs. - Introduced a script to list all registered routes, focusing on time tracking routes. - Added test script to verify route registration and specifically check for time tracking routes.
404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""
|
||
Order Generation Service for Time Tracking Module
|
||
==================================================
|
||
|
||
Aggreger godkendte tidsregistreringer til customer orders.
|
||
Beregn totals, moms, og opret order lines.
|
||
"""
|
||
|
||
import logging
|
||
from typing import List, Optional
|
||
from decimal import Decimal
|
||
from datetime import date
|
||
|
||
from fastapi import HTTPException
|
||
from app.core.config import settings
|
||
from app.core.database import execute_query, execute_insert, execute_update
|
||
from app.timetracking.backend.models import (
|
||
TModuleOrder,
|
||
TModuleOrderWithLines,
|
||
TModuleOrderLine,
|
||
TModuleOrderCreate,
|
||
TModuleOrderLineCreate
|
||
)
|
||
from app.timetracking.backend.audit import audit
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class OrderService:
|
||
"""Service for generating orders from approved time entries"""
|
||
|
||
@staticmethod
|
||
def _get_hourly_rate(customer_id: int, hub_customer_id: Optional[int]) -> Decimal:
|
||
"""
|
||
Hent timepris for kunde.
|
||
|
||
Prioritet:
|
||
1. customer_id (tmodule_customers.hourly_rate)
|
||
2. hub_customer_id (customers.hourly_rate)
|
||
3. Default fra settings
|
||
"""
|
||
try:
|
||
# Check module customer
|
||
query = "SELECT hourly_rate FROM tmodule_customers WHERE id = %s"
|
||
result = execute_query(query, (customer_id,), fetchone=True)
|
||
|
||
if result and result.get('hourly_rate'):
|
||
rate = result['hourly_rate']
|
||
logger.info(f"✅ Using tmodule customer rate: {rate} DKK")
|
||
return Decimal(str(rate))
|
||
|
||
# Check Hub customer if linked
|
||
if hub_customer_id:
|
||
query = "SELECT hourly_rate FROM customers WHERE id = %s"
|
||
result = execute_query(query, (hub_customer_id,), fetchone=True)
|
||
|
||
if result and result.get('hourly_rate'):
|
||
rate = result['hourly_rate']
|
||
logger.info(f"✅ Using Hub customer rate: {rate} DKK")
|
||
return Decimal(str(rate))
|
||
|
||
# Fallback to default
|
||
default_rate = Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||
logger.warning(f"⚠️ No customer rate found, using default: {default_rate} DKK")
|
||
return default_rate
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Error getting hourly rate: {e}")
|
||
# Safe fallback
|
||
return Decimal(str(settings.TIMETRACKING_DEFAULT_HOURLY_RATE))
|
||
|
||
@staticmethod
|
||
def generate_order_for_customer(
|
||
customer_id: int,
|
||
user_id: Optional[int] = None
|
||
) -> TModuleOrderWithLines:
|
||
"""
|
||
Generer ordre for alle godkendte tider for en kunde.
|
||
|
||
Args:
|
||
customer_id: ID fra tmodule_customers
|
||
user_id: ID på brugeren der opretter
|
||
|
||
Returns:
|
||
Order med lines
|
||
"""
|
||
try:
|
||
# Hent customer info
|
||
customer = execute_query(
|
||
"SELECT * FROM tmodule_customers WHERE id = %s",
|
||
(customer_id,),
|
||
fetchone=True
|
||
)
|
||
|
||
if not customer:
|
||
raise HTTPException(status_code=404, detail="Customer not found")
|
||
|
||
# Hent godkendte tider for kunden
|
||
query = """
|
||
SELECT t.*, c.title as case_title
|
||
FROM tmodule_times t
|
||
JOIN tmodule_cases c ON t.case_id = c.id
|
||
WHERE t.customer_id = %s
|
||
AND t.status = 'approved'
|
||
AND t.billable = true
|
||
ORDER BY c.id, t.worked_date
|
||
"""
|
||
approved_times = execute_query(query, (customer_id,))
|
||
|
||
if not approved_times:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="No approved billable time entries found for customer"
|
||
)
|
||
|
||
logger.info(f"📊 Found {len(approved_times)} approved time entries")
|
||
|
||
# Get hourly rate
|
||
hourly_rate = OrderService._get_hourly_rate(
|
||
customer_id,
|
||
customer.get('hub_customer_id')
|
||
)
|
||
|
||
# Group by case
|
||
case_groups = {}
|
||
for time_entry in approved_times:
|
||
case_id = time_entry['case_id']
|
||
if case_id not in case_groups:
|
||
case_groups[case_id] = {
|
||
'case_title': time_entry['case_title'],
|
||
'entries': []
|
||
}
|
||
case_groups[case_id]['entries'].append(time_entry)
|
||
|
||
# Build order lines
|
||
order_lines = []
|
||
line_number = 1
|
||
total_hours = Decimal('0')
|
||
|
||
for case_id, group in case_groups.items():
|
||
# Sum hours for this case
|
||
case_hours = sum(
|
||
Decimal(str(entry['approved_hours']))
|
||
for entry in group['entries']
|
||
)
|
||
|
||
# Build description
|
||
entry_count = len(group['entries'])
|
||
description = f"{group['case_title']} ({entry_count} tidsregistreringer)"
|
||
|
||
# Calculate line total
|
||
line_total = case_hours * hourly_rate
|
||
|
||
# Collect time entry IDs
|
||
time_entry_ids = [entry['id'] for entry in group['entries']]
|
||
|
||
order_lines.append(TModuleOrderLineCreate(
|
||
line_number=line_number,
|
||
description=description,
|
||
quantity=case_hours,
|
||
unit_price=hourly_rate,
|
||
line_total=line_total,
|
||
case_id=case_id,
|
||
time_entry_ids=time_entry_ids
|
||
))
|
||
|
||
total_hours += case_hours
|
||
line_number += 1
|
||
|
||
# Calculate totals
|
||
subtotal = total_hours * hourly_rate
|
||
vat_rate = Decimal('25.00') # Danish VAT
|
||
vat_amount = (subtotal * vat_rate / Decimal('100')).quantize(Decimal('0.01'))
|
||
total_amount = subtotal + vat_amount
|
||
|
||
logger.info(f"💰 Order totals: {total_hours}h × {hourly_rate} = {subtotal} + {vat_amount} moms = {total_amount} DKK")
|
||
|
||
# Create order
|
||
order_id = execute_insert(
|
||
"""INSERT INTO tmodule_orders
|
||
(customer_id, hub_customer_id, order_date, total_hours, hourly_rate,
|
||
subtotal, vat_rate, vat_amount, total_amount, status, created_by)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'draft', %s)""",
|
||
(
|
||
customer_id,
|
||
customer.get('hub_customer_id'),
|
||
date.today(),
|
||
total_hours,
|
||
hourly_rate,
|
||
subtotal,
|
||
vat_rate,
|
||
vat_amount,
|
||
total_amount,
|
||
user_id
|
||
)
|
||
)
|
||
|
||
logger.info(f"✅ Created order {order_id}")
|
||
|
||
# Create order lines
|
||
created_lines = []
|
||
for line in order_lines:
|
||
line_id = execute_insert(
|
||
"""INSERT INTO tmodule_order_lines
|
||
(order_id, case_id, line_number, description, quantity, unit_price,
|
||
line_total, time_entry_ids)
|
||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)""",
|
||
(
|
||
order_id,
|
||
line.case_id,
|
||
line.line_number,
|
||
line.description,
|
||
line.quantity,
|
||
line.unit_price,
|
||
line.line_total,
|
||
line.time_entry_ids
|
||
)
|
||
)
|
||
created_lines.append(line_id)
|
||
|
||
logger.info(f"✅ Created {len(created_lines)} order lines")
|
||
|
||
# Update time entries to 'billed' status
|
||
time_entry_ids = [
|
||
entry_id
|
||
for line in order_lines
|
||
for entry_id in line.time_entry_ids
|
||
]
|
||
|
||
if time_entry_ids:
|
||
placeholders = ','.join(['%s'] * len(time_entry_ids))
|
||
execute_update(
|
||
f"""UPDATE tmodule_times
|
||
SET status = 'billed'
|
||
WHERE id IN ({placeholders})""",
|
||
time_entry_ids
|
||
)
|
||
logger.info(f"✅ Marked {len(time_entry_ids)} time entries as billed")
|
||
|
||
# Log order creation
|
||
audit.log_order_created(
|
||
order_id=order_id,
|
||
customer_id=customer_id,
|
||
total_hours=float(total_hours),
|
||
total_amount=float(total_amount),
|
||
line_count=len(order_lines),
|
||
user_id=user_id
|
||
)
|
||
|
||
# Return full order with lines
|
||
return OrderService.get_order_with_lines(order_id)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Error generating order: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@staticmethod
|
||
def get_order_with_lines(order_id: int) -> TModuleOrderWithLines:
|
||
"""Hent ordre med linjer"""
|
||
try:
|
||
# Get order
|
||
order_query = "SELECT * FROM tmodule_orders WHERE id = %s"
|
||
order = execute_query(order_query, (order_id,), fetchone=True)
|
||
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
||
# Get lines
|
||
lines_query = """
|
||
SELECT * FROM tmodule_order_lines
|
||
WHERE order_id = %s
|
||
ORDER BY line_number
|
||
"""
|
||
lines = execute_query(lines_query, (order_id,))
|
||
|
||
return TModuleOrderWithLines(
|
||
**order,
|
||
lines=[TModuleOrderLine(**line) for line in lines]
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Error getting order: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@staticmethod
|
||
def list_orders(
|
||
customer_id: Optional[int] = None,
|
||
status: Optional[str] = None,
|
||
limit: int = 100
|
||
) -> List[TModuleOrder]:
|
||
"""List orders med filtrering"""
|
||
try:
|
||
conditions = []
|
||
params = []
|
||
|
||
if customer_id:
|
||
conditions.append("customer_id = %s")
|
||
params.append(customer_id)
|
||
|
||
if status:
|
||
conditions.append("status = %s")
|
||
params.append(status)
|
||
|
||
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
|
||
|
||
query = f"""
|
||
SELECT * FROM tmodule_orders
|
||
{where_clause}
|
||
ORDER BY order_date DESC, id DESC
|
||
LIMIT %s
|
||
"""
|
||
params.append(limit)
|
||
|
||
orders = execute_query(query, params if params else None)
|
||
|
||
return [TModuleOrder(**order) for order in orders]
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Error listing orders: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
@staticmethod
|
||
def cancel_order(
|
||
order_id: int,
|
||
reason: Optional[str] = None,
|
||
user_id: Optional[int] = None
|
||
) -> TModuleOrder:
|
||
"""Annuller en ordre"""
|
||
try:
|
||
# Check order exists and is not exported
|
||
order = execute_query(
|
||
"SELECT * FROM tmodule_orders WHERE id = %s",
|
||
(order_id,),
|
||
fetchone=True
|
||
)
|
||
|
||
if not order:
|
||
raise HTTPException(status_code=404, detail="Order not found")
|
||
|
||
if order['status'] == 'cancelled':
|
||
raise HTTPException(status_code=400, detail="Order already cancelled")
|
||
|
||
if order['status'] == 'exported':
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Cannot cancel exported order"
|
||
)
|
||
|
||
# Update status
|
||
execute_update(
|
||
"UPDATE tmodule_orders SET status = 'cancelled', notes = %s WHERE id = %s",
|
||
(reason, order_id)
|
||
)
|
||
|
||
# Reset time entries back to approved
|
||
lines = execute_query(
|
||
"SELECT time_entry_ids FROM tmodule_order_lines WHERE order_id = %s",
|
||
(order_id,)
|
||
)
|
||
|
||
all_time_ids = []
|
||
for line in lines:
|
||
if line.get('time_entry_ids'):
|
||
all_time_ids.extend(line['time_entry_ids'])
|
||
|
||
if all_time_ids:
|
||
placeholders = ','.join(['%s'] * len(all_time_ids))
|
||
execute_update(
|
||
f"UPDATE tmodule_times SET status = 'approved' WHERE id IN ({placeholders})",
|
||
all_time_ids
|
||
)
|
||
|
||
# Log cancellation
|
||
audit.log_order_cancelled(
|
||
order_id=order_id,
|
||
reason=reason,
|
||
user_id=user_id
|
||
)
|
||
|
||
logger.info(f"❌ Cancelled order {order_id}")
|
||
|
||
# Return updated order
|
||
updated = execute_query(
|
||
"SELECT * FROM tmodule_orders WHERE id = %s",
|
||
(order_id,),
|
||
fetchone=True
|
||
)
|
||
|
||
return TModuleOrder(**updated)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"❌ Error cancelling order: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# Singleton instance
|
||
order_service = OrderService()
|