bmc_hub/app/fixed_price/backend/router.py

770 lines
29 KiB
Python
Raw Permalink Normal View History

"""
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))