Spaces:
Sleeping
Sleeping
| """ | |
| Invoice Viewing Service - Public invoice viewing with token authentication | |
| This service handles: | |
| 1. Viewing invoices by token (no authentication required) | |
| 2. Tracking views | |
| 3. Enriching invoice data with ticket details and images | |
| """ | |
| import logging | |
| from typing import Dict | |
| from uuid import UUID | |
| from sqlalchemy.orm import Session | |
| from fastapi import HTTPException | |
| from datetime import datetime | |
| from app.models.contractor_invoice import ContractorInvoice | |
| from app.models.ticket import Ticket | |
| from app.models.ticket_image import TicketImage | |
| from app.models.document import Document | |
| from app.models.ticket_status_history import TicketStatusHistory | |
| from app.models.sales_order import SalesOrder | |
| from app.models.project import ProjectRegion | |
| from app.models.enums import TicketSource | |
| logger = logging.getLogger(__name__) | |
| class InvoiceViewingService: | |
| """Service for public invoice viewing""" | |
| def get_invoice_by_token( | |
| db: Session, | |
| token: str | |
| ) -> Dict: | |
| """ | |
| Get invoice details by viewing token. | |
| Returns: | |
| - Invoice details | |
| - Ticket details with images | |
| - Image URLs (Cloudinary public URLs) | |
| """ | |
| # Find invoice by token with eager loading of relationships | |
| from sqlalchemy.orm import joinedload | |
| invoice = db.query(ContractorInvoice).options( | |
| joinedload(ContractorInvoice.contractor), | |
| joinedload(ContractorInvoice.client), | |
| joinedload(ContractorInvoice.project) | |
| ).filter( | |
| ContractorInvoice.viewing_token == token, | |
| ContractorInvoice.deleted_at.is_(None) | |
| ).first() | |
| if not invoice: | |
| raise HTTPException(status_code=404, detail="Invoice not found") | |
| # Check token expiration | |
| from datetime import timezone | |
| now = datetime.now(timezone.utc) | |
| if invoice.viewing_token_expires_at and invoice.viewing_token_expires_at < now: | |
| raise HTTPException(status_code=403, detail="Viewing token has expired") | |
| # Track view | |
| invoice.times_viewed = (invoice.times_viewed or 0) + 1 | |
| invoice.last_viewed_at = now | |
| db.commit() | |
| # Get ticket IDs from line items | |
| ticket_ids = [ | |
| UUID(item['ticket_id']) | |
| for item in invoice.line_items | |
| if item.get('type') == 'ticket' and item.get('ticket_id') | |
| ] | |
| # Fetch tickets with details | |
| tickets = db.query(Ticket).filter( | |
| Ticket.id.in_(ticket_ids), | |
| Ticket.deleted_at.is_(None) | |
| ).all() | |
| # Build enriched line items | |
| enriched_line_items = [] | |
| for line_item in invoice.line_items: | |
| enriched_item = dict(line_item) | |
| if line_item.get('type') == 'ticket' and line_item.get('ticket_id'): | |
| ticket = next((t for t in tickets if str(t.id) == line_item['ticket_id']), None) | |
| if ticket: | |
| # Get images for this ticket | |
| ticket_images = db.query(TicketImage).filter( | |
| TicketImage.ticket_id == ticket.id, | |
| TicketImage.deleted_at.is_(None) | |
| ).all() | |
| # Build image data | |
| images_data = [] | |
| for ti in ticket_images: | |
| doc = db.query(Document).filter(Document.id == ti.document_id).first() | |
| if doc: | |
| images_data.append({ | |
| "id": str(ti.id), | |
| "image_type": ti.image_type, | |
| "description": ti.description, | |
| "url": doc.file_url, # Direct URL (Cloudinary public) | |
| "file_name": doc.file_name, | |
| "captured_at": ti.captured_at.isoformat() if ti.captured_at else None | |
| }) | |
| # Get status history | |
| status_history = db.query(TicketStatusHistory).filter( | |
| TicketStatusHistory.ticket_id == ticket.id, | |
| TicketStatusHistory.deleted_at.is_(None) | |
| ).order_by(TicketStatusHistory.changed_at).all() | |
| status_history_data = [ | |
| { | |
| "old_status": sh.old_status, | |
| "new_status": sh.new_status, | |
| "changed_at": sh.changed_at.isoformat() if sh.changed_at else None, | |
| "changed_by": sh.changed_by.name if sh.changed_by else None, | |
| "change_reason": sh.change_reason | |
| } | |
| for sh in status_history | |
| ] | |
| # Get sales order details if ticket is from sales order | |
| sales_order_details = None | |
| if ticket.source == TicketSource.SALES_ORDER.value and ticket.source_id: | |
| sales_order = db.query(SalesOrder).filter( | |
| SalesOrder.id == ticket.source_id, | |
| SalesOrder.deleted_at.is_(None) | |
| ).first() | |
| if sales_order: | |
| sales_order_details = { | |
| "order_number": sales_order.order_number, | |
| "service_request_number": sales_order.service_request_number, | |
| "customer_preferred_package": sales_order.customer_preferred_package, | |
| "package_price": float(sales_order.package_price) if sales_order.package_price else None, | |
| "installation_address_line1": sales_order.installation_address_line1, | |
| "installation_address_line2": sales_order.installation_address_line2, | |
| "installation_maps_link": sales_order.installation_maps_link, | |
| "preferred_visit_date": sales_order.preferred_visit_date.isoformat() if sales_order.preferred_visit_date else None, | |
| "preferred_visit_time": sales_order.preferred_visit_time, | |
| "received_date": sales_order.received_date.isoformat() if sales_order.received_date else None, | |
| "agent_name": sales_order.agent_name, | |
| "agent_number": sales_order.agent_number | |
| } | |
| # Get region details | |
| region_data = None | |
| if ticket.project_region_id: | |
| region = db.query(ProjectRegion).filter( | |
| ProjectRegion.id == ticket.project_region_id, | |
| ProjectRegion.deleted_at.is_(None) | |
| ).first() | |
| if region: | |
| region_data = { | |
| "id": str(region.id), | |
| "region_name": region.region_name, | |
| "region_code": region.region_code | |
| } | |
| enriched_item['ticket_details'] = { | |
| "id": str(ticket.id), | |
| "ticket_name": ticket.ticket_name, | |
| "ticket_type": ticket.ticket_type, | |
| "work_description": ticket.work_description, | |
| "completed_at": ticket.completed_at.isoformat() if ticket.completed_at else None, | |
| "scheduled_date": ticket.scheduled_date.isoformat() if ticket.scheduled_date else None, | |
| "work_location": { | |
| "latitude": float(ticket.work_location_latitude) if ticket.work_location_latitude else None, | |
| "longitude": float(ticket.work_location_longitude) if ticket.work_location_longitude else None | |
| } if ticket.work_location_latitude else None, | |
| "completion_data": ticket.completion_data or {}, | |
| "images": images_data, | |
| "images_count": len(images_data), | |
| "status_history": status_history_data, | |
| "sales_order": sales_order_details, | |
| "region": region_data | |
| } | |
| enriched_line_items.append(enriched_item) | |
| # Get organization names | |
| contractor_name = invoice.contractor.name if invoice.contractor else None | |
| client_name = invoice.client.name if invoice.client else None | |
| project_name = invoice.project.title if invoice.project else None | |
| # Debug logging | |
| logger.info(f"Invoice {invoice.invoice_number}: contractor={contractor_name}, client={client_name}, project={project_name}") | |
| # Build response | |
| return { | |
| "invoice": { | |
| "id": str(invoice.id), | |
| "invoice_number": invoice.invoice_number, | |
| "invoice_title": invoice.invoice_title, | |
| "contractor_id": str(invoice.contractor_id), | |
| "contractor_name": contractor_name, | |
| "client_id": str(invoice.client_id), | |
| "client_name": client_name, | |
| "project_id": str(invoice.project_id) if invoice.project_id else None, | |
| "project_name": project_name, | |
| "issue_date": invoice.issue_date.isoformat(), | |
| "due_date": invoice.due_date.isoformat(), | |
| "subtotal": float(invoice.subtotal), | |
| "tax_rate": float(invoice.tax_rate), | |
| "tax_amount": float(invoice.tax_amount), | |
| "discount_amount": float(invoice.discount_amount), | |
| "total_amount": float(invoice.total_amount), | |
| "amount_paid": float(invoice.amount_paid), | |
| "amount_due": float(invoice.amount_due), | |
| "currency": invoice.currency, | |
| "status": invoice.status, | |
| "notes": invoice.notes, | |
| "terms_and_conditions": invoice.terms_and_conditions, | |
| "line_items": enriched_line_items | |
| }, | |
| "token_expires_at": invoice.viewing_token_expires_at.isoformat() if invoice.viewing_token_expires_at else None, | |
| "times_viewed": invoice.times_viewed | |
| } | |