""" Procurement Service Client for Inter-Service Communication Provides API client for procurement operations and internal transfers """ import structlog from typing import Dict, Any, List, Optional from datetime import date from shared.clients.base_service_client import BaseServiceClient from shared.config.base import BaseServiceSettings logger = structlog.get_logger() class ProcurementServiceClient(BaseServiceClient): """Client for communicating with the Procurement Service""" def __init__(self, config: BaseServiceSettings, service_name: str = "unknown"): super().__init__(service_name, config) self.service_base_url = config.PROCUREMENT_SERVICE_URL def get_service_base_path(self) -> str: return "/api/v1" # ================================================================ # PURCHASE ORDER ENDPOINTS # ================================================================ async def create_purchase_order( self, tenant_id: str, order_data: Dict[str, Any] ) -> Optional[Dict[str, Any]]: """ Create a new purchase order Args: tenant_id: Tenant ID order_data: Purchase order data Returns: Created purchase order """ try: response = await self.post( "procurement/purchase-orders", data=order_data, tenant_id=tenant_id ) if response: logger.info("Created purchase order", tenant_id=tenant_id, po_number=response.get("po_number")) return response except Exception as e: logger.error("Error creating purchase order", tenant_id=tenant_id, error=str(e)) return None async def get_purchase_order( self, tenant_id: str, po_id: str ) -> Optional[Dict[str, Any]]: """ Get a specific purchase order Args: tenant_id: Tenant ID po_id: Purchase order ID Returns: Purchase order details """ try: response = await self.get( f"procurement/purchase-orders/{po_id}", tenant_id=tenant_id ) if response: logger.info("Retrieved purchase order", tenant_id=tenant_id, po_id=po_id) return response except Exception as e: logger.error("Error getting purchase order", tenant_id=tenant_id, po_id=po_id, error=str(e)) return None async def update_purchase_order_status( self, tenant_id: str, po_id: str, new_status: str, user_id: str ) -> Optional[Dict[str, Any]]: """ Update purchase order status Args: tenant_id: Tenant ID po_id: Purchase order ID new_status: New status user_id: User ID performing update Returns: Updated purchase order """ try: response = await self.put( f"procurement/purchase-orders/{po_id}/status", data={ "status": new_status, "updated_by_user_id": user_id }, tenant_id=tenant_id ) if response: logger.info("Updated purchase order status", tenant_id=tenant_id, po_id=po_id, new_status=new_status) return response except Exception as e: logger.error("Error updating purchase order status", tenant_id=tenant_id, po_id=po_id, new_status=new_status, error=str(e)) return None async def get_pending_purchase_orders( self, tenant_id: str, limit: int = 50, enrich_supplier: bool = True ) -> Optional[List[Dict[str, Any]]]: """ Get pending purchase orders Args: tenant_id: Tenant ID limit: Maximum number of results enrich_supplier: Whether to include supplier details (default: True) Set to False for faster queries when supplier data will be fetched separately Returns: List of pending purchase orders """ try: response = await self.get( "procurement/purchase-orders", params={ "status": "pending_approval", "limit": limit, "enrich_supplier": enrich_supplier }, tenant_id=tenant_id ) if response: logger.info("Retrieved pending purchase orders", tenant_id=tenant_id, count=len(response), enriched=enrich_supplier) return response if response else [] except Exception as e: logger.error("Error getting pending purchase orders", tenant_id=tenant_id, error=str(e)) return [] async def get_purchase_orders_by_supplier( self, tenant_id: str, supplier_id: str, date_from: Optional[date] = None, date_to: Optional[date] = None, status: Optional[str] = None, limit: int = 100 ) -> Optional[List[Dict[str, Any]]]: """ Get purchase orders for a specific supplier Args: tenant_id: Tenant ID supplier_id: Supplier ID to filter by date_from: Start date for filtering date_to: End date for filtering status: Status filter (e.g., 'approved', 'delivered') limit: Maximum number of results Returns: List of purchase orders with items """ try: params = { "supplier_id": supplier_id, "limit": limit } if date_from: params["date_from"] = date_from.isoformat() if date_to: params["date_to"] = date_to.isoformat() if status: params["status"] = status response = await self.get( "procurement/purchase-orders", params=params, tenant_id=tenant_id ) if response: logger.info("Retrieved purchase orders by supplier", tenant_id=tenant_id, supplier_id=supplier_id, count=len(response)) return response if response else [] except Exception as e: logger.error("Error getting purchase orders by supplier", tenant_id=tenant_id, supplier_id=supplier_id, error=str(e)) return [] # ================================================================ # INTERNAL TRANSFER ENDPOINTS (NEW FOR ENTERPRISE TIER) # ================================================================ async def create_internal_purchase_order( self, parent_tenant_id: str, child_tenant_id: str, items: List[Dict[str, Any]], delivery_date: date, notes: Optional[str] = None ) -> Optional[Dict[str, Any]]: """ Create an internal purchase order from parent to child tenant Args: parent_tenant_id: Parent tenant ID (supplier) child_tenant_id: Child tenant ID (buyer) items: List of items with product_id, quantity, unit_of_measure delivery_date: When child needs delivery notes: Optional notes for the transfer Returns: Created internal purchase order """ try: response = await self.post( "procurement/internal-transfers", data={ "destination_tenant_id": child_tenant_id, "items": items, "delivery_date": delivery_date.isoformat(), "notes": notes }, tenant_id=parent_tenant_id ) if response: logger.info("Created internal purchase order", parent_tenant_id=parent_tenant_id, child_tenant_id=child_tenant_id, po_number=response.get("po_number")) return response except Exception as e: logger.error("Error creating internal purchase order", parent_tenant_id=parent_tenant_id, child_tenant_id=child_tenant_id, error=str(e)) return None async def get_approved_internal_purchase_orders( self, parent_tenant_id: str, target_date: Optional[date] = None, status: Optional[str] = "approved" ) -> Optional[List[Dict[str, Any]]]: """ Get approved internal purchase orders for parent tenant Args: parent_tenant_id: Parent tenant ID target_date: Optional target date to filter status: Status filter (default: approved) Returns: List of approved internal purchase orders """ try: params = {"status": status} if target_date: params["target_date"] = target_date.isoformat() response = await self.get( "procurement/internal-transfers", params=params, tenant_id=parent_tenant_id ) if response: logger.info("Retrieved internal purchase orders", parent_tenant_id=parent_tenant_id, count=len(response)) return response if response else [] except Exception as e: logger.error("Error getting internal purchase orders", parent_tenant_id=parent_tenant_id, error=str(e)) return [] async def approve_internal_purchase_order( self, parent_tenant_id: str, po_id: str, approved_by_user_id: str ) -> Optional[Dict[str, Any]]: """ Approve an internal purchase order Args: parent_tenant_id: Parent tenant ID po_id: Purchase order ID to approve approved_by_user_id: User ID performing approval Returns: Updated purchase order """ try: response = await self.post( f"procurement/internal-transfers/{po_id}/approve", data={ "approved_by_user_id": approved_by_user_id }, tenant_id=parent_tenant_id ) if response: logger.info("Approved internal purchase order", parent_tenant_id=parent_tenant_id, po_id=po_id) return response except Exception as e: logger.error("Error approving internal purchase order", parent_tenant_id=parent_tenant_id, po_id=po_id, error=str(e)) return None async def get_internal_transfer_history( self, tenant_id: str, parent_tenant_id: Optional[str] = None, child_tenant_id: Optional[str] = None, start_date: Optional[date] = None, end_date: Optional[date] = None ) -> Optional[List[Dict[str, Any]]]: """ Get internal transfer history with optional filtering Args: tenant_id: Tenant ID (either parent or child) parent_tenant_id: Filter by specific parent tenant child_tenant_id: Filter by specific child tenant start_date: Filter by start date end_date: Filter by end date Returns: List of internal transfer records """ try: params = {} if parent_tenant_id: params["parent_tenant_id"] = parent_tenant_id if child_tenant_id: params["child_tenant_id"] = child_tenant_id if start_date: params["start_date"] = start_date.isoformat() if end_date: params["end_date"] = end_date.isoformat() response = await self.get( "procurement/internal-transfers/history", params=params, tenant_id=tenant_id ) if response: logger.info("Retrieved internal transfer history", tenant_id=tenant_id, count=len(response)) return response if response else [] except Exception as e: logger.error("Error getting internal transfer history", tenant_id=tenant_id, error=str(e)) return [] # ================================================================ # PROCUREMENT PLAN ENDPOINTS # ================================================================ async def get_procurement_plan( self, tenant_id: str, plan_id: str ) -> Optional[Dict[str, Any]]: """ Get a specific procurement plan Args: tenant_id: Tenant ID plan_id: Procurement plan ID Returns: Procurement plan details """ try: response = await self.get( f"procurement/plans/{plan_id}", tenant_id=tenant_id ) if response: logger.info("Retrieved procurement plan", tenant_id=tenant_id, plan_id=plan_id) return response except Exception as e: logger.error("Error getting procurement plan", tenant_id=tenant_id, plan_id=plan_id, error=str(e)) return None async def get_procurement_plans( self, tenant_id: str, date_from: Optional[date] = None, date_to: Optional[date] = None, status: Optional[str] = None ) -> Optional[List[Dict[str, Any]]]: """ Get procurement plans with optional filtering Args: tenant_id: Tenant ID date_from: Start date for filtering date_to: End date for filtering status: Status filter Returns: List of procurement plan dictionaries """ try: params = {} if date_from: params["date_from"] = date_from.isoformat() if date_to: params["date_to"] = date_to.isoformat() if status: params["status"] = status response = await self.get( "procurement/plans", params=params, tenant_id=tenant_id ) if response: logger.info("Retrieved procurement plans", tenant_id=tenant_id, count=len(response)) return response if response else [] except Exception as e: logger.error("Error getting procurement plans", tenant_id=tenant_id, error=str(e)) return [] # ================================================================ # SUPPLIER ENDPOINTS # ================================================================ async def get_suppliers( self, tenant_id: str ) -> Optional[List[Dict[str, Any]]]: """ Get suppliers for a tenant Args: tenant_id: Tenant ID Returns: List of supplier dictionaries """ try: response = await self.get( "procurement/suppliers", tenant_id=tenant_id ) if response: logger.info("Retrieved suppliers", tenant_id=tenant_id, count=len(response)) return response if response else [] except Exception as e: logger.error("Error getting suppliers", tenant_id=tenant_id, error=str(e)) return [] async def get_supplier( self, tenant_id: str, supplier_id: str ) -> Optional[Dict[str, Any]]: """ Get specific supplier details Args: tenant_id: Tenant ID supplier_id: Supplier ID Returns: Supplier details """ try: response = await self.get( f"procurement/suppliers/{supplier_id}", tenant_id=tenant_id ) if response: logger.info("Retrieved supplier details", tenant_id=tenant_id, supplier_id=supplier_id) return response except Exception as e: logger.error("Error getting supplier details", tenant_id=tenant_id, supplier_id=supplier_id, error=str(e)) return None # ================================================================ # UTILITIES # ================================================================ async def health_check(self) -> bool: """Check if procurement service is healthy""" try: # Use base health check method response = await self.get("health") return response is not None except Exception as e: logger.error("Procurement service health check failed", error=str(e)) return False # ================================================================ # INTERNAL TRIGGER METHODS # ================================================================ async def trigger_delivery_tracking_internal( self, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Trigger delivery tracking for a tenant (internal service use only). This method calls the internal endpoint which is protected by X-Internal-Service header. Args: tenant_id: Tenant ID to trigger delivery tracking for Returns: Dict with trigger results or None if failed """ try: # Call internal endpoint via gateway using tenant-scoped URL pattern # Endpoint: /api/v1/tenants/{tenant_id}/procurement/internal/delivery-tracking/trigger result = await self._make_request( method="POST", endpoint="procurement/internal/delivery-tracking/trigger", tenant_id=tenant_id, data={}, headers={"X-Internal-Service": "demo-session"} ) if result: logger.info( "Delivery tracking triggered successfully via internal endpoint", tenant_id=tenant_id, alerts_generated=result.get("alerts_generated", 0) ) else: logger.warning( "Delivery tracking internal endpoint returned no result", tenant_id=tenant_id ) return result except Exception as e: logger.error( "Error triggering delivery tracking via internal endpoint", tenant_id=tenant_id, error=str(e) ) return None # ================================================================ # INTERNAL AI INSIGHTS METHODS # ================================================================ async def trigger_price_insights_internal( self, tenant_id: str ) -> Optional[Dict[str, Any]]: """ Trigger price forecasting insights for a tenant (internal service use only). This method calls the internal endpoint which is protected by X-Internal-Service header. Args: tenant_id: Tenant ID to trigger insights for Returns: Dict with trigger results or None if failed """ try: result = await self._make_request( method="POST", endpoint="procurement/internal/ml/generate-price-insights", tenant_id=tenant_id, data={"tenant_id": tenant_id}, headers={"X-Internal-Service": "demo-session"} ) if result: logger.info( "Price insights triggered successfully via internal endpoint", tenant_id=tenant_id, insights_posted=result.get("insights_posted", 0) ) else: logger.warning( "Price insights internal endpoint returned no result", tenant_id=tenant_id ) return result except Exception as e: logger.error( "Error triggering price insights via internal endpoint", tenant_id=tenant_id, error=str(e) ) return None # Factory function for dependency injection def create_procurement_client(config: BaseServiceSettings, service_name: str = "unknown") -> ProcurementServiceClient: """Create procurement service client instance""" return ProcurementServiceClient(config, service_name)