""" Fixed-Price Agreement Router CRUD operations, billing period management, and reporting """ from fastapi import APIRouter, HTTPException, Response from fastapi.responses import StreamingResponse from app.core.database import execute_query from app.fixed_price.backend.models import ( FixedPriceAgreement, FixedPriceAgreementCreate, FixedPriceAgreementUpdate, CancellationRequest, BillingPeriod, BillingPeriodCreate, BillingPeriodApproval, ) from typing import List, Optional, Dict, Any from datetime import date, datetime, timedelta from decimal import Decimal, ROUND_CEILING from calendar import monthrange import logging import csv from io import StringIO logger = logging.getLogger(__name__) router = APIRouter() def _apply_rounding(hours: Decimal, rounding_minutes: int) -> Decimal: """Apply rounding to hours based on interval (same as prepaid cards)""" if rounding_minutes <= 0: return hours interval = Decimal(rounding_minutes) / Decimal(60) rounded = (hours / interval).to_integral_value(ROUND_CEILING) * interval return rounded def _last_day_of_month(dt: date) -> date: """Get last day of month for given date""" last_day = monthrange(dt.year, dt.month)[1] return date(dt.year, dt.month, last_day) def _calculate_prorated_amount(monthly_hours: float, hourly_rate: float, period_start: date, period_end: date) -> float: """ Calculate pro-rated amount based on actual days in period. If period is a full calendar month, returns full monthly amount. Otherwise, calculates daily rate and multiplies by days in period. """ # Full month amount monthly_amount = monthly_hours * hourly_rate # Check if period is a full month (starts on 1st and ends on last day) last_day_of_month = monthrange(period_start.year, period_start.month)[1] if period_start.day == 1 and period_end.day == last_day_of_month and period_start.month == period_end.month: return monthly_amount # Calculate pro-rated amount for partial month days_in_month = monthrange(period_start.year, period_start.month)[1] days_in_period = (period_end - period_start).days + 1 # +1 to include both start and end daily_rate = monthly_amount / days_in_month prorated_amount = daily_rate * days_in_period return prorated_amount # ============================================================================ # CRUD Operations # ============================================================================ @router.get("/fixed-price-agreements", response_model=List[Dict[str, Any]]) async def get_agreements( customer_id: Optional[int] = None, status: Optional[str] = None, include_current_period: bool = True ): """ Get all fixed-price agreements with optional filters """ try: filters = [] params = [] if customer_id: filters.append("customer_id = %s") params.append(customer_id) if status: filters.append("status = %s") params.append(status) where_clause = "WHERE " + " AND ".join(filters) if filters else "" agreements = execute_query(f""" SELECT * FROM customer_fixed_price_agreements {where_clause} ORDER BY created_at DESC """, params if params else None) # Enrich with current period info if include_current_period and agreements: for agr in agreements: period = execute_query(""" SELECT used_hours, remaining_hours, overtime_hours, status FROM fixed_price_billing_periods WHERE agreement_id = %s AND period_start <= CURRENT_DATE AND period_end >= CURRENT_DATE ORDER BY period_start DESC LIMIT 1 """, (agr['id'],)) if period and len(period) > 0: agr['current_period'] = period[0] agr['remaining_hours_this_month'] = float(period[0]['remaining_hours']) else: agr['current_period'] = None agr['remaining_hours_this_month'] = float(agr['monthly_hours']) return agreements or [] except Exception as e: logger.error(f"❌ Error fetching agreements: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any]) async def get_agreement(agreement_id: int): """ Get single agreement with full details including periods and timelogs """ try: agreements = execute_query(""" SELECT * FROM customer_fixed_price_agreements WHERE id = %s """, (agreement_id,)) if not agreements or len(agreements) == 0: raise HTTPException(status_code=404, detail="Agreement not found") agreement = agreements[0] # Get billing periods periods = execute_query(""" SELECT * FROM fixed_price_billing_periods WHERE agreement_id = %s ORDER BY period_start DESC """, (agreement_id,)) agreement['billing_periods'] = periods or [] # Get timelogs (similar to prepaid card detail) sag_logs = execute_query(""" SELECT t.id, t.worked_date, t.original_hours as actual_hours, t.approved_hours as rounded_hours, t.description, t.sag_id as source_id, 'sag' as source, s.titel as source_title FROM tmodule_times t LEFT JOIN tmodule_sag s ON t.sag_id = s.id WHERE t.fixed_price_agreement_id = %s ORDER BY t.worked_date DESC """, (agreement_id,)) ticket_logs = execute_query(""" SELECT w.id, w.created_at::date as worked_date, w.hours as actual_hours, w.rounded_hours, w.description, w.ticket_id as source_id, 'ticket' as source, t.ticket_number, t.subject as source_title FROM tticket_worklog w LEFT JOIN tticket_tickets t ON w.ticket_id = t.id WHERE w.fixed_price_agreement_id = %s ORDER BY w.created_at DESC """, (agreement_id,)) # Combine and sort timelogs timelogs = [] for log in (sag_logs or []): timelogs.append(log) for log in (ticket_logs or []): timelogs.append(log) timelogs.sort(key=lambda x: x['worked_date'], reverse=True) agreement['timelogs'] = timelogs return agreement except HTTPException: raise except Exception as e: logger.error(f"❌ Error fetching agreement {agreement_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/fixed-price-agreements", response_model=Dict[str, Any]) async def create_agreement(data: FixedPriceAgreementCreate): """ Create new fixed-price agreement and initialize first billing period """ try: # Validate rounding if data.rounding_minutes not in (0, 15, 30, 60): raise HTTPException(status_code=400, detail="Invalid rounding_minutes") # Insert agreement from app.core.database import get_db_connection, release_db_connection from psycopg2.extras import RealDictCursor conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: # Create agreement cursor.execute(""" INSERT INTO customer_fixed_price_agreements ( customer_id, customer_name, subscription_id, monthly_hours, hourly_rate, overtime_rate, internal_cost_rate, rounding_minutes, start_date, binding_months, end_date, notice_period_days, auto_renew, economic_product_number, economic_overtime_product_number, notes ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """, ( data.customer_id, data.customer_name, data.subscription_id, data.monthly_hours, data.hourly_rate, data.overtime_rate, data.internal_cost_rate, data.rounding_minutes, data.start_date, data.binding_months, data.end_date, data.notice_period_days, data.auto_renew, data.economic_product_number, data.economic_overtime_product_number, data.notes )) agreement = cursor.fetchone() # Create first billing period period_start = data.start_date period_end = _last_day_of_month(period_start) # Calculate pro-rated amount for first period base_amount = _calculate_prorated_amount( data.monthly_hours, data.hourly_rate, period_start, period_end ) # Pro-rate included hours as well for partial months days_in_month = monthrange(period_start.year, period_start.month)[1] days_in_period = (period_end - period_start).days + 1 last_day_of_month = monthrange(period_start.year, period_start.month)[1] if period_start.day == 1 and period_end.day == last_day_of_month: # Full month included_hours = data.monthly_hours else: # Pro-rate hours for partial month included_hours = (data.monthly_hours / days_in_month) * days_in_period cursor.execute(""" INSERT INTO fixed_price_billing_periods ( agreement_id, period_start, period_end, included_hours, base_amount ) VALUES (%s, %s, %s, %s, %s) RETURNING * """, ( agreement['id'], period_start, period_end, included_hours, base_amount )) first_period = cursor.fetchone() conn.commit() logger.info(f"✅ Created fixed-price agreement {agreement['agreement_number']} for customer {data.customer_id}") agreement['first_period'] = first_period return agreement finally: release_db_connection(conn) except HTTPException: raise except Exception as e: logger.error(f"❌ Error creating agreement: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.patch("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any]) async def update_agreement(agreement_id: int, data: FixedPriceAgreementUpdate): """ Update agreement terms (does not affect existing periods) """ try: # Build UPDATE query dynamically updates = [] params = [] for field, value in data.model_dump(exclude_unset=True).items(): if value is not None: updates.append(f"{field} = %s") params.append(value) if not updates: raise HTTPException(status_code=400, detail="No fields to update") params.append(agreement_id) result = execute_query(f""" UPDATE customer_fixed_price_agreements SET {", ".join(updates)} WHERE id = %s RETURNING * """, params) if not result or len(result) == 0: raise HTTPException(status_code=404, detail="Agreement not found") logger.info(f"✅ Updated fixed-price agreement {agreement_id}") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating agreement {agreement_id}: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put("/fixed-price-agreements/{agreement_id}/status") async def update_status(agreement_id: int, status: str): """ Update agreement status (active, suspended, etc.) """ try: valid_statuses = ['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation'] if status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of {valid_statuses}") result = execute_query(""" UPDATE customer_fixed_price_agreements SET status = %s WHERE id = %s RETURNING * """, (status, agreement_id)) if not result or len(result) == 0: raise HTTPException(status_code=404, detail="Agreement not found") logger.info(f"✅ Agreement {agreement_id} status → {status}") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating status: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.post("/fixed-price-agreements/{agreement_id}/cancel") async def cancel_agreement(agreement_id: int, request: CancellationRequest): """ Request cancellation with binding period validation """ try: agreements = execute_query(""" SELECT * FROM customer_fixed_price_agreements WHERE id = %s """, (agreement_id,)) if not agreements or len(agreements) == 0: raise HTTPException(status_code=404, detail="Agreement not found") agreement = agreements[0] today = date.today() binding_end = agreement['binding_end_date'] # Check binding period if binding_end and today < binding_end and not request.force: raise HTTPException( status_code=400, detail=f"Aftale er bundet til {binding_end}. Kontakt administrator for tvungen opsigelse." ) # Calculate effective date effective_date = request.effective_date or (today + timedelta(days=agreement['notice_period_days'])) # Update agreement result = execute_query(""" UPDATE customer_fixed_price_agreements SET status = 'pending_cancellation', cancellation_requested_date = %s, cancellation_effective_date = %s, cancellation_reason = %s WHERE id = %s RETURNING * """, (today, effective_date, request.reason, agreement_id)) logger.info(f"⚠️ Agreement {agreement_id} cancellation requested, effective {effective_date}") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error cancelling agreement: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Billing Period Management # ============================================================================ @router.get("/fixed-price-agreements/{agreement_id}/periods", response_model=List[Dict[str, Any]]) async def get_periods(agreement_id: int): """Get all billing periods for agreement""" try: periods = execute_query(""" SELECT * FROM fixed_price_billing_periods WHERE agreement_id = %s ORDER BY period_start DESC """, (agreement_id,)) return periods or [] except Exception as e: logger.error(f"❌ Error fetching periods: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.put("/fixed-price-agreements/{agreement_id}/periods/{period_id}/approve-overtime") async def approve_overtime(agreement_id: int, period_id: int, approval: BillingPeriodApproval): """ Approve overtime hours for billing """ try: # Calculate overtime amount agreements = execute_query(""" SELECT overtime_rate FROM customer_fixed_price_agreements WHERE id = %s """, (agreement_id,)) if not agreements or len(agreements) == 0: raise HTTPException(status_code=404, detail="Agreement not found") overtime_rate = agreements[0]['overtime_rate'] overtime_amount = approval.overtime_hours * Decimal(str(overtime_rate)) # Update period result = execute_query(""" UPDATE fixed_price_billing_periods SET overtime_amount = %s, overtime_approved = %s, status = CASE WHEN %s THEN 'ready_to_bill' ELSE status END WHERE id = %s AND agreement_id = %s RETURNING * """, (overtime_amount, approval.approved, approval.approved, period_id, agreement_id)) if not result or len(result) == 0: raise HTTPException(status_code=404, detail="Period not found") logger.info(f"✅ Overtime approved for period {period_id}: {approval.overtime_hours}t = {overtime_amount} DKK") return result[0] except HTTPException: raise except Exception as e: logger.error(f"❌ Error approving overtime: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Reporting & Analytics # ============================================================================ @router.get("/fixed-price-agreements/stats/summary") async def get_stats_summary(): """ Overall fixed-price system statistics """ try: result = execute_query(""" SELECT COUNT(DISTINCT id) as total_agreements, COUNT(DISTINCT id) FILTER (WHERE status = 'active') as active_agreements, COUNT(DISTINCT id) FILTER (WHERE status = 'cancelled') as cancelled_agreements, COUNT(DISTINCT id) FILTER (WHERE status = 'expired') as expired_agreements, SUM(monthly_hours) FILTER (WHERE status = 'active') as total_active_monthly_hours, AVG(hourly_rate) FILTER (WHERE status = 'active') as avg_hourly_rate, COUNT(DISTINCT customer_id) as unique_customers FROM customer_fixed_price_agreements """)[0] # Get revenue and profit from performance view performance = execute_query(""" SELECT COALESCE(SUM(total_revenue), 0) as total_revenue, COALESCE(SUM(total_internal_cost), 0) as total_cost, COALESCE(SUM(total_profit), 0) as total_profit, COALESCE(AVG(utilization_percent), 0) as avg_utilization FROM fixed_price_agreement_performance WHERE status = 'active' """)[0] return {**result, **performance} except Exception as e: logger.error(f"❌ Error fetching stats: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/reports/profitability") async def get_profitability_report( customer_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None ): """ Detailed profitability analysis with filters """ try: filters = [] params = [] if customer_id: filters.append("a.customer_id = %s") params.append(customer_id) if start_date: filters.append("bp.period_start >= %s") params.append(start_date) if end_date: filters.append("bp.period_end <= %s") params.append(end_date) where_clause = "WHERE " + " AND ".join(filters) if filters else "" return execute_query(f""" SELECT a.id, a.agreement_number, a.customer_name, a.monthly_hours, a.hourly_rate, a.internal_cost_rate, COUNT(bp.id) as period_count, COALESCE(SUM(bp.used_hours), 0) as total_hours, COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as overtime_hours, COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as base_revenue, COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as overtime_revenue, COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) as total_revenue, COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as internal_cost, COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) - COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as profit, CASE WHEN SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') > 0 THEN ROUND(( (SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') - SUM(bp.used_hours) * a.internal_cost_rate) / SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') * 100 )::numeric, 1) ELSE 0 END as profit_margin_percent FROM customer_fixed_price_agreements a LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id {where_clause} GROUP BY a.id ORDER BY profit DESC """, params if params else None) or [] except Exception as e: logger.error(f"❌ Error generating profitability report: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/reports/monthly-trends") async def get_monthly_trends(months: int = 12): """ Month-over-month trend analysis """ try: return execute_query(""" SELECT * FROM fixed_price_monthly_trends ORDER BY month DESC LIMIT %s """, (months,)) or [] except Exception as e: logger.error(f"❌ Error fetching trends: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/reports/customer-breakdown") async def get_customer_breakdown(): """ Per-customer revenue and profitability """ try: return execute_query(""" SELECT * FROM fixed_price_customer_summary ORDER BY total_revenue DESC """) or [] except Exception as e: logger.error(f"❌ Error fetching customer breakdown: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/reports/overtime-analysis") async def get_overtime_analysis(): """ Analyze overtime patterns to identify agreements with frequent overruns """ try: return execute_query(""" SELECT a.id, a.agreement_number, a.customer_name, a.monthly_hours, COUNT(bp.id) as total_periods, COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0) as periods_with_overtime, ROUND((COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0)::numeric / NULLIF(COUNT(bp.id), 0) * 100), 1) as overtime_frequency_percent, AVG(bp.overtime_hours) FILTER (WHERE bp.overtime_hours > 0) as avg_overtime_per_period, MAX(bp.overtime_hours) as max_overtime_single_period, COALESCE(SUM(bp.overtime_hours), 0) as total_overtime_hours, COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.overtime_approved), 0) as total_overtime_revenue FROM customer_fixed_price_agreements a LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id WHERE a.status = 'active' GROUP BY a.id HAVING COUNT(bp.id) > 0 ORDER BY overtime_frequency_percent DESC, total_overtime_hours DESC """) or [] except Exception as e: logger.error(f"❌ Error analyzing overtime: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/{agreement_id}/reports/period-detail") async def get_period_detail_report(agreement_id: int): """ Detailed period-by-period breakdown for single agreement """ try: return execute_query(""" SELECT bp.id, bp.period_start, bp.period_end, bp.included_hours, bp.used_hours, bp.overtime_hours, bp.base_amount, bp.overtime_amount, bp.overtime_approved, bp.status, bp.economic_invoice_number, -- Calculate profit for this period a.internal_cost_rate, bp.used_hours * a.internal_cost_rate as period_cost, (bp.base_amount + COALESCE(bp.overtime_amount, 0)) - (bp.used_hours * a.internal_cost_rate) as period_profit, -- Time entry breakdown (SELECT COUNT(*) FROM tmodule_times WHERE fixed_price_agreement_id = a.id AND worked_date BETWEEN bp.period_start AND bp.period_end) as sag_entries, (SELECT COUNT(*) FROM tticket_worklog WHERE fixed_price_agreement_id = a.id AND created_at::date BETWEEN bp.period_start AND bp.period_end) as ticket_entries FROM fixed_price_billing_periods bp JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id WHERE a.id = %s ORDER BY bp.period_start DESC """, (agreement_id,)) or [] except Exception as e: logger.error(f"❌ Error generating period detail: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e)) @router.get("/fixed-price-agreements/reports/export/csv") async def export_profitability_csv(): """ Export full profitability report as CSV """ try: data = execute_query(""" SELECT a.agreement_number, a.customer_name, a.status, a.monthly_hours, a.hourly_rate, a.overtime_rate, a.internal_cost_rate, a.start_date, perf.total_periods, perf.total_used_hours, perf.total_approved_overtime, perf.total_revenue, perf.total_internal_cost, perf.total_profit, perf.utilization_percent FROM customer_fixed_price_agreements a LEFT JOIN fixed_price_agreement_performance perf ON a.id = perf.id ORDER BY a.customer_name, a.agreement_number """) # Generate CSV output = StringIO() if data and len(data) > 0: writer = csv.DictWriter(output, fieldnames=data[0].keys()) writer.writeheader() writer.writerows(data) return Response( content=output.getvalue(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=fixed_price_profitability.csv"} ) except Exception as e: logger.error(f"❌ Error exporting CSV: {e}", exc_info=True) raise HTTPException(status_code=500, detail=str(e))