819 lines
27 KiB
Python
819 lines
27 KiB
Python
|
|
"""
|
|||
|
|
Ticket Module API Router
|
|||
|
|
=========================
|
|||
|
|
|
|||
|
|
REST API endpoints for ticket system.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from typing import List, Optional
|
|||
|
|
from fastapi import APIRouter, HTTPException, Query, status
|
|||
|
|
from fastapi.responses import JSONResponse
|
|||
|
|
|
|||
|
|
from app.ticket.backend.ticket_service import TicketService
|
|||
|
|
from app.ticket.backend.economic_export import ticket_economic_service
|
|||
|
|
from app.ticket.backend.models import (
|
|||
|
|
TTicket,
|
|||
|
|
TTicketCreate,
|
|||
|
|
TTicketUpdate,
|
|||
|
|
TTicketWithStats,
|
|||
|
|
TTicketComment,
|
|||
|
|
TTicketCommentCreate,
|
|||
|
|
TTicketWorklog,
|
|||
|
|
TTicketWorklogCreate,
|
|||
|
|
TTicketWorklogUpdate,
|
|||
|
|
TTicketWorklogWithDetails,
|
|||
|
|
TicketListResponse,
|
|||
|
|
TicketStatusUpdateRequest,
|
|||
|
|
WorklogReviewResponse,
|
|||
|
|
WorklogBillingRequest
|
|||
|
|
)
|
|||
|
|
from app.core.database import execute_query, execute_insert, execute_update
|
|||
|
|
from datetime import date
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
router = APIRouter()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# TICKET ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
@router.get("/tickets", response_model=TicketListResponse, tags=["Tickets"])
|
|||
|
|
async def list_tickets(
|
|||
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|||
|
|
priority: Optional[str] = Query(None, description="Filter by priority"),
|
|||
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|||
|
|
assigned_to_user_id: Optional[int] = Query(None, description="Filter by assigned user"),
|
|||
|
|
search: Optional[str] = Query(None, description="Search in subject/description"),
|
|||
|
|
limit: int = Query(50, ge=1, le=100, description="Number of results"),
|
|||
|
|
offset: int = Query(0, ge=0, description="Offset for pagination")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
List tickets with optional filters
|
|||
|
|
|
|||
|
|
- **status**: Filter by ticket status
|
|||
|
|
- **priority**: Filter by priority level
|
|||
|
|
- **customer_id**: Show tickets for specific customer
|
|||
|
|
- **assigned_to_user_id**: Show tickets assigned to user
|
|||
|
|
- **search**: Search in subject and description
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
tickets = TicketService.list_tickets(
|
|||
|
|
status=status,
|
|||
|
|
priority=priority,
|
|||
|
|
customer_id=customer_id,
|
|||
|
|
assigned_to_user_id=assigned_to_user_id,
|
|||
|
|
search=search,
|
|||
|
|
limit=limit,
|
|||
|
|
offset=offset
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Get total count for pagination
|
|||
|
|
total_query = "SELECT COUNT(*) as count FROM tticket_tickets WHERE 1=1"
|
|||
|
|
params = []
|
|||
|
|
|
|||
|
|
if status:
|
|||
|
|
total_query += " AND status = %s"
|
|||
|
|
params.append(status)
|
|||
|
|
if customer_id:
|
|||
|
|
total_query += " AND customer_id = %s"
|
|||
|
|
params.append(customer_id)
|
|||
|
|
|
|||
|
|
total_result = execute_query(total_query, tuple(params), fetchone=True)
|
|||
|
|
total = total_result['count'] if total_result else 0
|
|||
|
|
|
|||
|
|
return TicketListResponse(
|
|||
|
|
tickets=tickets,
|
|||
|
|
total=total,
|
|||
|
|
page=offset // limit + 1,
|
|||
|
|
page_size=limit
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error listing tickets: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/tickets/{ticket_id}", response_model=TTicketWithStats, tags=["Tickets"])
|
|||
|
|
async def get_ticket(ticket_id: int):
|
|||
|
|
"""
|
|||
|
|
Get single ticket with statistics
|
|||
|
|
|
|||
|
|
Returns ticket with comment count, worklog hours, etc.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
ticket = TicketService.get_ticket_with_stats(ticket_id)
|
|||
|
|
|
|||
|
|
if not ticket:
|
|||
|
|
raise HTTPException(status_code=404, detail=f"Ticket {ticket_id} not found")
|
|||
|
|
|
|||
|
|
return ticket
|
|||
|
|
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/tickets", response_model=TTicket, status_code=status.HTTP_201_CREATED, tags=["Tickets"])
|
|||
|
|
async def create_ticket(
|
|||
|
|
ticket_data: TTicketCreate,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User creating ticket")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Create new ticket
|
|||
|
|
|
|||
|
|
Ticket number will be auto-generated if not provided.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
ticket = TicketService.create_ticket(ticket_data, user_id=user_id)
|
|||
|
|
logger.info(f"✅ Created ticket {ticket['ticket_number']}")
|
|||
|
|
return ticket
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error creating ticket: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.patch("/tickets/{ticket_id}", response_model=TTicket, tags=["Tickets"])
|
|||
|
|
async def update_ticket(
|
|||
|
|
ticket_id: int,
|
|||
|
|
update_data: TTicketUpdate,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User making update")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Update ticket (partial update)
|
|||
|
|
|
|||
|
|
Only provided fields will be updated.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
ticket = TicketService.update_ticket(ticket_id, update_data, user_id=user_id)
|
|||
|
|
return ticket
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error updating ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.put("/tickets/{ticket_id}/status", response_model=TTicket, tags=["Tickets"])
|
|||
|
|
async def update_ticket_status(
|
|||
|
|
ticket_id: int,
|
|||
|
|
request: TicketStatusUpdateRequest,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User changing status")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Update ticket status with validation
|
|||
|
|
|
|||
|
|
Status transitions are validated according to workflow rules.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
ticket = TicketService.update_ticket_status(
|
|||
|
|
ticket_id,
|
|||
|
|
request.status.value,
|
|||
|
|
user_id=user_id,
|
|||
|
|
note=request.note
|
|||
|
|
)
|
|||
|
|
return ticket
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error updating status for ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.put("/tickets/{ticket_id}/assign", response_model=TTicket, tags=["Tickets"])
|
|||
|
|
async def assign_ticket(
|
|||
|
|
ticket_id: int,
|
|||
|
|
assigned_to_user_id: int = Query(..., description="User to assign to"),
|
|||
|
|
user_id: Optional[int] = Query(None, description="User making assignment")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Assign ticket to a user
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
ticket = TicketService.assign_ticket(ticket_id, assigned_to_user_id, user_id=user_id)
|
|||
|
|
return ticket
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error assigning ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# COMMENT ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
@router.get("/tickets/{ticket_id}/comments", response_model=List[TTicketComment], tags=["Comments"])
|
|||
|
|
async def list_comments(ticket_id: int):
|
|||
|
|
"""
|
|||
|
|
List all comments for a ticket
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
comments = execute_query(
|
|||
|
|
"SELECT * FROM tticket_comments WHERE ticket_id = %s ORDER BY created_at ASC",
|
|||
|
|
(ticket_id,)
|
|||
|
|
)
|
|||
|
|
return comments or []
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error listing comments for ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/tickets/{ticket_id}/comments", response_model=TTicketComment, status_code=status.HTTP_201_CREATED, tags=["Comments"])
|
|||
|
|
async def add_comment(
|
|||
|
|
ticket_id: int,
|
|||
|
|
comment_text: str = Query(..., min_length=1, description="Comment text"),
|
|||
|
|
is_internal: bool = Query(False, description="Is internal note"),
|
|||
|
|
user_id: Optional[int] = Query(None, description="User adding comment")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Add comment to ticket
|
|||
|
|
|
|||
|
|
- **is_internal**: If true, comment is only visible to staff
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
comment = TicketService.add_comment(
|
|||
|
|
ticket_id,
|
|||
|
|
comment_text,
|
|||
|
|
user_id=user_id,
|
|||
|
|
is_internal=is_internal
|
|||
|
|
)
|
|||
|
|
return comment
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error adding comment to ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# WORKLOG ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
@router.get("/tickets/{ticket_id}/worklog", response_model=List[TTicketWorklog], tags=["Worklog"])
|
|||
|
|
async def list_worklog(ticket_id: int):
|
|||
|
|
"""
|
|||
|
|
List all worklog entries for a ticket
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
worklog = execute_query(
|
|||
|
|
"SELECT * FROM tticket_worklog WHERE ticket_id = %s ORDER BY work_date DESC",
|
|||
|
|
(ticket_id,)
|
|||
|
|
)
|
|||
|
|
return worklog or []
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error listing worklog for ticket {ticket_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/tickets/{ticket_id}/worklog", response_model=TTicketWorklog, status_code=status.HTTP_201_CREATED, tags=["Worklog"])
|
|||
|
|
async def create_worklog(
|
|||
|
|
ticket_id: int,
|
|||
|
|
worklog_data: TTicketWorklogCreate,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User creating worklog")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Create worklog entry for ticket
|
|||
|
|
|
|||
|
|
Creates time entry in draft status.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from psycopg2.extras import Json
|
|||
|
|
|
|||
|
|
worklog_id = execute_insert(
|
|||
|
|
"""
|
|||
|
|
INSERT INTO tticket_worklog
|
|||
|
|
(ticket_id, work_date, hours, work_type, description, billing_method, status, user_id, prepaid_card_id)
|
|||
|
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|||
|
|
""",
|
|||
|
|
(
|
|||
|
|
ticket_id,
|
|||
|
|
worklog_data.work_date,
|
|||
|
|
worklog_data.hours,
|
|||
|
|
worklog_data.work_type.value,
|
|||
|
|
worklog_data.description,
|
|||
|
|
worklog_data.billing_method.value,
|
|||
|
|
'draft',
|
|||
|
|
user_id or worklog_data.user_id,
|
|||
|
|
worklog_data.prepaid_card_id
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Log audit
|
|||
|
|
TicketService.log_audit(
|
|||
|
|
ticket_id=ticket_id,
|
|||
|
|
entity_type="worklog",
|
|||
|
|
entity_id=worklog_id,
|
|||
|
|
user_id=user_id,
|
|||
|
|
action="created",
|
|||
|
|
details={"hours": float(worklog_data.hours), "work_type": worklog_data.work_type.value}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
worklog = execute_query(
|
|||
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
|||
|
|
(worklog_id,),
|
|||
|
|
fetchone=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger.info(f"✅ Created worklog entry {worklog_id} for ticket {ticket_id}")
|
|||
|
|
return worklog
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error creating worklog: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.patch("/worklog/{worklog_id}", response_model=TTicketWorklog, tags=["Worklog"])
|
|||
|
|
async def update_worklog(
|
|||
|
|
worklog_id: int,
|
|||
|
|
update_data: TTicketWorklogUpdate,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User updating worklog")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Update worklog entry (partial update)
|
|||
|
|
|
|||
|
|
Only draft entries can be fully edited.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
# Get current worklog
|
|||
|
|
current = execute_query(
|
|||
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
|||
|
|
(worklog_id,),
|
|||
|
|
fetchone=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not current:
|
|||
|
|
raise HTTPException(status_code=404, detail=f"Worklog {worklog_id} not found")
|
|||
|
|
|
|||
|
|
# Build update query
|
|||
|
|
updates = []
|
|||
|
|
params = []
|
|||
|
|
|
|||
|
|
update_dict = update_data.model_dump(exclude_unset=True)
|
|||
|
|
|
|||
|
|
for field, value in update_dict.items():
|
|||
|
|
if hasattr(value, 'value'):
|
|||
|
|
value = value.value
|
|||
|
|
updates.append(f"{field} = %s")
|
|||
|
|
params.append(value)
|
|||
|
|
|
|||
|
|
if updates:
|
|||
|
|
params.append(worklog_id)
|
|||
|
|
query = f"UPDATE tticket_worklog SET {', '.join(updates)} WHERE id = %s"
|
|||
|
|
execute_update(query, tuple(params))
|
|||
|
|
|
|||
|
|
# Log audit
|
|||
|
|
TicketService.log_audit(
|
|||
|
|
ticket_id=current['ticket_id'],
|
|||
|
|
entity_type="worklog",
|
|||
|
|
entity_id=worklog_id,
|
|||
|
|
user_id=user_id,
|
|||
|
|
action="updated",
|
|||
|
|
details=update_dict
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Fetch updated
|
|||
|
|
worklog = execute_query(
|
|||
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
|||
|
|
(worklog_id,),
|
|||
|
|
fetchone=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return worklog
|
|||
|
|
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error updating worklog {worklog_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/worklog/review", response_model=WorklogReviewResponse, tags=["Worklog"])
|
|||
|
|
async def review_worklog(
|
|||
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|||
|
|
status: str = Query("draft", description="Filter by status (default: draft)")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Get worklog entries for review/billing
|
|||
|
|
|
|||
|
|
Returns entries ready for review with ticket context.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
from decimal import Decimal
|
|||
|
|
|
|||
|
|
query = """
|
|||
|
|
SELECT w.*, t.ticket_number, t.subject AS ticket_subject,
|
|||
|
|
t.customer_id, t.status AS ticket_status
|
|||
|
|
FROM tticket_worklog w
|
|||
|
|
JOIN tticket_tickets t ON w.ticket_id = t.id
|
|||
|
|
WHERE w.status = %s
|
|||
|
|
"""
|
|||
|
|
params = [status]
|
|||
|
|
|
|||
|
|
if customer_id:
|
|||
|
|
query += " AND t.customer_id = %s"
|
|||
|
|
params.append(customer_id)
|
|||
|
|
|
|||
|
|
query += " ORDER BY w.work_date DESC, t.customer_id"
|
|||
|
|
|
|||
|
|
worklogs = execute_query(query, tuple(params))
|
|||
|
|
|
|||
|
|
# Calculate totals
|
|||
|
|
total_hours = Decimal('0')
|
|||
|
|
total_billable_hours = Decimal('0')
|
|||
|
|
|
|||
|
|
for w in worklogs or []:
|
|||
|
|
total_hours += Decimal(str(w['hours']))
|
|||
|
|
if w['status'] in ['draft', 'billable']:
|
|||
|
|
total_billable_hours += Decimal(str(w['hours']))
|
|||
|
|
|
|||
|
|
return WorklogReviewResponse(
|
|||
|
|
worklogs=worklogs or [],
|
|||
|
|
total=len(worklogs) if worklogs else 0,
|
|||
|
|
total_hours=total_hours,
|
|||
|
|
total_billable_hours=total_billable_hours
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting worklog review: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/worklog/mark-billable", tags=["Worklog"])
|
|||
|
|
async def mark_worklog_billable(
|
|||
|
|
request: WorklogBillingRequest,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User marking as billable")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Mark worklog entries as billable
|
|||
|
|
|
|||
|
|
Changes status from draft to billable for selected entries.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
updated_count = 0
|
|||
|
|
|
|||
|
|
for worklog_id in request.worklog_ids:
|
|||
|
|
# Get worklog
|
|||
|
|
worklog = execute_query(
|
|||
|
|
"SELECT * FROM tticket_worklog WHERE id = %s",
|
|||
|
|
(worklog_id,),
|
|||
|
|
fetchone=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if not worklog:
|
|||
|
|
logger.warning(f"⚠️ Worklog {worklog_id} not found, skipping")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
if worklog['status'] != 'draft':
|
|||
|
|
logger.warning(f"⚠️ Worklog {worklog_id} not in draft status, skipping")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# Update to billable
|
|||
|
|
execute_update(
|
|||
|
|
"UPDATE tticket_worklog SET status = 'billable' WHERE id = %s",
|
|||
|
|
(worklog_id,)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Log audit
|
|||
|
|
TicketService.log_audit(
|
|||
|
|
ticket_id=worklog['ticket_id'],
|
|||
|
|
entity_type="worklog",
|
|||
|
|
entity_id=worklog_id,
|
|||
|
|
user_id=user_id,
|
|||
|
|
action="marked_billable",
|
|||
|
|
old_value="draft",
|
|||
|
|
new_value="billable",
|
|||
|
|
details={"note": request.note} if request.note else None
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
updated_count += 1
|
|||
|
|
|
|||
|
|
logger.info(f"✅ Marked {updated_count} worklog entries as billable")
|
|||
|
|
|
|||
|
|
return JSONResponse(
|
|||
|
|
content={
|
|||
|
|
"success": True,
|
|||
|
|
"updated_count": updated_count,
|
|||
|
|
"message": f"Marked {updated_count} entries as billable"
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error marking worklog as billable: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# PREPAID CARD (KLIPPEKORT) ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
from app.ticket.backend.klippekort_service import KlippekortService
|
|||
|
|
from app.ticket.backend.models import (
|
|||
|
|
TPrepaidCard,
|
|||
|
|
TPrepaidCardCreate,
|
|||
|
|
TPrepaidCardUpdate,
|
|||
|
|
TPrepaidCardWithStats,
|
|||
|
|
TPrepaidTransaction,
|
|||
|
|
PrepaidCardBalanceResponse,
|
|||
|
|
PrepaidCardTopUpRequest
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/prepaid-cards", response_model=List[TPrepaidCard], tags=["Prepaid Cards"])
|
|||
|
|
async def list_prepaid_cards(
|
|||
|
|
customer_id: Optional[int] = Query(None, description="Filter by customer"),
|
|||
|
|
status: Optional[str] = Query(None, description="Filter by status"),
|
|||
|
|
limit: int = Query(50, ge=1, le=100),
|
|||
|
|
offset: int = Query(0, ge=0)
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
List prepaid cards with optional filters
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
cards = KlippekortService.list_cards(
|
|||
|
|
customer_id=customer_id,
|
|||
|
|
status=status,
|
|||
|
|
limit=limit,
|
|||
|
|
offset=offset
|
|||
|
|
)
|
|||
|
|
return cards
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error listing prepaid cards: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/prepaid-cards/{card_id}", response_model=TPrepaidCardWithStats, tags=["Prepaid Cards"])
|
|||
|
|
async def get_prepaid_card(card_id: int):
|
|||
|
|
"""
|
|||
|
|
Get prepaid card with usage statistics
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
card = KlippekortService.get_card_with_stats(card_id)
|
|||
|
|
|
|||
|
|
if not card:
|
|||
|
|
raise HTTPException(status_code=404, detail=f"Prepaid card {card_id} not found")
|
|||
|
|
|
|||
|
|
return card
|
|||
|
|
|
|||
|
|
except HTTPException:
|
|||
|
|
raise
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting prepaid card {card_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/prepaid-cards", response_model=TPrepaidCard, status_code=status.HTTP_201_CREATED, tags=["Prepaid Cards"])
|
|||
|
|
async def purchase_prepaid_card(
|
|||
|
|
card_data: TPrepaidCardCreate,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User purchasing card")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Purchase new prepaid card
|
|||
|
|
|
|||
|
|
CONSTRAINT: Only 1 active card allowed per customer.
|
|||
|
|
Will fail if customer already has an active card.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
card = KlippekortService.purchase_card(card_data, user_id=user_id)
|
|||
|
|
logger.info(f"✅ Purchased prepaid card {card['card_number']}")
|
|||
|
|
return card
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error purchasing prepaid card: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/prepaid-cards/customer/{customer_id}/balance", response_model=PrepaidCardBalanceResponse, tags=["Prepaid Cards"])
|
|||
|
|
async def check_customer_balance(customer_id: int):
|
|||
|
|
"""
|
|||
|
|
Check prepaid card balance for customer
|
|||
|
|
|
|||
|
|
Returns balance info for customer's active card.
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
balance_info = KlippekortService.check_balance(customer_id)
|
|||
|
|
|
|||
|
|
if not balance_info['has_card']:
|
|||
|
|
return PrepaidCardBalanceResponse(
|
|||
|
|
card=None,
|
|||
|
|
can_deduct=False,
|
|||
|
|
message=f"Customer {customer_id} has no active prepaid card"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Get card details
|
|||
|
|
card = KlippekortService.get_card_with_stats(balance_info['card_id'])
|
|||
|
|
|
|||
|
|
return PrepaidCardBalanceResponse(
|
|||
|
|
card=card,
|
|||
|
|
can_deduct=balance_info['balance_hours'] > 0,
|
|||
|
|
message=f"Balance: {balance_info['balance_hours']}h"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error checking balance for customer {customer_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/prepaid-cards/{card_id}/top-up", response_model=TPrepaidTransaction, tags=["Prepaid Cards"])
|
|||
|
|
async def top_up_prepaid_card(
|
|||
|
|
card_id: int,
|
|||
|
|
request: PrepaidCardTopUpRequest,
|
|||
|
|
user_id: Optional[int] = Query(None, description="User performing top-up")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Top up prepaid card with additional hours
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
transaction = KlippekortService.top_up_card(
|
|||
|
|
card_id,
|
|||
|
|
request.hours,
|
|||
|
|
user_id=user_id,
|
|||
|
|
note=request.note
|
|||
|
|
)
|
|||
|
|
return transaction
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error topping up card {card_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/prepaid-cards/{card_id}/transactions", response_model=List[TPrepaidTransaction], tags=["Prepaid Cards"])
|
|||
|
|
async def get_card_transactions(
|
|||
|
|
card_id: int,
|
|||
|
|
limit: int = Query(100, ge=1, le=500)
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Get transaction history for prepaid card
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
transactions = KlippekortService.get_transactions(card_id, limit=limit)
|
|||
|
|
return transactions
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting transactions for card {card_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.delete("/prepaid-cards/{card_id}", response_model=TPrepaidCard, tags=["Prepaid Cards"])
|
|||
|
|
async def cancel_prepaid_card(
|
|||
|
|
card_id: int,
|
|||
|
|
reason: Optional[str] = Query(None, description="Cancellation reason"),
|
|||
|
|
user_id: Optional[int] = Query(None, description="User cancelling card")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Cancel/deactivate prepaid card
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
card = KlippekortService.cancel_card(card_id, user_id=user_id, reason=reason)
|
|||
|
|
return card
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error cancelling card {card_id}: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# STATISTICS ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
@router.get("/tickets/stats/by-status", tags=["Statistics"])
|
|||
|
|
async def get_stats_by_status():
|
|||
|
|
"""
|
|||
|
|
Get ticket statistics grouped by status
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
stats = execute_query(
|
|||
|
|
"SELECT * FROM tticket_stats_by_status ORDER BY status"
|
|||
|
|
)
|
|||
|
|
return stats or []
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting stats: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/tickets/stats/open", tags=["Statistics"])
|
|||
|
|
async def get_open_tickets_stats():
|
|||
|
|
"""
|
|||
|
|
Get statistics for open tickets
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
stats = execute_query(
|
|||
|
|
"""
|
|||
|
|
SELECT
|
|||
|
|
COUNT(*) as total_open,
|
|||
|
|
COUNT(*) FILTER (WHERE status = 'open') as new_tickets,
|
|||
|
|
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress,
|
|||
|
|
COUNT(*) FILTER (WHERE priority = 'urgent') as urgent_count,
|
|||
|
|
AVG(age_hours) as avg_age_hours
|
|||
|
|
FROM tticket_open_tickets
|
|||
|
|
""",
|
|||
|
|
fetchone=True
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return stats or {}
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error getting open tickets stats: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ============================================================================
|
|||
|
|
# E-CONOMIC EXPORT ENDPOINTS
|
|||
|
|
# ============================================================================
|
|||
|
|
|
|||
|
|
@router.post("/worklog/export/preview", tags=["E-conomic Export"])
|
|||
|
|
async def preview_economic_export(
|
|||
|
|
customer_id: int = Query(..., description="Customer ID"),
|
|||
|
|
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
|||
|
|
date_from: Optional[date] = Query(None, description="Start date filter"),
|
|||
|
|
date_to: Optional[date] = Query(None, description="End date filter")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Preview what would be exported to e-conomic without actually exporting
|
|||
|
|
|
|||
|
|
**Safety**: This is read-only and safe to call
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
preview = await ticket_economic_service.get_export_preview(
|
|||
|
|
customer_id=customer_id,
|
|||
|
|
worklog_ids=worklog_ids,
|
|||
|
|
date_from=date_from,
|
|||
|
|
date_to=date_to
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return preview
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error generating export preview: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/worklog/export/execute", tags=["E-conomic Export"])
|
|||
|
|
async def execute_economic_export(
|
|||
|
|
customer_id: int = Query(..., description="Customer ID"),
|
|||
|
|
worklog_ids: Optional[List[int]] = Query(None, description="Specific worklog IDs to export"),
|
|||
|
|
date_from: Optional[date] = Query(None, description="Start date filter"),
|
|||
|
|
date_to: Optional[date] = Query(None, description="End date filter")
|
|||
|
|
):
|
|||
|
|
"""
|
|||
|
|
Export billable worklog entries to e-conomic as draft invoice
|
|||
|
|
|
|||
|
|
**⚠️ WARNING**: This creates invoices in e-conomic (subject to safety switches)
|
|||
|
|
|
|||
|
|
**Safety Switches**:
|
|||
|
|
- `TICKET_ECONOMIC_READ_ONLY=true`: Blocks execution
|
|||
|
|
- `TICKET_ECONOMIC_DRY_RUN=true`: Logs but doesn't execute
|
|||
|
|
- Both must be `false` to actually export
|
|||
|
|
|
|||
|
|
**Process**:
|
|||
|
|
1. Validates customer has e-conomic mapping
|
|||
|
|
2. Collects billable worklog entries
|
|||
|
|
3. Creates draft invoice in e-conomic
|
|||
|
|
4. Marks worklog entries as "billed"
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
result = await ticket_economic_service.export_billable_worklog_batch(
|
|||
|
|
customer_id=customer_id,
|
|||
|
|
worklog_ids=worklog_ids,
|
|||
|
|
date_from=date_from,
|
|||
|
|
date_to=date_to
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if result['status'] == 'blocked':
|
|||
|
|
return JSONResponse(
|
|||
|
|
status_code=403,
|
|||
|
|
content={
|
|||
|
|
'error': 'Export blocked by safety switches',
|
|||
|
|
'read_only': result.get('read_only'),
|
|||
|
|
'dry_run': result.get('dry_run'),
|
|||
|
|
'message': 'Set TICKET_ECONOMIC_READ_ONLY=false and TICKET_ECONOMIC_DRY_RUN=false to enable'
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(e))
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"❌ Error executing export: {e}")
|
|||
|
|
raise HTTPException(status_code=500, detail=str(e))
|