# services/suppliers/app/api/suppliers.py """ Supplier CRUD API endpoints (ATOMIC) """ from fastapi import APIRouter, Depends, HTTPException, Query, Path from typing import List, Optional, Dict, Any from uuid import UUID import structlog from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.core.database import get_db from app.services.supplier_service import SupplierService from app.models.suppliers import SupplierPriceList from app.models import AuditLog from app.schemas.suppliers import ( SupplierCreate, SupplierUpdate, SupplierResponse, SupplierSummary, SupplierSearchParams, SupplierDeletionSummary, SupplierPriceListCreate, SupplierPriceListUpdate, SupplierPriceListResponse ) from shared.auth.decorators import get_current_user_dep from shared.routing import RouteBuilder from shared.auth.access_control import require_user_role from shared.security import create_audit_logger, AuditSeverity, AuditAction # Create route builder for consistent URL structure route_builder = RouteBuilder('suppliers') router = APIRouter(tags=["suppliers"]) logger = structlog.get_logger() audit_logger = create_audit_logger("suppliers-service", AuditLog) @router.post(route_builder.build_base_route(""), response_model=SupplierResponse) @require_user_role(['admin', 'owner', 'member']) async def create_supplier( supplier_data: SupplierCreate, tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create a new supplier""" try: service = SupplierService(db) # Get user role from current_user dict user_role = current_user.get("role", "member").lower() supplier = await service.create_supplier( tenant_id=UUID(tenant_id), supplier_data=supplier_data, created_by=current_user["user_id"], created_by_role=user_role ) return SupplierResponse.from_orm(supplier) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error creating supplier", error=str(e)) raise HTTPException(status_code=500, detail="Failed to create supplier") @router.get(route_builder.build_base_route(""), response_model=List[SupplierSummary]) async def list_suppliers( tenant_id: str = Path(..., description="Tenant ID"), search_term: Optional[str] = Query(None, description="Search term"), supplier_type: Optional[str] = Query(None, description="Supplier type filter"), status: Optional[str] = Query(None, description="Status filter"), limit: int = Query(50, ge=1, le=1000, description="Number of results to return"), offset: int = Query(0, ge=0, description="Number of results to skip"), db: AsyncSession = Depends(get_db) ): """List suppliers with optional filters""" try: service = SupplierService(db) search_params = SupplierSearchParams( search_term=search_term, supplier_type=supplier_type, status=status, limit=limit, offset=offset ) suppliers = await service.search_suppliers( tenant_id=UUID(tenant_id), search_params=search_params ) return [SupplierSummary.from_orm(supplier) for supplier in suppliers] except Exception as e: logger.error("Error listing suppliers", error=str(e)) raise HTTPException(status_code=500, detail="Failed to retrieve suppliers") @router.get(route_builder.build_base_route("batch"), response_model=List[SupplierSummary]) async def get_suppliers_batch( tenant_id: str = Path(..., description="Tenant ID"), ids: str = Query(..., description="Comma-separated supplier IDs"), db: AsyncSession = Depends(get_db) ): """ Get multiple suppliers in a single call for performance optimization. This endpoint is designed to eliminate N+1 query patterns when fetching supplier data for multiple purchase orders or other entities. Args: tenant_id: Tenant ID ids: Comma-separated supplier IDs (e.g., "abc123,def456,xyz789") Returns: List of supplier summaries for the requested IDs """ try: service = SupplierService(db) # Parse comma-separated IDs supplier_ids = [id.strip() for id in ids.split(",") if id.strip()] if not supplier_ids: return [] if len(supplier_ids) > 100: raise HTTPException( status_code=400, detail="Maximum 100 supplier IDs allowed per batch request" ) # Convert to UUIDs try: uuid_ids = [UUID(id) for id in supplier_ids] except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid supplier ID format: {e}") # Fetch suppliers suppliers = await service.get_suppliers_batch(tenant_id=UUID(tenant_id), supplier_ids=uuid_ids) logger.info( "Batch retrieved suppliers", tenant_id=tenant_id, requested_count=len(supplier_ids), found_count=len(suppliers) ) return [SupplierSummary.from_orm(supplier) for supplier in suppliers] except HTTPException: raise except Exception as e: logger.error("Error batch retrieving suppliers", error=str(e), tenant_id=tenant_id) raise HTTPException(status_code=500, detail="Failed to retrieve suppliers") @router.get(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse) async def get_supplier( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """Get supplier by ID""" try: service = SupplierService(db) supplier = await service.get_supplier(supplier_id) if not supplier: raise HTTPException(status_code=404, detail="Supplier not found") return SupplierResponse.from_orm(supplier) except HTTPException: raise except Exception as e: logger.error("Error getting supplier", supplier_id=str(supplier_id), error=str(e)) raise HTTPException(status_code=500, detail="Failed to retrieve supplier") @router.put(route_builder.build_resource_detail_route("", "supplier_id"), response_model=SupplierResponse) @require_user_role(['admin', 'owner', 'member']) async def update_supplier( supplier_data: SupplierUpdate, supplier_id: UUID = Path(..., description="Supplier ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update supplier information""" try: service = SupplierService(db) # Check supplier exists existing_supplier = await service.get_supplier(supplier_id) if not existing_supplier: raise HTTPException(status_code=404, detail="Supplier not found") supplier = await service.update_supplier( supplier_id=supplier_id, supplier_data=supplier_data, updated_by=current_user["user_id"] ) if not supplier: raise HTTPException(status_code=404, detail="Supplier not found") return SupplierResponse.from_orm(supplier) except HTTPException: raise except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error("Error updating supplier", supplier_id=str(supplier_id), error=str(e)) raise HTTPException(status_code=500, detail="Failed to update supplier") @router.delete(route_builder.build_resource_detail_route("", "supplier_id")) @require_user_role(['admin', 'owner']) async def delete_supplier( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Delete supplier (soft delete, Admin+ only)""" try: service = SupplierService(db) # Check supplier exists existing_supplier = await service.get_supplier(supplier_id) if not existing_supplier: raise HTTPException(status_code=404, detail="Supplier not found") # Capture supplier data before deletion supplier_data = { "supplier_name": existing_supplier.name, "supplier_type": existing_supplier.supplier_type, "contact_person": existing_supplier.contact_person, "email": existing_supplier.email } success = await service.delete_supplier(supplier_id) if not success: raise HTTPException(status_code=404, detail="Supplier not found") # Log audit event for supplier deletion try: # Get sync db session for audit logging from app.core.database import SessionLocal sync_db = SessionLocal() try: await audit_logger.log_deletion( db_session=sync_db, tenant_id=tenant_id, user_id=current_user["user_id"], resource_type="supplier", resource_id=str(supplier_id), resource_data=supplier_data, description=f"Admin {current_user.get('email', 'unknown')} deleted supplier", endpoint=f"/suppliers/{supplier_id}", method="DELETE" ) sync_db.commit() finally: sync_db.close() except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) logger.info("Deleted supplier", supplier_id=str(supplier_id), tenant_id=tenant_id, user_id=current_user["user_id"]) return {"message": "Supplier deleted successfully"} except HTTPException: raise except Exception as e: logger.error("Error deleting supplier", supplier_id=str(supplier_id), error=str(e)) raise HTTPException(status_code=500, detail="Failed to delete supplier") @router.delete( route_builder.build_resource_action_route("", "supplier_id", "hard"), response_model=SupplierDeletionSummary ) @require_user_role(['admin', 'owner']) async def hard_delete_supplier( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Hard delete supplier and all associated data (Admin/Owner only, permanent)""" try: service = SupplierService(db) # Check supplier exists existing_supplier = await service.get_supplier(supplier_id) if not existing_supplier: raise HTTPException(status_code=404, detail="Supplier not found") # Capture supplier data before deletion supplier_data = { "id": str(existing_supplier.id), "name": existing_supplier.name, "status": existing_supplier.status.value, "supplier_code": existing_supplier.supplier_code } # Perform hard deletion deletion_summary = await service.hard_delete_supplier(supplier_id, UUID(tenant_id)) # Log audit event for hard deletion try: # Get sync db session for audit logging from app.core.database import SessionLocal sync_db = SessionLocal() try: await audit_logger.log_deletion( db_session=sync_db, tenant_id=tenant_id, user_id=current_user["user_id"], resource_type="supplier", resource_id=str(supplier_id), resource_data=supplier_data, description=f"Hard deleted supplier '{supplier_data['name']}' and all associated data", endpoint=f"/suppliers/{supplier_id}/hard", method="DELETE", metadata=deletion_summary ) sync_db.commit() finally: sync_db.close() except Exception as audit_error: logger.warning("Failed to log audit event", error=str(audit_error)) logger.info("Hard deleted supplier", supplier_id=str(supplier_id), tenant_id=tenant_id, user_id=current_user["user_id"], deletion_summary=deletion_summary) return deletion_summary except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: logger.error("Error hard deleting supplier", supplier_id=str(supplier_id), error=str(e)) raise HTTPException(status_code=500, detail="Failed to hard delete supplier") @router.get( route_builder.build_base_route("count"), response_model=dict ) async def count_suppliers( tenant_id: str = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """Get count of suppliers for a tenant""" try: service = SupplierService(db) # Use search with maximum allowed limit to get all suppliers search_params = SupplierSearchParams(limit=1000) suppliers = await service.search_suppliers( tenant_id=UUID(tenant_id), search_params=search_params ) count = len(suppliers) logger.info("Retrieved supplier count", tenant_id=tenant_id, count=count) return {"count": count} except Exception as e: logger.error("Error counting suppliers", tenant_id=tenant_id, error=str(e)) raise HTTPException(status_code=500, detail="Failed to count suppliers") @router.get( route_builder.build_resource_action_route("", "supplier_id", "products"), response_model=List[Dict[str, Any]] ) async def get_supplier_products( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), is_active: bool = Query(True, description="Filter by active price lists"), db: AsyncSession = Depends(get_db) ): """ Get list of product IDs that a supplier provides Returns a list of inventory product IDs from the supplier's price list """ try: # Query supplier price lists query = select(SupplierPriceList).where( SupplierPriceList.tenant_id == UUID(tenant_id), SupplierPriceList.supplier_id == supplier_id ) if is_active: query = query.where(SupplierPriceList.is_active == True) result = await db.execute(query) price_lists = result.scalars().all() # Extract unique product IDs product_ids = list(set([str(pl.inventory_product_id) for pl in price_lists])) logger.info( "Retrieved supplier products", supplier_id=str(supplier_id), product_count=len(product_ids) ) return [{"inventory_product_id": pid} for pid in product_ids] except Exception as e: logger.error( "Error getting supplier products", supplier_id=str(supplier_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to retrieve supplier products" ) @router.get( route_builder.build_resource_action_route("", "supplier_id", "price-lists"), response_model=List[SupplierPriceListResponse] ) async def get_supplier_price_lists( supplier_id: UUID = Path(..., description="Supplier ID"), tenant_id: str = Path(..., description="Tenant ID"), is_active: bool = Query(True, description="Filter by active price lists"), db: AsyncSession = Depends(get_db) ): """Get all price list items for a supplier""" try: service = SupplierService(db) price_lists = await service.get_supplier_price_lists( supplier_id=supplier_id, tenant_id=UUID(tenant_id), is_active=is_active ) logger.info( "Retrieved supplier price lists", supplier_id=str(supplier_id), count=len(price_lists) ) return [SupplierPriceListResponse.from_orm(pl) for pl in price_lists] except Exception as e: logger.error( "Error getting supplier price lists", supplier_id=str(supplier_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to retrieve supplier price lists" ) @router.get( route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"), response_model=SupplierPriceListResponse ) async def get_supplier_price_list( supplier_id: UUID = Path(..., description="Supplier ID"), price_list_id: UUID = Path(..., description="Price List ID"), tenant_id: str = Path(..., description="Tenant ID"), db: AsyncSession = Depends(get_db) ): """Get specific price list item for a supplier""" try: service = SupplierService(db) price_list = await service.get_supplier_price_list( price_list_id=price_list_id, tenant_id=UUID(tenant_id) ) if not price_list: raise HTTPException(status_code=404, detail="Price list item not found") logger.info( "Retrieved supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id) ) return SupplierPriceListResponse.from_orm(price_list) except HTTPException: raise except Exception as e: logger.error( "Error getting supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to retrieve supplier price list item" ) @router.post( route_builder.build_resource_action_route("", "supplier_id", "price-lists"), response_model=SupplierPriceListResponse ) @require_user_role(['admin', 'owner', 'member']) async def create_supplier_price_list( supplier_id: UUID = Path(..., description="Supplier ID"), price_list_data: SupplierPriceListCreate = None, tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Create a new price list item for a supplier""" try: service = SupplierService(db) # Verify supplier exists supplier = await service.get_supplier(supplier_id) if not supplier: raise HTTPException(status_code=404, detail="Supplier not found") price_list = await service.create_supplier_price_list( supplier_id=supplier_id, price_list_data=price_list_data, tenant_id=UUID(tenant_id), created_by=UUID(current_user["user_id"]) ) logger.info( "Created supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list.id) ) return SupplierPriceListResponse.from_orm(price_list) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error( "Error creating supplier price list item", supplier_id=str(supplier_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to create supplier price list item" ) @router.put( route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}"), response_model=SupplierPriceListResponse ) @require_user_role(['admin', 'owner', 'member']) async def update_supplier_price_list( supplier_id: UUID = Path(..., description="Supplier ID"), price_list_id: UUID = Path(..., description="Price List ID"), price_list_data: SupplierPriceListUpdate = None, tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Update a price list item for a supplier""" try: service = SupplierService(db) # Verify supplier and price list exist supplier = await service.get_supplier(supplier_id) if not supplier: raise HTTPException(status_code=404, detail="Supplier not found") price_list = await service.get_supplier_price_list( price_list_id=price_list_id, tenant_id=UUID(tenant_id) ) if not price_list: raise HTTPException(status_code=404, detail="Price list item not found") updated_price_list = await service.update_supplier_price_list( price_list_id=price_list_id, price_list_data=price_list_data, tenant_id=UUID(tenant_id), updated_by=UUID(current_user["user_id"]) ) logger.info( "Updated supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id) ) return SupplierPriceListResponse.from_orm(updated_price_list) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error( "Error updating supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to update supplier price list item" ) @router.delete( route_builder.build_resource_action_route("", "supplier_id", "price-lists/{price_list_id}") ) @require_user_role(['admin', 'owner']) async def delete_supplier_price_list( supplier_id: UUID = Path(..., description="Supplier ID"), price_list_id: UUID = Path(..., description="Price List ID"), tenant_id: str = Path(..., description="Tenant ID"), current_user: Dict[str, Any] = Depends(get_current_user_dep), db: AsyncSession = Depends(get_db) ): """Delete a price list item for a supplier""" try: service = SupplierService(db) # Verify supplier and price list exist supplier = await service.get_supplier(supplier_id) if not supplier: raise HTTPException(status_code=404, detail="Supplier not found") price_list = await service.get_supplier_price_list( price_list_id=price_list_id, tenant_id=UUID(tenant_id) ) if not price_list: raise HTTPException(status_code=404, detail="Price list item not found") success = await service.delete_supplier_price_list( price_list_id=price_list_id, tenant_id=UUID(tenant_id) ) if not success: raise HTTPException(status_code=404, detail="Price list item not found") logger.info( "Deleted supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id) ) return {"message": "Price list item deleted successfully"} except Exception as e: logger.error( "Error deleting supplier price list item", supplier_id=str(supplier_id), price_list_id=str(price_list_id), error=str(e) ) raise HTTPException( status_code=500, detail="Failed to delete supplier price list item" )