""" Order Generation Service for Time Tracking Module ================================================== Aggreger godkendte tidsregistreringer til customer orders. Beregn totals, moms, og opret order lines. """ from app.services.invoice_fee_service import invoice_fee_line 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 (Supports both vTiger Cases and Hub Sags) query = """ SELECT t.*, COALESCE( NULLIF(TRIM(c.title), ''), NULLIF(TRIM(c.vtiger_data->>'ticket_title'), ''), NULLIF(TRIM(s.titel), ''), 'Ingen titel' ) as case_title, COALESCE(c.vtiger_id, CONCAT('SAG-', s.id)) as case_vtiger_id, COALESCE( c.vtiger_data->>'case_no', c.vtiger_data->>'ticket_no', CONCAT('SAG-', s.id) ) as case_number, c.vtiger_data->>'ticket_title' as vtiger_title, COALESCE(c.priority, 'Normal') as case_priority, COALESCE(c.status, s.status) as case_status, COALESCE(c.module_type, 'sag') as case_type, CONCAT(cont.first_name, ' ', cont.last_name) as contact_name FROM tmodule_times t LEFT JOIN tmodule_cases c ON t.case_id = c.id LEFT JOIN sag_sager s ON t.sag_id = s.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 AND COALESCE(t.billing_method, 'invoice') NOT IN ('prepaid', 'prepaid_card', 'subscription', 'fixed_price', 'internal', 'non_billable', 'warranty') ORDER BY COALESCE(c.id, s.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: # Use case_number as unique key to prevent collision between Cases and Sags case_key = time_entry.get('case_number') if not case_key: # Fallback to ID if no number (should not happen with updated query) case_key = str(time_entry.get('case_id') or time_entry.get('sag_id')) if case_key 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_key] = { 'case_id': time_entry.get('case_id'), 'sag_id': time_entry.get('sag_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 'case_priority': time_entry.get('case_priority'), # Prioritet 'case_status': time_entry.get('case_status'), # Status 'case_type': time_entry.get('case_type'), # Brand/Type (module_type) '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_key]['entries'].append(time_entry) # Opdater til seneste dato if time_entry.get('worked_date'): if not case_groups[case_key]['worked_date'] or time_entry['worked_date'] > case_groups[case_key]['worked_date']: case_groups[case_key]['worked_date'] = time_entry['worked_date'] # Marker som rejse hvis nogen entry er rejse if time_entry.get('is_travel'): case_groups[case_key]['is_travel'] = True # Tilføj beskrivelse hvis den ikke er tom if time_entry.get('description') and time_entry['description'].strip(): case_groups[case_key]['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 ONLY from vtiger_data case_no field # NEVER parse from vtiger_id as they don't match (39x40366 vs CC2141) case_number = group.get('case_number') or "" # Brug case titel fra vTiger case_title = group.get('case_title') or "" # Hvis case titel er generisk placeholder, brug contact + dato (IKKE descriptions) # Descriptions bruges kun i ordre notes, ikke som titel if not case_title or case_title.strip() == "" or case_title.lower() in ['none', 'ingen beskrivelse', 'ingen titel', 'no title']: 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, titel, dato, type, prioritet description_parts = [] # Case nummer og titel if case_number: description_parts.append(f"{case_number} - {case_title}") else: description_parts.append(case_title) # Dato if group.get('worked_date'): date_str = group['worked_date'].strftime('%d.%m.%Y') description_parts.append(f"Dato: {date_str}") # Brand/Type (module_type) if group.get('case_type'): description_parts.append(f"Type: {group['case_type']}") # Prioritet if group.get('case_priority'): description_parts.append(f"Prioritet: {group['case_priority']}") # Join all parts with newlines for multi-line description description = "\n".join(description_parts) # 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=group.get('case_id'), sag_id=group.get('sag_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 # Fee is not counted as working hours. fee = invoice_fee_line(customer.get('hub_customer_id')) # Calculate totals subtotal = total_hours * hourly_rate if fee: subtotal += Decimal(str(fee['amount'])) 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, sag_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, %s) RETURNING id""", ( order_id, line.case_id, line.sag_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) if fee: execute_insert("""INSERT INTO tmodule_order_lines (order_id, line_number, description, quantity, unit_price, line_total, time_entry_ids, product_number, product_id, source_type) VALUES (%s, %s, %s, 1, %s, %s, %s, %s, %s, 'invoice_fee') RETURNING id""", (order_id, len(order_lines) + 1, fee['description'], fee['unit_price'], fee['amount'], [], fee['product_number'], fee['product_id'])) logger.info(f"✅ Created {len(created_lines)} order lines") # NOTE: Time entries remain 'approved' status until exported to e-conomic # They will be updated to 'billed' with billed_via_thehub_id in economic_export.py # 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()