Spaces:
Sleeping
Sleeping
| """ | |
| Invoice Generation API Endpoints | |
| Endpoints for: | |
| - Getting available tickets for invoicing | |
| - Generating invoices from tickets | |
| - Exporting invoices to CSV | |
| - Regenerating viewing tokens | |
| """ | |
| from fastapi import APIRouter, Depends, HTTPException, Response, Query, BackgroundTasks | |
| from sqlalchemy.orm import Session | |
| from typing import List, Optional | |
| from uuid import UUID | |
| from datetime import date | |
| import os | |
| from app.api.deps import get_db, get_current_user | |
| from app.models.user import User | |
| from app.models.enums import AppRole | |
| from app.services.invoice_generation_service import InvoiceGenerationService | |
| from app.services.notification_delivery import NotificationDelivery | |
| from app.schemas.invoice_generation import ( | |
| InvoiceGenerateRequest, | |
| AvailableTicketResponse, | |
| InvoiceGenerateResponse, | |
| RegenerateTokenRequest, | |
| RegenerateTokenResponse | |
| ) | |
| from app.schemas.contractor_invoice import ContractorInvoiceResponse | |
| router = APIRouter(prefix="/invoices", tags=["Invoice Generation"]) | |
| def check_invoice_permission(current_user: User, contractor_id: UUID): | |
| """Check if user can manage invoices for contractor""" | |
| if current_user.role == AppRole.PLATFORM_ADMIN.value: | |
| return True | |
| if current_user.role in [AppRole.PROJECT_MANAGER.value, AppRole.DISPATCHER.value]: | |
| if current_user.contractor_id == contractor_id: | |
| return True | |
| raise HTTPException( | |
| status_code=403, | |
| detail="Not authorized to manage invoices for this contractor" | |
| ) | |
| def get_available_tickets( | |
| project_id: UUID = Query(..., description="Project ID (required)"), | |
| contractor_id: Optional[str] = Query(None, description="Contractor ID (optional - use 'auto' or omit to derive from project)"), | |
| start_date: Optional[date] = Query(None, description="Filter by completion date (start)"), | |
| end_date: Optional[date] = Query(None, description="Filter by completion date (end)"), | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Get completed tickets available for invoicing. | |
| Returns tickets that are: | |
| - Status: completed | |
| - Not yet invoiced | |
| - Optionally filtered by project and date range | |
| **Contractor ID:** | |
| - If not provided, automatically derived from project's contractor | |
| - If provided, validates it matches the project's contractor | |
| """ | |
| from app.models.project import Project | |
| # Get project to derive contractor | |
| project = db.query(Project).filter(Project.id == project_id).first() | |
| if not project: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| # Handle contractor_id parameter | |
| resolved_contractor_id: UUID | |
| if contractor_id is None or contractor_id.lower() == "auto": | |
| # Auto-derive from project | |
| resolved_contractor_id = project.contractor_id | |
| if not resolved_contractor_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Project has no contractor assigned. Please specify contractor_id." | |
| ) | |
| else: | |
| # Parse and validate provided contractor_id | |
| try: | |
| resolved_contractor_id = UUID(contractor_id) | |
| except ValueError: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Invalid contractor_id format: {contractor_id}. Use a valid UUID or 'auto'." | |
| ) | |
| # Validate it matches project's contractor | |
| if resolved_contractor_id != project.contractor_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Contractor {resolved_contractor_id} does not match project's contractor {project.contractor_id}" | |
| ) | |
| check_invoice_permission(current_user, resolved_contractor_id) | |
| tickets = InvoiceGenerationService.get_available_tickets( | |
| db=db, | |
| contractor_id=resolved_contractor_id, | |
| project_id=project_id, | |
| start_date=start_date, | |
| end_date=end_date | |
| ) | |
| return { | |
| "tickets": tickets, | |
| "total_count": len(tickets) | |
| } | |
| def generate_invoice( | |
| data: InvoiceGenerateRequest, | |
| background_tasks: BackgroundTasks, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Generate invoice from selected completed tickets. | |
| This creates: | |
| - Invoice record with ticket line items | |
| - Viewing token for public access | |
| - Notifications for all PMs | |
| - Audit log entry | |
| Returns invoice details and links for viewing/downloading. | |
| **Auto-derivation:** | |
| - contractor_id: Derived from project if not provided | |
| - client_id: Derived from project if not provided | |
| """ | |
| from app.models.project import Project | |
| # Get project to derive contractor and client | |
| project = db.query(Project).filter(Project.id == data.project_id).first() | |
| if not project: | |
| raise HTTPException(status_code=404, detail="Project not found") | |
| # Derive contractor_id if not provided | |
| contractor_id = data.contractor_id | |
| if contractor_id is None: | |
| contractor_id = project.contractor_id | |
| if not contractor_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Project has no contractor assigned. Please specify contractor_id." | |
| ) | |
| else: | |
| # Validate provided contractor_id matches project | |
| if contractor_id != project.contractor_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Contractor {contractor_id} does not match project's contractor {project.contractor_id}" | |
| ) | |
| # Derive client_id if not provided | |
| client_id = data.client_id | |
| if client_id is None: | |
| client_id = project.client_id | |
| if not client_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail="Project has no client assigned. Please specify client_id." | |
| ) | |
| else: | |
| # Validate provided client_id matches project | |
| if client_id != project.client_id: | |
| raise HTTPException( | |
| status_code=400, | |
| detail=f"Client {client_id} does not match project's client {project.client_id}" | |
| ) | |
| check_invoice_permission(current_user, contractor_id) | |
| invoice, viewing_link, csv_link, notification_ids = InvoiceGenerationService.generate_invoice_from_tickets( | |
| db=db, | |
| contractor_id=contractor_id, | |
| client_id=client_id, | |
| project_id=data.project_id, | |
| ticket_ids=data.ticket_ids, | |
| invoice_metadata=data.dict(exclude={'ticket_ids', 'contractor_id', 'client_id', 'project_id'}), | |
| current_user=current_user | |
| ) | |
| # Queue notification delivery (Tier 2 - Asynchronous) | |
| if notification_ids: | |
| NotificationDelivery.queue_bulk_delivery( | |
| background_tasks=background_tasks, | |
| notification_ids=notification_ids | |
| ) | |
| return InvoiceGenerateResponse( | |
| success=True, | |
| invoice_id=invoice.id, | |
| invoice_number=invoice.invoice_number, | |
| tickets_count=len(data.ticket_ids), | |
| viewing_link=viewing_link, | |
| csv_download_link=csv_link, | |
| notification_created=True | |
| ) | |
| def export_invoice_csv( | |
| invoice_id: UUID, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Export invoice to CSV with ticket details and image links. | |
| CSV includes: | |
| - Invoice metadata | |
| - Ticket details (ID, name, type, description) | |
| - Sales order references | |
| - Completion data | |
| - Image links (type:url format) | |
| Marks invoice as exported and tracks who exported it. | |
| """ | |
| csv_data = InvoiceGenerationService.export_invoice_to_csv( | |
| db=db, | |
| invoice_id=invoice_id, | |
| current_user=current_user | |
| ) | |
| return Response( | |
| content=csv_data.getvalue(), | |
| media_type="text/csv", | |
| headers={ | |
| "Content-Disposition": f"attachment; filename=invoice_{invoice_id}.csv" | |
| } | |
| ) | |
| def regenerate_token( | |
| invoice_id: UUID, | |
| data: RegenerateTokenRequest, | |
| db: Session = Depends(get_db), | |
| current_user: User = Depends(get_current_user) | |
| ): | |
| """ | |
| Regenerate viewing token for invoice. | |
| Use cases: | |
| - Token expired | |
| - Client lost the link | |
| - Need to extend access period | |
| Returns new viewing link with updated expiration. | |
| """ | |
| token, expires_at = InvoiceGenerationService.regenerate_viewing_token( | |
| db=db, | |
| invoice_id=invoice_id, | |
| expires_in_days=data.expires_in_days, | |
| current_user=current_user | |
| ) | |
| base_url = os.getenv("APP_BASE_URL", "https://your-app.com") | |
| viewing_link = f"{base_url}/invoices/view?token={token}" | |
| return RegenerateTokenResponse( | |
| viewing_link=viewing_link, | |
| expires_at=expires_at | |
| ) | |