New alert service
This commit is contained in:
@@ -18,6 +18,7 @@ from .suppliers_client import SuppliersServiceClient
|
||||
from .tenant_client import TenantServiceClient
|
||||
from .ai_insights_client import AIInsightsClient
|
||||
from .alerts_client import AlertsServiceClient
|
||||
from .alert_processor_client import AlertProcessorClient, get_alert_processor_client
|
||||
from .procurement_client import ProcurementServiceClient
|
||||
from .distribution_client import DistributionServiceClient
|
||||
|
||||
@@ -158,6 +159,10 @@ def get_distribution_client(config: BaseServiceSettings = None, service_name: st
|
||||
return _client_cache[cache_key]
|
||||
|
||||
|
||||
# Note: get_alert_processor_client is already defined in alert_processor_client.py
|
||||
# and imported above, so we don't need to redefine it here
|
||||
|
||||
|
||||
class ServiceClients:
|
||||
"""Convenient wrapper for all service clients"""
|
||||
|
||||
@@ -267,6 +272,7 @@ __all__ = [
|
||||
'RecipesServiceClient',
|
||||
'SuppliersServiceClient',
|
||||
'AlertsServiceClient',
|
||||
'AlertProcessorClient',
|
||||
'TenantServiceClient',
|
||||
'DistributionServiceClient',
|
||||
'ServiceClients',
|
||||
@@ -280,6 +286,7 @@ __all__ = [
|
||||
'get_recipes_client',
|
||||
'get_suppliers_client',
|
||||
'get_alerts_client',
|
||||
'get_alert_processor_client',
|
||||
'get_tenant_client',
|
||||
'get_procurement_client',
|
||||
'get_distribution_client',
|
||||
|
||||
220
shared/clients/alert_processor_client.py
Normal file
220
shared/clients/alert_processor_client.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# shared/clients/alert_processor_client.py
|
||||
"""
|
||||
Alert Processor Service Client - Inter-service communication
|
||||
Handles communication with the alert processor service for alert lifecycle management
|
||||
"""
|
||||
|
||||
import structlog
|
||||
from typing import Dict, Any, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from shared.clients.base_service_client import BaseServiceClient
|
||||
from shared.config.base import BaseServiceSettings
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class AlertProcessorClient(BaseServiceClient):
|
||||
"""Client for communicating with the alert processor service via gateway"""
|
||||
|
||||
def __init__(self, config: BaseServiceSettings, calling_service_name: str = "unknown"):
|
||||
super().__init__(calling_service_name, config)
|
||||
|
||||
def get_service_base_path(self) -> str:
|
||||
"""Return the base path for alert processor service APIs"""
|
||||
return "/api/v1"
|
||||
|
||||
# ================================================================
|
||||
# ALERT LIFECYCLE MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
async def acknowledge_alerts_by_metadata(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
alert_type: str,
|
||||
metadata_filter: Dict[str, Any],
|
||||
acknowledged_by: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Acknowledge all active alerts matching alert type and metadata.
|
||||
|
||||
Used when user actions trigger alert acknowledgment (e.g., approving a PO).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
alert_type: Alert type to filter (e.g., 'po_approval_needed')
|
||||
metadata_filter: Metadata fields to match (e.g., {'po_id': 'uuid'})
|
||||
acknowledged_by: Optional user ID who acknowledged
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"acknowledged_count": 2,
|
||||
"alert_ids": ["uuid1", "uuid2"]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"alert_type": alert_type,
|
||||
"metadata_filter": metadata_filter
|
||||
}
|
||||
|
||||
if acknowledged_by:
|
||||
payload["acknowledged_by"] = acknowledged_by
|
||||
|
||||
result = await self.post(
|
||||
f"tenants/{tenant_id}/alerts/acknowledge-by-metadata",
|
||||
tenant_id=str(tenant_id),
|
||||
json=payload
|
||||
)
|
||||
|
||||
if result and result.get("success"):
|
||||
logger.info(
|
||||
"Acknowledged alerts by metadata",
|
||||
tenant_id=str(tenant_id),
|
||||
alert_type=alert_type,
|
||||
count=result.get("acknowledged_count", 0),
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
|
||||
return result or {"success": False, "acknowledged_count": 0, "alert_ids": []}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error acknowledging alerts by metadata",
|
||||
error=str(e),
|
||||
tenant_id=str(tenant_id),
|
||||
alert_type=alert_type,
|
||||
metadata_filter=metadata_filter,
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
return {"success": False, "acknowledged_count": 0, "alert_ids": [], "error": str(e)}
|
||||
|
||||
async def resolve_alerts_by_metadata(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
alert_type: str,
|
||||
metadata_filter: Dict[str, Any],
|
||||
resolved_by: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resolve all active alerts matching alert type and metadata.
|
||||
|
||||
Used when user actions complete an alert's underlying issue (e.g., marking delivery received).
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
alert_type: Alert type to filter (e.g., 'delivery_overdue')
|
||||
metadata_filter: Metadata fields to match (e.g., {'po_id': 'uuid'})
|
||||
resolved_by: Optional user ID who resolved
|
||||
|
||||
Returns:
|
||||
{
|
||||
"success": true,
|
||||
"resolved_count": 1,
|
||||
"alert_ids": ["uuid1"]
|
||||
}
|
||||
"""
|
||||
try:
|
||||
payload = {
|
||||
"alert_type": alert_type,
|
||||
"metadata_filter": metadata_filter
|
||||
}
|
||||
|
||||
if resolved_by:
|
||||
payload["resolved_by"] = resolved_by
|
||||
|
||||
result = await self.post(
|
||||
f"tenants/{tenant_id}/alerts/resolve-by-metadata",
|
||||
tenant_id=str(tenant_id),
|
||||
json=payload
|
||||
)
|
||||
|
||||
if result and result.get("success"):
|
||||
logger.info(
|
||||
"Resolved alerts by metadata",
|
||||
tenant_id=str(tenant_id),
|
||||
alert_type=alert_type,
|
||||
count=result.get("resolved_count", 0),
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
|
||||
return result or {"success": False, "resolved_count": 0, "alert_ids": []}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error resolving alerts by metadata",
|
||||
error=str(e),
|
||||
tenant_id=str(tenant_id),
|
||||
alert_type=alert_type,
|
||||
metadata_filter=metadata_filter,
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
return {"success": False, "resolved_count": 0, "alert_ids": [], "error": str(e)}
|
||||
|
||||
async def get_active_alerts(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
priority_level: Optional[str] = None,
|
||||
limit: int = 100
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get active alerts for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant UUID
|
||||
priority_level: Optional priority filter (critical, important, standard, info)
|
||||
limit: Maximum number of alerts to return
|
||||
|
||||
Returns:
|
||||
List of alert dictionaries
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"status": "active",
|
||||
"limit": limit
|
||||
}
|
||||
|
||||
if priority_level:
|
||||
params["priority_level"] = priority_level
|
||||
|
||||
result = await self.get(
|
||||
f"tenants/{tenant_id}/alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
params=params
|
||||
)
|
||||
|
||||
alerts = result.get("alerts", []) if isinstance(result, dict) else []
|
||||
|
||||
logger.info(
|
||||
"Retrieved active alerts",
|
||||
tenant_id=str(tenant_id),
|
||||
count=len(alerts),
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
|
||||
return alerts
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching active alerts",
|
||||
error=str(e),
|
||||
tenant_id=str(tenant_id),
|
||||
calling_service=self.calling_service_name
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Factory function for easy import
|
||||
def get_alert_processor_client(config: BaseServiceSettings, calling_service_name: str) -> AlertProcessorClient:
|
||||
"""
|
||||
Factory function to create an AlertProcessorClient instance.
|
||||
|
||||
Args:
|
||||
config: Service configuration with gateway URL
|
||||
calling_service_name: Name of the service making the call (for logging)
|
||||
|
||||
Returns:
|
||||
AlertProcessorClient instance
|
||||
"""
|
||||
return AlertProcessorClient(config, calling_service_name)
|
||||
@@ -159,17 +159,38 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
response = await self.get(
|
||||
# Use _make_request directly to construct correct URL
|
||||
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"tenants/{tenant_id}/distribution/routes",
|
||||
params=params,
|
||||
tenant_id=tenant_id
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info("Retrieved delivery routes",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response.get("routes", [])))
|
||||
return response.get("routes", []) if response else []
|
||||
# Handle different response formats
|
||||
if isinstance(response, list):
|
||||
# Direct list of routes
|
||||
logger.info("Retrieved delivery routes",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response))
|
||||
return response
|
||||
elif isinstance(response, dict):
|
||||
# Response wrapped in routes key
|
||||
if "routes" in response:
|
||||
logger.info("Retrieved delivery routes",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response.get("routes", [])))
|
||||
return response.get("routes", [])
|
||||
else:
|
||||
# Return the whole dict if it's a single route
|
||||
logger.info("Retrieved delivery routes",
|
||||
tenant_id=tenant_id,
|
||||
count=1)
|
||||
return [response]
|
||||
logger.info("No delivery routes found",
|
||||
tenant_id=tenant_id)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery routes",
|
||||
tenant_id=tenant_id,
|
||||
@@ -193,14 +214,17 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
"""
|
||||
try:
|
||||
response = await self.get(
|
||||
f"tenants/{tenant_id}/distribution/routes/{route_id}",
|
||||
f"distribution/routes/{route_id}",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info("Retrieved delivery route detail",
|
||||
tenant_id=tenant_id,
|
||||
route_id=route_id)
|
||||
# Ensure we return the route data directly if it's wrapped in a route key
|
||||
if isinstance(response, dict) and "route" in response:
|
||||
return response["route"]
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("Error getting delivery route detail",
|
||||
@@ -241,17 +265,38 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
if status:
|
||||
params["status"] = status
|
||||
|
||||
response = await self.get(
|
||||
# Use _make_request directly to construct correct URL
|
||||
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"tenants/{tenant_id}/distribution/shipments",
|
||||
params=params,
|
||||
tenant_id=tenant_id
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info("Retrieved shipments",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response.get("shipments", [])))
|
||||
return response.get("shipments", []) if response else []
|
||||
# Handle different response formats
|
||||
if isinstance(response, list):
|
||||
# Direct list of shipments
|
||||
logger.info("Retrieved shipments",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response))
|
||||
return response
|
||||
elif isinstance(response, dict):
|
||||
# Response wrapped in shipments key
|
||||
if "shipments" in response:
|
||||
logger.info("Retrieved shipments",
|
||||
tenant_id=tenant_id,
|
||||
count=len(response.get("shipments", [])))
|
||||
return response.get("shipments", [])
|
||||
else:
|
||||
# Return the whole dict if it's a single shipment
|
||||
logger.info("Retrieved shipments",
|
||||
tenant_id=tenant_id,
|
||||
count=1)
|
||||
return [response]
|
||||
logger.info("No shipments found",
|
||||
tenant_id=tenant_id)
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Error getting shipments",
|
||||
tenant_id=tenant_id,
|
||||
@@ -275,14 +320,17 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
"""
|
||||
try:
|
||||
response = await self.get(
|
||||
f"tenants/{tenant_id}/distribution/shipments/{shipment_id}",
|
||||
f"distribution/shipments/{shipment_id}",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info("Retrieved shipment detail",
|
||||
tenant_id=tenant_id,
|
||||
shipment_id=shipment_id)
|
||||
# Ensure we return the shipment data directly if it's wrapped in a shipment key
|
||||
if isinstance(response, dict) and "shipment" in response:
|
||||
return response["shipment"]
|
||||
return response
|
||||
except Exception as e:
|
||||
logger.error("Error getting shipment detail",
|
||||
@@ -320,7 +368,7 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
}
|
||||
|
||||
response = await self.put(
|
||||
f"tenants/{tenant_id}/distribution/shipments/{shipment_id}/status",
|
||||
f"distribution/shipments/{shipment_id}/status",
|
||||
data=payload,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
@@ -343,57 +391,8 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
# INTERNAL DEMO ENDPOINTS
|
||||
# ================================================================
|
||||
|
||||
async def setup_enterprise_distribution_demo(
|
||||
self,
|
||||
parent_tenant_id: str,
|
||||
child_tenant_ids: List[str],
|
||||
session_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Internal endpoint to setup distribution for enterprise demo
|
||||
|
||||
Args:
|
||||
parent_tenant_id: Parent tenant ID
|
||||
child_tenant_ids: List of child tenant IDs
|
||||
session_id: Demo session ID
|
||||
|
||||
Returns:
|
||||
Distribution setup result
|
||||
"""
|
||||
try:
|
||||
url = f"{self.service_base_url}/api/v1/internal/demo/setup"
|
||||
|
||||
async with self.get_http_client() as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
json={
|
||||
"parent_tenant_id": parent_tenant_id,
|
||||
"child_tenant_ids": child_tenant_ids,
|
||||
"session_id": session_id
|
||||
},
|
||||
headers={
|
||||
"X-Internal-API-Key": self.config.INTERNAL_API_KEY,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
result = response.json()
|
||||
logger.info("Setup enterprise distribution demo",
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
child_count=len(child_tenant_ids))
|
||||
return result
|
||||
else:
|
||||
logger.error("Failed to setup enterprise distribution demo",
|
||||
status_code=response.status_code,
|
||||
response_text=response.text)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error setting up enterprise distribution demo",
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
error=str(e))
|
||||
return None
|
||||
# Legacy setup_enterprise_distribution_demo method removed
|
||||
# Distribution now uses standard /internal/demo/clone endpoint via DataCloner
|
||||
|
||||
async def get_shipments_for_date(
|
||||
self,
|
||||
@@ -411,21 +410,45 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
List of shipments for the date
|
||||
"""
|
||||
try:
|
||||
response = await self.get(
|
||||
# Use _make_request directly to construct correct URL
|
||||
# Gateway route: /api/v1/tenants/{tenant_id}/distribution/{path}
|
||||
response = await self._make_request(
|
||||
"GET",
|
||||
f"tenants/{tenant_id}/distribution/shipments",
|
||||
params={
|
||||
"date_from": target_date.isoformat(),
|
||||
"date_to": target_date.isoformat()
|
||||
},
|
||||
tenant_id=tenant_id
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if response:
|
||||
logger.info("Retrieved shipments for date",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date.isoformat(),
|
||||
shipment_count=len(response.get("shipments", [])))
|
||||
return response.get("shipments", []) if response else []
|
||||
# Handle different response formats
|
||||
if isinstance(response, list):
|
||||
# Direct list of shipments
|
||||
logger.info("Retrieved shipments for date",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date.isoformat(),
|
||||
shipment_count=len(response))
|
||||
return response
|
||||
elif isinstance(response, dict):
|
||||
# Response wrapped in shipments key
|
||||
if "shipments" in response:
|
||||
logger.info("Retrieved shipments for date",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date.isoformat(),
|
||||
shipment_count=len(response.get("shipments", [])))
|
||||
return response.get("shipments", [])
|
||||
else:
|
||||
# Return the whole dict if it's a single shipment
|
||||
logger.info("Retrieved shipments for date",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date.isoformat(),
|
||||
shipment_count=1)
|
||||
return [response]
|
||||
logger.info("No shipments found for date",
|
||||
tenant_id=tenant_id,
|
||||
target_date=target_date.isoformat())
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error("Error getting shipments for date",
|
||||
tenant_id=tenant_id,
|
||||
@@ -451,4 +474,4 @@ class DistributionServiceClient(BaseServiceClient):
|
||||
# Factory function for dependency injection
|
||||
def create_distribution_client(config: BaseServiceSettings, service_name: str = "unknown") -> DistributionServiceClient:
|
||||
"""Create distribution service client instance"""
|
||||
return DistributionServiceClient(config, service_name)
|
||||
return DistributionServiceClient(config, service_name)
|
||||
|
||||
@@ -420,9 +420,12 @@ class ForecastServiceClient(BaseServiceClient):
|
||||
if product_id:
|
||||
params["product_id"] = product_id
|
||||
|
||||
return await self.get(
|
||||
"forecasting/enterprise/aggregated",
|
||||
tenant_id=parent_tenant_id,
|
||||
# Use _make_request directly because the base_service_client adds /tenants/{tenant_id}/ prefix
|
||||
# Gateway route is: /api/v1/tenants/{tenant_id}/forecasting/enterprise/{path}
|
||||
# So we need the full path without tenant_id parameter to avoid double prefixing
|
||||
return await self._make_request(
|
||||
"GET",
|
||||
f"tenants/{parent_tenant_id}/forecasting/enterprise/aggregated",
|
||||
params=params
|
||||
)
|
||||
|
||||
|
||||
@@ -655,6 +655,53 @@ class InventoryServiceClient(BaseServiceClient):
|
||||
# DASHBOARD METHODS
|
||||
# ================================================================
|
||||
|
||||
async def get_inventory_summary_batch(
|
||||
self,
|
||||
tenant_ids: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get inventory summaries for multiple tenants in a single request.
|
||||
|
||||
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of tenant IDs to fetch
|
||||
|
||||
Returns:
|
||||
Dict mapping tenant_id -> inventory summary
|
||||
"""
|
||||
try:
|
||||
if not tenant_ids:
|
||||
return {}
|
||||
|
||||
if len(tenant_ids) > 100:
|
||||
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
|
||||
tenant_ids = tenant_ids[:100]
|
||||
|
||||
result = await self.post(
|
||||
"inventory/batch/inventory-summary",
|
||||
data={"tenant_ids": tenant_ids},
|
||||
tenant_id=tenant_ids[0] # Use first tenant for auth context
|
||||
)
|
||||
|
||||
summaries = result if isinstance(result, dict) else {}
|
||||
|
||||
logger.info(
|
||||
"Batch retrieved inventory summaries",
|
||||
requested=len(tenant_ids),
|
||||
found=len(summaries)
|
||||
)
|
||||
|
||||
return summaries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error batch fetching inventory summaries",
|
||||
error=str(e),
|
||||
tenant_count=len(tenant_ids)
|
||||
)
|
||||
return {}
|
||||
|
||||
async def get_stock_status(
|
||||
self,
|
||||
tenant_id: str
|
||||
@@ -692,7 +739,7 @@ class InventoryServiceClient(BaseServiceClient):
|
||||
"""
|
||||
try:
|
||||
return await self.get(
|
||||
"/inventory/sustainability/widget",
|
||||
"/sustainability/widget",
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
except Exception as e:
|
||||
|
||||
@@ -138,7 +138,8 @@ class ProcurementServiceClient(BaseServiceClient):
|
||||
async def get_pending_purchase_orders(
|
||||
self,
|
||||
tenant_id: str,
|
||||
limit: int = 50
|
||||
limit: int = 50,
|
||||
enrich_supplier: bool = True
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get pending purchase orders
|
||||
@@ -146,6 +147,8 @@ class ProcurementServiceClient(BaseServiceClient):
|
||||
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
|
||||
@@ -153,14 +156,19 @@ class ProcurementServiceClient(BaseServiceClient):
|
||||
try:
|
||||
response = await self.get(
|
||||
"procurement/purchase-orders",
|
||||
params={"status": "pending_approval", "limit": limit},
|
||||
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))
|
||||
count=len(response),
|
||||
enriched=enrich_supplier)
|
||||
return response if response else []
|
||||
except Exception as e:
|
||||
logger.error("Error getting pending purchase orders",
|
||||
@@ -168,6 +176,60 @@ class ProcurementServiceClient(BaseServiceClient):
|
||||
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)
|
||||
# ================================================================
|
||||
|
||||
@@ -449,6 +449,53 @@ class ProductionServiceClient(BaseServiceClient):
|
||||
# DASHBOARD METHODS
|
||||
# ================================================================
|
||||
|
||||
async def get_production_summary_batch(
|
||||
self,
|
||||
tenant_ids: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get production summaries for multiple tenants in a single request.
|
||||
|
||||
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of tenant IDs to fetch
|
||||
|
||||
Returns:
|
||||
Dict mapping tenant_id -> production summary
|
||||
"""
|
||||
try:
|
||||
if not tenant_ids:
|
||||
return {}
|
||||
|
||||
if len(tenant_ids) > 100:
|
||||
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
|
||||
tenant_ids = tenant_ids[:100]
|
||||
|
||||
result = await self.post(
|
||||
"production/batch/production-summary",
|
||||
data={"tenant_ids": tenant_ids},
|
||||
tenant_id=tenant_ids[0] # Use first tenant for auth context
|
||||
)
|
||||
|
||||
summaries = result if isinstance(result, dict) else {}
|
||||
|
||||
logger.info(
|
||||
"Batch retrieved production summaries",
|
||||
requested=len(tenant_ids),
|
||||
found=len(summaries)
|
||||
)
|
||||
|
||||
return summaries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error batch fetching production summaries",
|
||||
error=str(e),
|
||||
tenant_count=len(tenant_ids)
|
||||
)
|
||||
return {}
|
||||
|
||||
async def get_todays_batches(
|
||||
self,
|
||||
tenant_id: str
|
||||
|
||||
@@ -215,6 +215,65 @@ class SalesServiceClient(BaseServiceClient):
|
||||
params=params
|
||||
)
|
||||
|
||||
async def get_sales_summary_batch(
|
||||
self,
|
||||
tenant_ids: List[str],
|
||||
start_date: date,
|
||||
end_date: date
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get sales summaries for multiple tenants in a single request.
|
||||
|
||||
Phase 2 optimization: Eliminates N+1 query patterns for enterprise dashboards.
|
||||
|
||||
Args:
|
||||
tenant_ids: List of tenant IDs to fetch
|
||||
start_date: Start date for summary range
|
||||
end_date: End date for summary range
|
||||
|
||||
Returns:
|
||||
Dict mapping tenant_id -> sales summary
|
||||
"""
|
||||
try:
|
||||
if not tenant_ids:
|
||||
return {}
|
||||
|
||||
if len(tenant_ids) > 100:
|
||||
logger.warning("Batch request exceeds max tenant limit", requested=len(tenant_ids))
|
||||
tenant_ids = tenant_ids[:100]
|
||||
|
||||
data = {
|
||||
"tenant_ids": tenant_ids,
|
||||
"start_date": start_date.isoformat(),
|
||||
"end_date": end_date.isoformat()
|
||||
}
|
||||
|
||||
result = await self.post(
|
||||
"sales/batch/sales-summary",
|
||||
data=data,
|
||||
tenant_id=tenant_ids[0] # Use first tenant for auth context
|
||||
)
|
||||
|
||||
summaries = result if isinstance(result, dict) else {}
|
||||
|
||||
logger.info(
|
||||
"Batch retrieved sales summaries",
|
||||
requested=len(tenant_ids),
|
||||
found=len(summaries),
|
||||
start_date=start_date.isoformat(),
|
||||
end_date=end_date.isoformat()
|
||||
)
|
||||
|
||||
return summaries
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error batch fetching sales summaries",
|
||||
error=str(e),
|
||||
tenant_count=len(tenant_ids)
|
||||
)
|
||||
return {}
|
||||
|
||||
# ================================================================
|
||||
# DATA IMPORT
|
||||
# ================================================================
|
||||
|
||||
@@ -62,17 +62,54 @@ class SuppliersServiceClient(BaseServiceClient):
|
||||
params["search_term"] = search
|
||||
if category:
|
||||
params["supplier_type"] = category
|
||||
|
||||
|
||||
result = await self.get("suppliers", tenant_id=tenant_id, params=params)
|
||||
suppliers = result if result else []
|
||||
logger.info("Searched suppliers from suppliers service",
|
||||
logger.info("Searched suppliers from suppliers service",
|
||||
search_term=search, suppliers_count=len(suppliers), tenant_id=tenant_id)
|
||||
return suppliers
|
||||
except Exception as e:
|
||||
logger.error("Error searching suppliers",
|
||||
logger.error("Error searching suppliers",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
|
||||
async def get_suppliers_batch(self, tenant_id: str, supplier_ids: List[str]) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get multiple suppliers in a single request for performance optimization.
|
||||
|
||||
This method eliminates N+1 query patterns when fetching supplier data
|
||||
for multiple purchase orders or other entities.
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
supplier_ids: List of supplier IDs to fetch
|
||||
|
||||
Returns:
|
||||
List of supplier dictionaries or empty list if error
|
||||
"""
|
||||
try:
|
||||
if not supplier_ids:
|
||||
return []
|
||||
|
||||
# Join IDs as comma-separated string
|
||||
ids_param = ",".join(supplier_ids)
|
||||
params = {"ids": ids_param}
|
||||
|
||||
result = await self.get("suppliers/batch", tenant_id=tenant_id, params=params)
|
||||
suppliers = result if result else []
|
||||
|
||||
logger.info("Batch retrieved suppliers from suppliers service",
|
||||
requested_count=len(supplier_ids),
|
||||
found_count=len(suppliers),
|
||||
tenant_id=tenant_id)
|
||||
return suppliers
|
||||
except Exception as e:
|
||||
logger.error("Error batch retrieving suppliers",
|
||||
error=str(e),
|
||||
requested_count=len(supplier_ids),
|
||||
tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
# ================================================================
|
||||
# SUPPLIER RECOMMENDATIONS
|
||||
# ================================================================
|
||||
@@ -107,186 +144,7 @@ class SuppliersServiceClient(BaseServiceClient):
|
||||
logger.error("Error getting best supplier for ingredient",
|
||||
error=str(e), ingredient_id=ingredient_id, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# PURCHASE ORDER MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
async def create_purchase_order(self, tenant_id: str, order_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Create a new purchase order"""
|
||||
try:
|
||||
result = await self.post("suppliers/purchase-orders", data=order_data, tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Created purchase order",
|
||||
order_id=result.get('id'),
|
||||
supplier_id=order_data.get('supplier_id'),
|
||||
tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error creating purchase order",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_purchase_orders(self, tenant_id: str, status: Optional[str] = None, supplier_id: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get purchase orders with optional filtering"""
|
||||
try:
|
||||
params = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if supplier_id:
|
||||
params["supplier_id"] = supplier_id
|
||||
|
||||
result = await self.get("suppliers/purchase-orders", tenant_id=tenant_id, params=params)
|
||||
orders = result.get('orders', []) if result else []
|
||||
logger.info("Retrieved purchase orders from suppliers service",
|
||||
orders_count=len(orders), tenant_id=tenant_id)
|
||||
return orders
|
||||
except Exception as e:
|
||||
logger.error("Error getting purchase orders",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
async def update_purchase_order_status(self, tenant_id: str, order_id: str, status: str) -> Optional[Dict[str, Any]]:
|
||||
"""Update purchase order status"""
|
||||
try:
|
||||
data = {"status": status}
|
||||
result = await self.put(f"suppliers/purchase-orders/{order_id}/status", data=data, tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Updated purchase order status",
|
||||
order_id=order_id, status=status, tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error updating purchase order status",
|
||||
error=str(e), order_id=order_id, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def approve_purchase_order(
|
||||
self,
|
||||
tenant_id: str,
|
||||
po_id: str,
|
||||
approval_data: Dict[str, Any]
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Auto-approve a purchase order
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
po_id: Purchase Order ID
|
||||
approval_data: Approval data including:
|
||||
- approved_by: User ID or "system" for auto-approval
|
||||
- approval_notes: Notes about the approval
|
||||
- auto_approved: Boolean flag indicating auto-approval
|
||||
- approval_reasons: List of reasons for auto-approval
|
||||
|
||||
Returns:
|
||||
Updated purchase order data or None
|
||||
"""
|
||||
try:
|
||||
# Format the approval request payload
|
||||
payload = {
|
||||
"action": "approve",
|
||||
"notes": approval_data.get("approval_notes", "Auto-approved by system")
|
||||
}
|
||||
|
||||
result = await self.post(
|
||||
f"suppliers/purchase-orders/{po_id}/approve",
|
||||
data=payload,
|
||||
tenant_id=tenant_id
|
||||
)
|
||||
|
||||
if result:
|
||||
logger.info("Auto-approved purchase order",
|
||||
po_id=po_id,
|
||||
tenant_id=tenant_id,
|
||||
auto_approved=approval_data.get("auto_approved", True))
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error auto-approving purchase order",
|
||||
error=str(e),
|
||||
po_id=po_id,
|
||||
tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_supplier(self, tenant_id: str, supplier_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get supplier details with performance metrics
|
||||
|
||||
Args:
|
||||
tenant_id: Tenant ID
|
||||
supplier_id: Supplier ID
|
||||
|
||||
Returns:
|
||||
Supplier data including performance metrics or None
|
||||
"""
|
||||
try:
|
||||
# Use the existing get_supplier_by_id method which returns full supplier data
|
||||
result = await self.get_supplier_by_id(tenant_id, supplier_id)
|
||||
|
||||
if result:
|
||||
logger.info("Retrieved supplier data for auto-approval",
|
||||
supplier_id=supplier_id,
|
||||
tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier data",
|
||||
error=str(e),
|
||||
supplier_id=supplier_id,
|
||||
tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# DELIVERY MANAGEMENT
|
||||
# ================================================================
|
||||
|
||||
async def get_deliveries(self, tenant_id: str, status: Optional[str] = None, date: Optional[str] = None) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Get deliveries with optional filtering"""
|
||||
try:
|
||||
params = {}
|
||||
if status:
|
||||
params["status"] = status
|
||||
if date:
|
||||
params["date"] = date
|
||||
|
||||
result = await self.get("suppliers/deliveries", tenant_id=tenant_id, params=params)
|
||||
deliveries = result.get('deliveries', []) if result else []
|
||||
logger.info("Retrieved deliveries from suppliers service",
|
||||
deliveries_count=len(deliveries), tenant_id=tenant_id)
|
||||
return deliveries
|
||||
except Exception as e:
|
||||
logger.error("Error getting deliveries",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return []
|
||||
|
||||
async def update_delivery_status(self, tenant_id: str, delivery_id: str, status: str, notes: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Update delivery status"""
|
||||
try:
|
||||
data = {"status": status}
|
||||
if notes:
|
||||
data["notes"] = notes
|
||||
|
||||
result = await self.put(f"suppliers/deliveries/{delivery_id}/status", data=data, tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Updated delivery status",
|
||||
delivery_id=delivery_id, status=status, tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error updating delivery status",
|
||||
error=str(e), delivery_id=delivery_id, tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
async def get_supplier_order_summaries(self, tenant_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get supplier order summaries for central bakery dashboard"""
|
||||
try:
|
||||
result = await self.get("suppliers/dashboard/order-summaries", tenant_id=tenant_id)
|
||||
if result:
|
||||
logger.info("Retrieved supplier order summaries from suppliers service",
|
||||
tenant_id=tenant_id)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Error getting supplier order summaries",
|
||||
error=str(e), tenant_id=tenant_id)
|
||||
return None
|
||||
|
||||
# ================================================================
|
||||
# PERFORMANCE TRACKING
|
||||
# ================================================================
|
||||
|
||||
@@ -310,7 +310,9 @@ class TenantServiceClient(BaseServiceClient):
|
||||
List of child tenant dictionaries
|
||||
"""
|
||||
try:
|
||||
result = await self.get("children", tenant_id=parent_tenant_id)
|
||||
# Use _make_request directly to avoid double tenant_id in URL
|
||||
# The gateway expects: /api/v1/tenants/{tenant_id}/children
|
||||
result = await self._make_request("GET", f"tenants/{parent_tenant_id}/children")
|
||||
if result:
|
||||
logger.info("Retrieved child tenants",
|
||||
parent_tenant_id=parent_tenant_id,
|
||||
|
||||
Reference in New Issue
Block a user