# ================================================================ # services/data/app/api/sales.py - FIXED FOR NEW TENANT-SCOPED ARCHITECTURE # ================================================================ """Sales data API endpoints with tenant-scoped URLs""" from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, Response, Path from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from typing import List, Optional, Dict, Any from uuid import UUID from datetime import datetime import base64 import structlog from app.core.database import get_db from app.schemas.sales import ( SalesDataCreate, SalesDataResponse, SalesDataQuery, SalesDataImport, SalesImportResult, SalesValidationResult, SalesExportRequest ) from app.services.sales_service import SalesService from app.services.data_import_service import DataImportService from app.services.messaging import ( publish_sales_created, publish_data_imported, publish_export_completed ) # Import unified authentication from shared library from shared.auth.decorators import get_current_user_dep router = APIRouter(tags=["sales"]) logger = structlog.get_logger() # ================================================================ # TENANT-SCOPED SALES ENDPOINTS # ================================================================ @router.post("/tenants/{tenant_id}/sales", response_model=SalesDataResponse) async def create_sales_record( sales_data: SalesDataCreate, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create a new sales record for tenant""" try: logger.debug("Creating sales record", product=sales_data.product_name, quantity=sales_data.quantity_sold, tenant_id=tenant_id, user_id=current_user["user_id"]) # Override tenant_id from URL path (gateway already verified access) sales_data.tenant_id = tenant_id record = await SalesService.create_sales_record(sales_data, db) # Publish event (non-blocking) try: await publish_sales_created({ "tenant_id": tenant_id, "product_name": sales_data.product_name, "quantity_sold": sales_data.quantity_sold, "revenue": sales_data.revenue, "source": sales_data.source, "created_by": current_user["user_id"], "timestamp": datetime.utcnow().isoformat() }) except Exception as pub_error: logger.warning("Failed to publish sales created event", error=str(pub_error)) # Continue - event failure shouldn't break API logger.info("Successfully created sales record", record_id=record.id, tenant_id=tenant_id) return record except Exception as e: logger.error("Failed to create sales record", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to create sales record: {str(e)}") @router.post("/tenants/{tenant_id}/sales/bulk", response_model=List[SalesDataResponse]) async def create_bulk_sales( sales_data: List[SalesDataCreate], tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create multiple sales records for tenant""" try: logger.debug("Creating bulk sales records", count=len(sales_data), tenant_id=tenant_id) # Override tenant_id for all records for record in sales_data: record.tenant_id = tenant_id records = await SalesService.create_bulk_sales(sales_data, db) # Publish event try: await publish_data_imported({ "tenant_id": tenant_id, "type": "bulk_create", "records_created": len(records), "created_by": current_user["user_id"], "timestamp": datetime.utcnow().isoformat() }) except Exception as pub_error: logger.warning("Failed to publish bulk import event", error=str(pub_error)) logger.info("Successfully created bulk sales records", count=len(records), tenant_id=tenant_id) return records except Exception as e: logger.error("Failed to create bulk sales records", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to create bulk sales records: {str(e)}") @router.get("/tenants/{tenant_id}/sales", response_model=List[SalesDataResponse]) async def get_sales_data( tenant_id: UUID = Path(..., description="Tenant ID"), start_date: Optional[datetime] = Query(None, description="Start date filter"), end_date: Optional[datetime] = Query(None, description="End date filter"), product_name: Optional[str] = Query(None, description="Product name filter"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get sales data for tenant with filters""" try: logger.debug("Querying sales data", tenant_id=tenant_id, start_date=start_date, end_date=end_date, product_name=product_name) query = SalesDataQuery( tenant_id=tenant_id, start_date=start_date, end_date=end_date, product_name=product_name ) records = await SalesService.get_sales_data(query, db) logger.debug("Successfully retrieved sales data", count=len(records), tenant_id=tenant_id) return records except Exception as e: logger.error("Failed to query sales data", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to query sales data: {str(e)}") @router.post("/tenants/{tenant_id}/sales/import", response_model=SalesImportResult) async def import_sales_data( tenant_id: UUID = Path(..., description="Tenant ID"), file: UploadFile = File(...), file_format: str = Form(...), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Import sales data from file for tenant - FIXED VERSION""" try: logger.info("Importing sales data", tenant_id=tenant_id, format=file_format, filename=file.filename, user_id=current_user["user_id"]) # Read file content content = await file.read() file_content = content.decode('utf-8') # ✅ FIX: tenant_id comes from URL path, not file upload result = await DataImportService.process_upload( tenant_id, file_content, file_format, db, filename=file.filename ) if result["success"]: # Publish event try: await publish_data_imported({ "tenant_id": str(tenant_id), # Ensure string conversion "type": "file_import", "format": file_format, "filename": file.filename, "records_created": result["records_created"], "imported_by": current_user["user_id"], "timestamp": datetime.utcnow().isoformat() }) except Exception as pub_error: logger.warning("Failed to publish import event", error=str(pub_error)) logger.info("Import completed", success=result["success"], records_created=result.get("records_created", 0), tenant_id=tenant_id) return result except Exception as e: logger.error("Failed to import sales data", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to import sales data: {str(e)}") @router.post("/tenants/{tenant_id}/sales/import/validate", response_model=SalesValidationResult) async def validate_import_data( import_data: SalesDataImport, tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Validate import data - Gateway already verified tenant access""" try: logger.debug("Validating import data", tenant_id=tenant_id, user_id=current_user["user_id"]) # Set tenant context from URL path import_data.tenant_id = tenant_id validation = await DataImportService.validate_import_data(import_data.model_dump()) logger.debug("Validation completed", is_valid=validation.get("is_valid", False), tenant_id=tenant_id) return validation except Exception as e: logger.error("Failed to validate import data", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to validate import data: {str(e)}") @router.get("/tenants/{tenant_id}/sales/import/template/{format_type}") async def get_import_template( tenant_id: UUID = Path(..., description="Tenant ID"), format_type: str = Path(..., description="Template format: csv, json, excel"), current_user: Dict[str, Any] = Depends(get_current_user_dep) ): """Get import template for specified format""" try: logger.debug("Getting import template", format=format_type, tenant_id=tenant_id, user_id=current_user["user_id"]) template = await DataImportService.get_import_template(format_type) if "error" in template: logger.warning("Template generation error", error=template["error"]) raise HTTPException(status_code=400, detail=template["error"]) logger.debug("Template generated successfully", format=format_type, tenant_id=tenant_id) if format_type.lower() == "csv": return Response( content=template["template"], media_type="text/csv", headers={"Content-Disposition": f"attachment; filename={template['filename']}"} ) elif format_type.lower() == "json": return Response( content=template["template"], media_type="application/json", headers={"Content-Disposition": f"attachment; filename={template['filename']}"} ) elif format_type.lower() in ["excel", "xlsx"]: return Response( content=base64.b64decode(template["template"]), media_type=template["content_type"], headers={"Content-Disposition": f"attachment; filename={template['filename']}"} ) else: return template except HTTPException: raise except Exception as e: logger.error("Failed to generate import template", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate template: {str(e)}") @router.get("/tenants/{tenant_id}/sales/analytics") async def get_sales_analytics( tenant_id: UUID = Path(..., description="Tenant ID"), start_date: Optional[datetime] = Query(None, description="Start date"), end_date: Optional[datetime] = Query(None, description="End date"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get sales analytics for tenant""" try: logger.debug("Getting sales analytics", tenant_id=tenant_id, start_date=start_date, end_date=end_date) analytics = await SalesService.get_sales_analytics( tenant_id, start_date, end_date, db ) logger.debug("Analytics generated successfully", tenant_id=tenant_id) return analytics except Exception as e: logger.error("Failed to generate sales analytics", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate analytics: {str(e)}") @router.post("/tenants/{tenant_id}/sales/export") async def export_sales_data( tenant_id: UUID = Path(..., description="Tenant ID"), export_format: str = Query("csv", description="Export format: csv, excel, json"), start_date: Optional[datetime] = Query(None, description="Start date"), end_date: Optional[datetime] = Query(None, description="End date"), products: Optional[List[str]] = Query(None, description="Filter by products"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Export sales data in specified format for tenant""" try: logger.info("Exporting sales data", tenant_id=tenant_id, format=export_format, user_id=current_user["user_id"]) export_result = await SalesService.export_sales_data( tenant_id, export_format, start_date, end_date, products, db ) if not export_result: raise HTTPException(status_code=404, detail="No data found for export") # Publish export event try: await publish_export_completed({ "tenant_id": tenant_id, "format": export_format, "exported_by": current_user["user_id"], "record_count": export_result.get("record_count", 0), "timestamp": datetime.utcnow().isoformat() }) except Exception as pub_error: logger.warning("Failed to publish export event", error=str(pub_error)) logger.info("Export completed successfully", tenant_id=tenant_id, format=export_format) return StreamingResponse( iter([export_result["content"]]), media_type=export_result["media_type"], headers={"Content-Disposition": f"attachment; filename={export_result['filename']}"} ) except HTTPException: raise except Exception as e: logger.error("Failed to export sales data", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to export sales data: {str(e)}") @router.delete("/tenants/{tenant_id}/sales/{record_id}") async def delete_sales_record( tenant_id: UUID = Path(..., description="Tenant ID"), record_id: str = Path(..., description="Sales record ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Delete a sales record for tenant""" try: logger.info("Deleting sales record", record_id=record_id, tenant_id=tenant_id, user_id=current_user["user_id"]) # Verify record belongs to tenant before deletion record = await SalesService.get_sales_record(record_id, db) if not record or record.tenant_id != tenant_id: raise HTTPException(status_code=404, detail="Sales record not found") success = await SalesService.delete_sales_record(record_id, db) if not success: raise HTTPException(status_code=404, detail="Sales record not found") logger.info("Sales record deleted successfully", record_id=record_id, tenant_id=tenant_id) return {"status": "success", "message": "Sales record deleted successfully"} except HTTPException: raise except Exception as e: logger.error("Failed to delete sales record", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to delete sales record: {str(e)}") @router.get("/tenants/{tenant_id}/sales/summary") async def get_sales_summary( tenant_id: UUID = Path(..., description="Tenant ID"), period: str = Query("daily", description="Summary period: daily, weekly, monthly"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get sales summary for specified period for tenant""" try: logger.debug("Getting sales summary", tenant_id=tenant_id, period=period) summary = await SalesService.get_sales_summary(tenant_id, period, db) logger.debug("Summary generated successfully", tenant_id=tenant_id) return summary except Exception as e: logger.error("Failed to generate sales summary", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to generate summary: {str(e)}") @router.get("/tenants/{tenant_id}/sales/products") async def get_products_list( tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get list of all products with sales data for tenant""" try: logger.debug("Getting products list", tenant_id=tenant_id) products = await SalesService.get_products_list(tenant_id, db) logger.debug("Products list retrieved", count=len(products), tenant_id=tenant_id) return products except Exception as e: logger.error("Failed to get products list", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail=f"Failed to get products list: {str(e)}") @router.get("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse) async def get_sales_record( tenant_id: UUID = Path(..., description="Tenant ID"), record_id: str = Path(..., description="Sales record ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Get a specific sales record for tenant""" try: logger.debug("Getting sales record", record_id=record_id, tenant_id=tenant_id) record = await SalesService.get_sales_record(record_id, db) if not record or record.tenant_id != tenant_id: raise HTTPException(status_code=404, detail="Sales record not found") logger.debug("Sales record retrieved", record_id=record_id, tenant_id=tenant_id) return record except HTTPException: raise except Exception as e: logger.error("Failed to get sales record", error=str(e), tenant_id=tenant_id, record_id=record_id) raise HTTPException(status_code=500, detail=f"Failed to get sales record: {str(e)}") @router.put("/tenants/{tenant_id}/sales/{record_id}", response_model=SalesDataResponse) async def update_sales_record( sales_data: SalesDataCreate, record_id: str = Path(..., description="Sales record ID"), tenant_id: UUID = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update a sales record for tenant""" try: logger.info("Updating sales record", record_id=record_id, tenant_id=tenant_id, user_id=current_user["user_id"]) # Verify record exists and belongs to tenant existing_record = await SalesService.get_sales_record(record_id, db) if not existing_record or existing_record.tenant_id != tenant_id: raise HTTPException(status_code=404, detail="Sales record not found") # Override tenant_id from URL path sales_data.tenant_id = tenant_id updated_record = await SalesService.update_sales_record(record_id, sales_data, db) if not updated_record: raise HTTPException(status_code=404, detail="Sales record not found") logger.info("Sales record updated successfully", record_id=record_id, tenant_id=tenant_id) return updated_record except HTTPException: raise except Exception as e: logger.error("Failed to update sales record", error=str(e), tenant_id=tenant_id, record_id=record_id) raise HTTPException(status_code=500, detail=f"Failed to update sales record: {str(e)}")