- 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.
506 lines
18 KiB
Python
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))
|