- Added migration 025 for the Ticket System, creating tables for tickets, comments, attachments, worklogs, prepaid cards, and audit logs. - Introduced migration 026 to add ticket-related permissions to the auth system and assign them to user groups. - Developed a test suite for the Ticket Module, validating database schema, ticket number generation, prepaid card constraints, service logic, worklog creation, audit logging, and views.
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))
|