""" 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, execute_query_single 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,)) if result and result[0].get('hourly_rate'): rate = result[0]['hourly_rate'] logger.info(f"✅ Using tmodule customer rate: {rate} DKK") return Decimal(str(rate)) # Hub customers table doesn't have hourly_rate column # Skip that check and go straight to default # 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_single( "SELECT * FROM tmodule_customers WHERE id = %s", (customer_id,)) if not customer: raise HTTPException(status_code=404, detail=f"Customer {customer_id} not found in tmodule_customers") # Debug log logger.info(f"✅ Found customer: {customer.get('name') if isinstance(customer, dict) else type(customer)}") # Hent godkendte tider for kunden med case og contact detaljer query = """ SELECT t.*, COALESCE(c.title, c.vtiger_data->>'ticket_title', 'Ingen titel') as case_title, c.vtiger_id as case_vtiger_id, COALESCE(c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no') as case_number, 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: # Prioriter case title fra vTiger (c.title), fallback til vtiger_data title case_title = time_entry.get('case_title') or time_entry.get('vtiger_title') case_groups[case_id] = { 'case_vtiger_id': time_entry.get('case_vtiger_id'), 'case_number': time_entry.get('case_number'), # Fra vtiger_data 'case_title': case_title, # Case titel fra vTiger 'contact_name': time_entry.get('contact_name'), 'worked_date': time_entry.get('worked_date'), # Seneste dato 'is_travel': False, # Marker hvis nogen entry er rejse 'entries': [], 'descriptions': [] # Samle alle beskrivelser } case_groups[case_id]['entries'].append(time_entry) # Opdater til seneste dato if time_entry.get('worked_date'): if not case_groups[case_id]['worked_date'] or time_entry['worked_date'] > case_groups[case_id]['worked_date']: case_groups[case_id]['worked_date'] = time_entry['worked_date'] # Marker som rejse hvis nogen entry er rejse if time_entry.get('is_travel'): case_groups[case_id]['is_travel'] = True # 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'] ) # Check if any entries were rounded original_hours = sum( Decimal(str(entry['original_hours'])) for entry in group['entries'] ) was_rounded = (case_hours != original_hours) # Extract case number from vtiger_id (format: 39x3942 -> CC3942) case_number = "" # Prioriter case_number fra vtiger_data (TKxxxxx format) if group.get('case_number'): case_number = group['case_number'] # Fallback til parse fra vtiger_id elif group['case_vtiger_id']: vtiger_parts = group['case_vtiger_id'].split('x') if len(vtiger_parts) > 1: # Use full case ID number case_number = f"CC{vtiger_parts[1]}" # Brug case titel fra vTiger (fallback til time entry beskrivelser) case_title = group.get('case_title') or "" # Hvis case titel er tom eller generisk, brug time entry beskrivelser if not case_title or case_title.strip() == "" or case_title.lower() in ['none', 'ingen beskrivelse', 'ingen titel', 'no title', '']: import re if group['descriptions']: # Fjern "X m" og "X t" mønstre (måneder/timer fra dato format) clean_descriptions = [] for desc in group['descriptions']: # Fjern mønstre som "12 m", "2 t", "/ 12 m /", etc. cleaned = re.sub(r'[/\s]*\d+\s*[mt]\s*[/\s]*', ' ', desc, flags=re.IGNORECASE).strip() # Fjern ekstra whitespace cleaned = re.sub(r'\s+', ' ', cleaned).strip() # Fjern tomme strenge eller bare punktum/komma/bindestreg if cleaned and cleaned not in [',', '.', '-', '/', '']: clean_descriptions.append(cleaned) if clean_descriptions: unique_descriptions = list(set(clean_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 += "..." # Hvis stadig ingen titel, byg en fra contact_name + dato if not case_title or case_title.strip() == "": if group.get('contact_name'): # Brug contact navn og dato date_str = group.get('worked_date', '').strftime('%d.%m.%Y') if group.get('worked_date') else '' case_title = f"Support til {group['contact_name']}" if date_str: case_title += f" - {date_str}" else: # Sidste fallback hvis intet andet case_title = "Support arbejde" # 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, case_contact=group.get('contact_name'), time_date=group.get('worked_date'), is_travel=group.get('is_travel', False) )) 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) RETURNING id""", ( 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, case_contact, time_date, is_travel) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id""", ( order_id, line.case_id, line.line_number, line.description, line.quantity, line.unit_price, line.line_total, line.time_entry_ids, line.case_contact, line.time_date, line.is_travel ) ) 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 tmodule_customers c ON o.customer_id = c.id WHERE o.id = %s """ order = execute_query_single(order_query, (order_id,)) 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, (SELECT COUNT(*) FROM tmodule_order_lines WHERE order_id = o.id) as line_count FROM tmodule_orders o LEFT JOIN tmodule_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_result = execute_query( "SELECT * FROM tmodule_orders WHERE id = %s", (order_id,)) if not order_result: raise HTTPException(status_code=404, detail="Order not found") order = order_result[0] if order['status'] == 'cancelled': raise HTTPException(status_code=400, detail="Order already cancelled") if order['status'] in ('exported', 'posted'): raise HTTPException( status_code=400, detail="Kan ikke annullere bogført ordre. Ordren er overført til e-conomic." ) # 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,)) return TModuleOrder(**updated[0]) 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()