bmc_hub/app/prepaid/backend/router.py
Christian e4b9091a1b feat: Implement fixed-price agreements frontend views and related templates
- Added views for listing fixed-price agreements, displaying agreement details, and a reporting dashboard.
- Created HTML templates for listing, detailing, and reporting on fixed-price agreements.
- Introduced API endpoint to fetch active customers for agreement creation.
- Added migration scripts for creating necessary database tables and views for fixed-price agreements, billing periods, and reporting.
- Implemented triggers for auto-generating agreement numbers and updating timestamps.
- Enhanced ticket management with archived ticket views and filtering capabilities.
2026-02-08 01:45:00 +01:00

506 lines
18 KiB
Python

from fastapi import APIRouter, HTTPException
from app.core.database import execute_query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, date
from decimal import Decimal, ROUND_CEILING
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# Pydantic Models
class PrepaidCard(BaseModel):
id: Optional[int] = None
card_number: str
customer_id: int
purchased_hours: float
used_hours: float
remaining_hours: float
price_per_hour: float
total_amount: float
status: str
purchased_at: Optional[datetime] = None
expires_at: Optional[datetime] = None
economic_invoice_number: Optional[str] = None
economic_product_number: Optional[str] = None
notes: Optional[str] = None
rounding_minutes: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class PrepaidCardCreate(BaseModel):
customer_id: int
purchased_hours: float
price_per_hour: float
expires_at: Optional[date] = None
notes: Optional[str] = None
rounding_minutes: Optional[int] = None
class PrepaidCardRoundingUpdate(BaseModel):
rounding_minutes: int
def _normalize_rounding_minutes(value: Optional[int]) -> int:
if value is None:
return 0
return int(value)
def _apply_rounding(hours: float, rounding_minutes: int) -> float:
if rounding_minutes <= 0:
return float(hours)
interval = Decimal(rounding_minutes) / Decimal(60)
raw = Decimal(str(hours))
rounded = (raw / interval).to_integral_value(rounding=ROUND_CEILING) * interval
return float(rounded)
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[int] = None):
"""
Get all prepaid cards with customer information
"""
try:
query = """
SELECT
pc.*,
c.name as customer_name,
c.email as customer_email,
(SELECT COUNT(*) FROM tticket_prepaid_transactions WHERE card_id = pc.id) as transaction_count
FROM tticket_prepaid_cards pc
LEFT JOIN customers c ON pc.customer_id = c.id
WHERE 1=1
"""
params = []
if status:
query += " AND pc.status = %s"
params.append(status)
if customer_id:
query += " AND pc.customer_id = %s"
params.append(customer_id)
query += " ORDER BY pc.created_at DESC"
cards = execute_query(query, tuple(params) if params else None)
# Recalculate used_hours and remaining_hours from actual timelogs for each card
for card in cards or []:
card_id = card['id']
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Get sag timelogs
sag_logs = execute_query("""
SELECT original_hours, approved_hours
FROM tmodule_times
WHERE prepaid_card_id = %s
""", (card_id,))
# Get ticket timelogs
ticket_logs = execute_query("""
SELECT hours, rounded_hours
FROM tticket_worklog
WHERE prepaid_card_id = %s
""", (card_id,))
# Calculate total rounded hours
total_rounded = 0.0
for log in sag_logs or []:
actual = float(log['original_hours'])
if log.get('approved_hours'):
total_rounded += float(log['approved_hours'])
else:
total_rounded += _apply_rounding(actual, rounding_minutes)
for log in ticket_logs or []:
if log.get('rounded_hours'):
total_rounded += float(log['rounded_hours'])
else:
total_rounded += float(log['hours'])
# Override with calculated values
purchased = float(card['purchased_hours'])
card['used_hours'] = total_rounded
card['remaining_hours'] = purchased - total_rounded
logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
return cards or []
except Exception as e:
logger.error(f"❌ Error fetching prepaid cards: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/{card_id}", response_model=Dict[str, Any])
async def get_prepaid_card(card_id: int):
"""
Get a specific prepaid card with transactions
"""
try:
result = execute_query("""
SELECT
pc.*,
c.name as customer_name,
c.email as customer_email
FROM tticket_prepaid_cards pc
LEFT JOIN customers c ON pc.customer_id = c.id
WHERE pc.id = %s
""", (card_id,))
if not result or len(result) == 0:
raise HTTPException(status_code=404, detail="Prepaid card not found")
card = result[0]
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Get transactions
transactions = execute_query("""
SELECT
pt.*,
w.ticket_id,
t.subject as ticket_title
FROM tticket_prepaid_transactions pt
LEFT JOIN tticket_worklog w ON pt.worklog_id = w.id
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE pt.card_id = %s
ORDER BY pt.created_at DESC
""", (card_id,))
card['transactions'] = transactions or []
# Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card)
sag_logs = execute_query("""
SELECT
tm.id,
tm.sag_id,
tm.worked_date,
tm.description,
tm.original_hours,
tm.approved_hours,
tm.created_at,
tm.user_name,
s.titel AS sag_title
FROM tmodule_times tm
LEFT JOIN sag_sager s ON tm.sag_id = s.id
WHERE tm.prepaid_card_id = %s
""", (card_id,))
ticket_logs = execute_query("""
SELECT
w.id,
w.ticket_id,
w.work_date,
w.description,
w.hours,
w.rounded_hours,
w.created_at,
t.subject AS ticket_title,
t.ticket_number
FROM tticket_worklog w
LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
WHERE w.prepaid_card_id = %s
""", (card_id,))
timelogs = []
for log in sag_logs or []:
actual_hours = float(log['original_hours'])
# Use stored approved_hours if available, else calculate
rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes)
timelogs.append({
"source": "sag",
"source_id": log.get("sag_id"),
"source_title": log.get("sag_title"),
"worked_date": log.get("worked_date"),
"created_at": log.get("created_at"),
"description": log.get("description"),
"user_name": log.get("user_name"),
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
})
for log in ticket_logs or []:
actual_hours = float(log['hours'])
# Use stored rounded_hours if available, else use actual
rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours
ticket_label = log.get("ticket_number") or log.get("ticket_id")
timelogs.append({
"source": "ticket",
"source_id": log.get("ticket_id"),
"source_title": log.get("ticket_title") or "Ticket",
"ticket_number": ticket_label,
"worked_date": log.get("work_date"),
"created_at": log.get("created_at"),
"description": log.get("description"),
"user_name": None,
"actual_hours": actual_hours,
"rounded_hours": rounded_hours
})
timelogs.sort(
key=lambda item: (
item.get("worked_date") or date.min,
item.get("created_at") or datetime.min
),
reverse=True
)
# Calculate actual totals from timelogs (sum of rounded hours)
total_rounded_hours = sum(log['rounded_hours'] for log in timelogs)
purchased_hours = float(card['purchased_hours'])
# Override DB values with calculated values based on actual timelogs
card['used_hours'] = total_rounded_hours
card['remaining_hours'] = purchased_hours - total_rounded_hours
card['timelogs'] = timelogs
card['rounding_minutes'] = rounding_minutes
return card
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error fetching prepaid card {card_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/prepaid-cards", response_model=Dict[str, Any])
async def create_prepaid_card(card: PrepaidCardCreate):
"""
Create a new prepaid card
Note: As of migration 065, customers can have multiple active cards simultaneously.
"""
try:
rounding_minutes = _normalize_rounding_minutes(card.rounding_minutes)
if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes")
# Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour
# Create card (need to use fetch=False for INSERT RETURNING)
conn = None
try:
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("""
INSERT INTO tticket_prepaid_cards
(customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes, rounding_minutes)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""", (
card.customer_id,
card.purchased_hours,
card.price_per_hour,
total_amount,
card.expires_at,
card.notes
, rounding_minutes
))
conn.commit()
result = cursor.fetchone()
logger.info(f"✅ Created prepaid card: {result['card_number']}")
return result
finally:
if conn:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error creating prepaid card: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id}/status")
async def update_card_status(card_id: int, status: str):
"""
Update prepaid card status (cancel, reactivate)
"""
try:
if status not in ['active', 'cancelled']:
raise HTTPException(status_code=400, detail="Invalid status")
conn = None
try:
from app.core.database import get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
conn = get_db_connection()
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("""
UPDATE tticket_prepaid_cards
SET status = %s
WHERE id = %s
RETURNING *
""", (status, card_id))
conn.commit()
result = cursor.fetchone()
if not result:
raise HTTPException(status_code=404, detail="Card not found")
logger.info(f"✅ Updated card {card_id} status to {status}")
return result
finally:
if conn:
release_db_connection(conn)
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating card status: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any])
async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate):
"""
Update rounding interval for a prepaid card (minutes)
"""
try:
rounding_minutes = _normalize_rounding_minutes(payload.rounding_minutes)
if rounding_minutes not in (0, 15, 30, 60):
raise HTTPException(status_code=400, detail="Invalid rounding minutes")
result = execute_query(
"""
UPDATE tticket_prepaid_cards
SET rounding_minutes = %s
WHERE id = %s
RETURNING *
""",
(rounding_minutes, card_id)
)
if not result:
raise HTTPException(status_code=404, detail="Card not found")
return result[0]
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error updating rounding for card {card_id}: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/prepaid-cards/{card_id}")
async def delete_prepaid_card(card_id: int):
"""
Delete a prepaid card (only if no transactions)
"""
try:
# Check for transactions
transactions = execute_query("""
SELECT COUNT(*) as count FROM tticket_prepaid_transactions
WHERE card_id = %s
""", (card_id,))
if transactions and len(transactions) > 0 and transactions[0]['count'] > 0:
raise HTTPException(
status_code=400,
detail="Cannot delete card with transactions"
)
execute_query("DELETE FROM tticket_prepaid_cards WHERE id = %s", (card_id,), fetch=False)
logger.info(f"✅ Deleted prepaid card {card_id}")
return {"message": "Card deleted successfully"}
except HTTPException:
raise
except Exception as e:
logger.error(f"❌ Error deleting card: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
async def get_prepaid_stats():
"""
Get prepaid cards statistics (calculated from actual timelogs)
"""
try:
# Get all cards
cards = execute_query("""
SELECT id, status, purchased_hours, total_amount, rounding_minutes
FROM tticket_prepaid_cards
""")
stats = {
'active_count': 0,
'depleted_count': 0,
'expired_count': 0,
'cancelled_count': 0,
'total_remaining_hours': 0.0,
'total_used_hours': 0.0,
'total_purchased_hours': 0.0,
'total_revenue': 0.0
}
for card in cards or []:
card_id = card['id']
status = card['status']
purchased = float(card['purchased_hours'])
rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Count by status
if status == 'active':
stats['active_count'] += 1
elif status == 'depleted':
stats['depleted_count'] += 1
elif status == 'expired':
stats['expired_count'] += 1
elif status == 'cancelled':
stats['cancelled_count'] += 1
# Calculate actual used hours from timelogs
sag_logs = execute_query("""
SELECT original_hours, approved_hours
FROM tmodule_times
WHERE prepaid_card_id = %s
""", (card_id,))
ticket_logs = execute_query("""
SELECT hours, rounded_hours
FROM tticket_worklog
WHERE prepaid_card_id = %s
""", (card_id,))
used = 0.0
for log in sag_logs or []:
if log.get('approved_hours'):
used += float(log['approved_hours'])
else:
used += _apply_rounding(float(log['original_hours']), rounding_minutes)
for log in ticket_logs or []:
if log.get('rounded_hours'):
used += float(log['rounded_hours'])
else:
used += float(log['hours'])
remaining = purchased - used
stats['total_purchased_hours'] += purchased
stats['total_used_hours'] += used
stats['total_revenue'] += float(card['total_amount'])
if status == 'active':
stats['total_remaining_hours'] += remaining
return stats
except Exception as e:
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))