770 lines
29 KiB
Python
770 lines
29 KiB
Python
|
|
"""
|
||
|
|
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))
|