bmc_hub/app/timetracking/backend/order_service.py
Christian a230071632 feat: Add customer time pricing management page with dynamic features
- Implemented a new HTML page for managing customer time pricing with Bootstrap styling.
- Added navigation and responsive design elements.
- Integrated JavaScript for loading customer data, editing rates, and handling modals for time entries and order creation.
- Included theme toggle functionality and statistics display for customer rates.
- Enhanced user experience with toast notifications for actions performed.

docs: Create e-conomic Write Mode guide

- Added comprehensive documentation for exporting approved time entries to e-conomic as draft orders.
- Detailed safety flags for write operations, including read-only and dry-run modes.
- Provided activation steps, error handling, and best practices for using the e-conomic integration.

migrations: Add user_company field to contacts and e-conomic customer number to customers

- Created migration to add user_company field to contacts for better organization tracking.
- Added e-conomic customer number field to tmodule_customers for invoice export synchronization.
2025-12-10 18:29:13 +01:00

454 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 med case og contact detaljer
query = """
SELECT t.*,
c.title as case_title,
c.vtiger_id as case_vtiger_id,
c.vtiger_data->>'ticket_title' as vtiger_title,
CONCAT(cont.first_name, ' ', cont.last_name) as contact_name
FROM tmodule_times t
JOIN tmodule_cases c ON t.case_id = c.id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_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 og gem ekstra metadata
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_vtiger_id': time_entry.get('case_vtiger_id'),
'contact_name': time_entry.get('contact_name'),
'entries': [],
'descriptions': [] # Samle alle beskrivelser
}
case_groups[case_id]['entries'].append(time_entry)
# Tilføj beskrivelse hvis den ikke er tom
if time_entry.get('description') and time_entry['description'].strip():
case_groups[case_id]['descriptions'].append(time_entry['description'].strip())
# 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']
)
# Extract case number from vtiger_id (format: 39x42930 -> CC2930)
case_number = ""
if group['case_vtiger_id']:
vtiger_parts = group['case_vtiger_id'].split('x')
if len(vtiger_parts) > 1:
# Take last 4 digits
case_number = f"CC{vtiger_parts[1][-4:]}"
# Brug tidsregistreringers beskrivelser som titel
# Tag første beskrivelse, eller alle hvis de er forskellige
case_title = "Ingen beskrivelse"
if group['descriptions']:
# Hvis alle beskrivelser er ens, brug kun én
unique_descriptions = list(set(group['descriptions']))
if len(unique_descriptions) == 1:
case_title = unique_descriptions[0]
else:
# Hvis forskellige, join dem
case_title = ", ".join(unique_descriptions[:3]) # Max 3 for ikke at blive for lang
if len(unique_descriptions) > 3:
case_title += "..."
# Build description med case nummer prefix
if case_number:
description = f"{case_number} - {case_title}"
else:
description = case_title
# 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 with customer name
order_query = """
SELECT o.*, c.name as customer_name
FROM tmodule_orders o
LEFT JOIN customers c ON o.customer_id = c.id
WHERE o.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 with additional context (contact, date)
lines_query = """
SELECT ol.*,
STRING_AGG(DISTINCT CONCAT(cont.first_name, ' ', cont.last_name), ', ') as case_contact,
MIN(t.worked_date) as time_date
FROM tmodule_order_lines ol
LEFT JOIN tmodule_times t ON t.id = ANY(ol.time_entry_ids)
LEFT JOIN tmodule_cases c ON c.id = ol.case_id
LEFT JOIN contacts cont ON cont.vtiger_id = c.vtiger_data->>'contact_id'
WHERE ol.order_id = %s
GROUP BY ol.id, ol.order_id, ol.case_id, ol.line_number, ol.description,
ol.quantity, ol.unit_price, ol.line_total, ol.time_entry_ids,
ol.product_number, ol.account_number, ol.created_at
ORDER BY ol.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("o.customer_id = %s")
params.append(customer_id)
if status:
conditions.append("o.status = %s")
params.append(status)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
query = f"""
SELECT o.*, c.name as customer_name
FROM tmodule_orders o
LEFT JOIN customers c ON o.customer_id = c.id
{where_clause}
ORDER BY o.order_date DESC, o.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()