- Added a new section in the settings frontend for managing time multiplier presets, including UI for adding, displaying, and saving presets. - Introduced a new function to normalize and load time multiplier presets from settings. - Updated the worklog submission process to include a selected multiplier preset and its corresponding rate multiplier. - Enhanced the worklog model to support additional fields: extra_billed_hours, extended_support_flag, rate_multiplier, manual_hourly_rate, manual_rounded_hours, and rounding_override_reason. - Implemented backend validation to ensure that time entries linked to prepaid cards cannot be edited if the card is in a locked status. - Added database migration to introduce new columns and constraints for the worklog and time entry tables.
574 lines
21 KiB
Python
574 lines
21 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
|
|
|
|
|
|
class PrepaidCardStatusUpdate(BaseModel):
|
|
status: str
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
|
try:
|
|
row = execute_query(
|
|
"""
|
|
SELECT 1
|
|
FROM information_schema.columns
|
|
WHERE table_name = %s AND column_name = %s
|
|
LIMIT 1
|
|
""",
|
|
(table_name, column_name)
|
|
)
|
|
return bool(row)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@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:int}", 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)
|
|
has_tm_manual = _column_exists("tmodule_times", "manual_rounded_hours")
|
|
has_tm_extra = _column_exists("tmodule_times", "extra_billed_hours")
|
|
has_tm_multiplier = _column_exists("tmodule_times", "rate_multiplier")
|
|
has_w_manual = _column_exists("tticket_worklog", "manual_rounded_hours")
|
|
has_w_extra = _column_exists("tticket_worklog", "extra_billed_hours")
|
|
has_w_multiplier = _column_exists("tticket_worklog", "rate_multiplier")
|
|
|
|
tm_manual_select = "tm.manual_rounded_hours" if has_tm_manual else "NULL AS manual_rounded_hours"
|
|
tm_extra_select = "tm.extra_billed_hours" if has_tm_extra else "0 AS extra_billed_hours"
|
|
tm_multiplier_select = "tm.rate_multiplier" if has_tm_multiplier else "1.0 AS rate_multiplier"
|
|
w_manual_select = "w.manual_rounded_hours" if has_w_manual else "NULL AS manual_rounded_hours"
|
|
w_extra_select = "w.extra_billed_hours" if has_w_extra else "0 AS extra_billed_hours"
|
|
w_multiplier_select = "w.rate_multiplier" if has_w_multiplier else "1.0 AS rate_multiplier"
|
|
|
|
sag_logs = execute_query(f"""
|
|
SELECT
|
|
tm.id,
|
|
tm.sag_id,
|
|
tm.worked_date,
|
|
tm.description,
|
|
tm.original_hours,
|
|
tm.approved_hours,
|
|
{tm_manual_select},
|
|
{tm_extra_select},
|
|
{tm_multiplier_select},
|
|
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(f"""
|
|
SELECT
|
|
w.id,
|
|
w.ticket_id,
|
|
w.work_date,
|
|
w.description,
|
|
w.hours,
|
|
w.rounded_hours,
|
|
{w_manual_select},
|
|
{w_extra_select},
|
|
{w_multiplier_select},
|
|
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'])
|
|
# Priority: manual override -> approved_hours -> auto rounding
|
|
rounded_hours = (
|
|
float(log.get('manual_rounded_hours') or 0) if log.get('manual_rounded_hours')
|
|
else float(log.get('approved_hours') or 0) if log.get('approved_hours')
|
|
else _apply_rounding(actual_hours, rounding_minutes)
|
|
)
|
|
timelogs.append({
|
|
"source": "sag",
|
|
"entry_id": log.get("id"),
|
|
"id": log.get("id"),
|
|
"time_id": log.get("id"),
|
|
"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,
|
|
"extra_billed_hours": float(log.get("extra_billed_hours") or 0),
|
|
"rate_multiplier": float(log.get("rate_multiplier") or 1.0)
|
|
})
|
|
|
|
for log in ticket_logs or []:
|
|
actual_hours = float(log['hours'])
|
|
# Priority: manual override -> rounded_hours -> actual
|
|
rounded_hours = (
|
|
float(log.get('manual_rounded_hours') or 0) if log.get('manual_rounded_hours')
|
|
else 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",
|
|
"entry_id": log.get("id"),
|
|
"id": log.get("id"),
|
|
"worklog_id": log.get("id"),
|
|
"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,
|
|
"extra_billed_hours": float(log.get("extra_billed_hours") or 0),
|
|
"rate_multiplier": float(log.get("rate_multiplier") or 1.0)
|
|
})
|
|
|
|
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:int}/status")
|
|
async def update_card_status(card_id: int, payload: PrepaidCardStatusUpdate):
|
|
"""
|
|
Update prepaid card status
|
|
"""
|
|
try:
|
|
status = str(payload.status or "").strip().lower()
|
|
if status not in ['active', 'cancelled', 'closed']:
|
|
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:int}/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")
|
|
|
|
current = execute_query(
|
|
"SELECT status FROM tticket_prepaid_cards WHERE id = %s",
|
|
(card_id,)
|
|
)
|
|
if not current:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
if str(current[0].get("status") or "").lower() == "closed":
|
|
raise HTTPException(status_code=409, detail="Closed prepaid cards cannot be modified")
|
|
|
|
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:int}")
|
|
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))
|