New alert service

This commit is contained in:
Urtzi Alfaro
2025-12-05 20:07:01 +01:00
parent 1fe3a73549
commit 667e6e0404
393 changed files with 26002 additions and 61033 deletions

View File

@@ -0,0 +1,149 @@
# services/inventory/app/api/batch.py
"""
Inventory Batch API - Batch operations for enterprise dashboards
Phase 2 optimization: Eliminate N+1 query patterns by fetching inventory data
for multiple tenants in a single request.
"""
from fastapi import APIRouter, Depends, HTTPException, Body
from typing import List, Dict, Any
from uuid import UUID
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
import structlog
import asyncio
from app.core.database import get_db
from app.services.dashboard_service import DashboardService
from app.services.inventory_service import InventoryService
from shared.auth.decorators import get_current_user_dep
router = APIRouter(tags=["inventory-batch"])
logger = structlog.get_logger()
class InventorySummaryBatchRequest(BaseModel):
"""Request model for batch inventory summary"""
tenant_ids: List[str] = Field(..., description="List of tenant IDs", max_length=100)
class InventorySummary(BaseModel):
"""Inventory summary for a single tenant"""
tenant_id: str
total_value: float
out_of_stock_count: int
low_stock_count: int
adequate_stock_count: int
total_ingredients: int
@router.post("/batch/inventory-summary", response_model=Dict[str, InventorySummary])
async def get_inventory_summary_batch(
request: InventorySummaryBatchRequest = Body(...),
current_user: Dict[str, Any] = Depends(get_current_user_dep),
db: AsyncSession = Depends(get_db)
):
"""
Get inventory summary for multiple tenants in a single request.
Optimized for enterprise dashboards to eliminate N+1 query patterns.
Fetches inventory data for all tenants in parallel.
Args:
request: Batch request with tenant IDs
Returns:
Dictionary mapping tenant_id -> inventory summary
Example:
POST /api/v1/inventory/batch/inventory-summary
{
"tenant_ids": ["tenant-1", "tenant-2", "tenant-3"]
}
Response:
{
"tenant-1": {"tenant_id": "tenant-1", "total_value": 15000, ...},
"tenant-2": {"tenant_id": "tenant-2", "total_value": 12000, ...},
"tenant-3": {"tenant_id": "tenant-3", "total_value": 18000, ...}
}
"""
try:
if len(request.tenant_ids) > 100:
raise HTTPException(
status_code=400,
detail="Maximum 100 tenant IDs allowed per batch request"
)
if not request.tenant_ids:
return {}
logger.info(
"Batch fetching inventory summaries",
tenant_count=len(request.tenant_ids)
)
async def fetch_tenant_inventory(tenant_id: str) -> tuple[str, InventorySummary]:
"""Fetch inventory summary for a single tenant"""
try:
tenant_uuid = UUID(tenant_id)
dashboard_service = DashboardService(
inventory_service=InventoryService(),
food_safety_service=None
)
overview = await dashboard_service.get_inventory_overview(db, tenant_uuid)
return tenant_id, InventorySummary(
tenant_id=tenant_id,
total_value=float(overview.get('total_value', 0)),
out_of_stock_count=int(overview.get('out_of_stock_count', 0)),
low_stock_count=int(overview.get('low_stock_count', 0)),
adequate_stock_count=int(overview.get('adequate_stock_count', 0)),
total_ingredients=int(overview.get('total_ingredients', 0))
)
except Exception as e:
logger.warning(
"Failed to fetch inventory for tenant in batch",
tenant_id=tenant_id,
error=str(e)
)
return tenant_id, InventorySummary(
tenant_id=tenant_id,
total_value=0.0,
out_of_stock_count=0,
low_stock_count=0,
adequate_stock_count=0,
total_ingredients=0
)
# Fetch all tenant inventory data in parallel
tasks = [fetch_tenant_inventory(tid) for tid in request.tenant_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Build result dictionary
result_dict = {}
for result in results:
if isinstance(result, Exception):
logger.error("Exception in batch inventory fetch", error=str(result))
continue
tenant_id, summary = result
result_dict[tenant_id] = summary
logger.info(
"Batch inventory summaries retrieved",
requested_count=len(request.tenant_ids),
successful_count=len(result_dict)
)
return result_dict
except HTTPException:
raise
except Exception as e:
logger.error("Error in batch inventory summary", error=str(e), exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to fetch batch inventory summaries: {str(e)}"
)

View File

@@ -30,6 +30,7 @@ from app.schemas.dashboard import (
AlertSummary,
RecentActivity
)
from app.utils.cache import get_cached, set_cached, make_cache_key
logger = structlog.get_logger()
@@ -62,19 +63,34 @@ async def get_inventory_dashboard_summary(
dashboard_service: DashboardService = Depends(get_dashboard_service),
db: AsyncSession = Depends(get_db)
):
"""Get comprehensive inventory dashboard summary"""
"""Get comprehensive inventory dashboard summary with caching (30s TTL)"""
try:
# PHASE 2: Check cache first (only if no filters applied)
cache_key = None
if filters is None:
cache_key = make_cache_key("inventory_dashboard", str(tenant_id))
cached_result = await get_cached(cache_key)
if cached_result is not None:
logger.debug("Cache hit for inventory dashboard", cache_key=cache_key, tenant_id=str(tenant_id))
return InventoryDashboardSummary(**cached_result)
# Cache miss or filters applied - fetch from database
summary = await dashboard_service.get_inventory_dashboard_summary(db, tenant_id, filters)
logger.info("Dashboard summary retrieved",
# PHASE 2: Cache the result (30s TTL for inventory levels)
if cache_key:
await set_cached(cache_key, summary.model_dump(), ttl=30)
logger.debug("Cached inventory dashboard", cache_key=cache_key, ttl=30, tenant_id=str(tenant_id))
logger.info("Dashboard summary retrieved",
tenant_id=str(tenant_id),
total_ingredients=summary.total_ingredients)
return summary
except Exception as e:
logger.error("Error getting dashboard summary",
tenant_id=str(tenant_id),
logger.error("Error getting dashboard summary",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -82,6 +98,41 @@ async def get_inventory_dashboard_summary(
)
@router.get(
route_builder.build_dashboard_route("overview")
)
async def get_inventory_dashboard_overview(
tenant_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
dashboard_service: DashboardService = Depends(get_dashboard_service),
db: AsyncSession = Depends(get_db)
):
"""
Get lightweight inventory dashboard overview for health checks.
This endpoint is optimized for frequent polling by the orchestrator service
for dashboard health-status checks. It returns only essential metrics needed
to determine inventory health status.
"""
try:
overview = await dashboard_service.get_inventory_overview(db, tenant_id)
logger.info("Inventory dashboard overview retrieved",
tenant_id=str(tenant_id),
out_of_stock_count=overview.get('out_of_stock_count', 0))
return overview
except Exception as e:
logger.error("Error getting inventory dashboard overview",
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve inventory dashboard overview"
)
@router.get(
route_builder.build_dashboard_route("food-safety"),
response_model=FoodSafetyDashboard

View File

@@ -11,7 +11,7 @@ from typing import Dict, Any
from decimal import Decimal
import structlog
from shared.messaging.rabbitmq import RabbitMQClient
from shared.messaging import RabbitMQClient
from app.core.database import database_manager
from app.repositories.stock_repository import StockRepository
from app.repositories.stock_movement_repository import StockMovementRepository
@@ -137,6 +137,23 @@ class DeliveryEventConsumer:
# Create a new stock batch entry for this delivery
# The Stock model uses batch tracking - each delivery creates a new batch entry
# Extract unit cost from delivery item
unit_cost = Decimal('0')
try:
if 'unit_cost' in item:
unit_cost = Decimal(str(item['unit_cost']))
elif 'unit_price' in item:
unit_cost = Decimal(str(item['unit_price']))
elif 'price' in item:
unit_cost = Decimal(str(item['price']))
except (ValueError, TypeError, KeyError) as e:
logger.warning("Could not extract unit cost from delivery item for stock entry",
item_id=item.get('id'),
error=str(e))
# Calculate total cost
total_cost = unit_cost * accepted_quantity
stock_data = {
'tenant_id': tenant_id,
'ingredient_id': ingredient_id,
@@ -153,9 +170,9 @@ class DeliveryEventConsumer:
'received_date': datetime.fromisoformat(received_at.replace('Z', '+00:00')) if received_at else datetime.now(timezone.utc),
'expiration_date': datetime.fromisoformat(item.get('expiry_date').replace('Z', '+00:00')) if item.get('expiry_date') else None,
# Cost (TODO: Get actual unit cost from delivery item if available)
'unit_cost': Decimal('0'),
'total_cost': Decimal('0'),
# Cost - extracted from delivery item
'unit_cost': unit_cost,
'total_cost': total_cost,
# Production stage - default to raw ingredient for deliveries
'production_stage': 'raw_ingredient',
@@ -182,12 +199,26 @@ class DeliveryEventConsumer:
from app.models.inventory import StockMovementType
from app.schemas.inventory import StockMovementCreate
# Extract unit cost from delivery item or default to 0
unit_cost = Decimal('0')
try:
if 'unit_cost' in item:
unit_cost = Decimal(str(item['unit_cost']))
elif 'unit_price' in item:
unit_cost = Decimal(str(item['unit_price']))
elif 'price' in item:
unit_cost = Decimal(str(item['price']))
except (ValueError, TypeError, KeyError) as e:
logger.warning("Could not extract unit cost from delivery item",
item_id=item.get('id'),
error=str(e))
movement_data = StockMovementCreate(
ingredient_id=ingredient_id,
stock_id=stock.id,
movement_type=StockMovementType.PURCHASE,
quantity=float(accepted_quantity),
unit_cost=Decimal('0'), # TODO: Get from delivery item
unit_cost=unit_cost,
reference_number=f"DEL-{delivery_id}",
reason_code='delivery',
notes=f"Delivery received from PO {po_id}. Batch: {item.get('batch_lot_number', 'N/A')}",

View File

@@ -9,7 +9,7 @@ from typing import Dict, Any
import json
from app.services.internal_transfer_service import InternalTransferInventoryService
from shared.messaging.rabbitmq import RabbitMQClient
from shared.messaging import RabbitMQClient
logger = structlog.get_logger()

View File

@@ -13,9 +13,11 @@ from app.core.database import database_manager
from app.services.inventory_alert_service import InventoryAlertService
from app.consumers.delivery_event_consumer import DeliveryEventConsumer
from shared.service_base import StandardFastAPIService
from shared.messaging import UnifiedEventPublisher
import asyncio
from app.api import (
batch,
ingredients,
stock_entries,
transformations,
@@ -79,10 +81,12 @@ class InventoryService(StandardFastAPIService):
async def _setup_messaging(self):
"""Setup messaging for inventory service"""
from shared.messaging.rabbitmq import RabbitMQClient
from shared.messaging import RabbitMQClient
try:
self.rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, service_name="inventory-service")
await self.rabbitmq_client.connect()
# Create event publisher
self.event_publisher = UnifiedEventPublisher(self.rabbitmq_client, "inventory-service")
self.logger.info("Inventory service messaging setup completed")
except Exception as e:
self.logger.error("Failed to setup inventory messaging", error=str(e))
@@ -105,13 +109,16 @@ class InventoryService(StandardFastAPIService):
# Call parent startup (includes database, messaging, etc.)
await super().on_startup(app)
# Initialize alert service
alert_service = InventoryAlertService(settings)
await alert_service.start()
self.logger.info("Inventory alert service started")
# Initialize alert service with EventPublisher
if self.event_publisher:
alert_service = InventoryAlertService(self.event_publisher)
await alert_service.start()
self.logger.info("Inventory alert service started")
# Store alert service in app state
app.state.alert_service = alert_service
# Store alert service in app state
app.state.alert_service = alert_service
else:
self.logger.error("Event publisher not initialized, alert service unavailable")
# Initialize and start delivery event consumer
self.delivery_consumer = DeliveryEventConsumer()
@@ -179,6 +186,7 @@ service.setup_standard_endpoints()
# Include new standardized routers
# IMPORTANT: Register audit router FIRST to avoid route matching conflicts
service.add_router(audit.router)
service.add_router(batch.router)
service.add_router(ingredients.router)
service.add_router(stock_entries.router)
service.add_router(transformations.router)

View File

@@ -436,7 +436,33 @@ class DashboardService:
except Exception as e:
logger.error("Failed to get live metrics", error=str(e))
raise
async def get_inventory_overview(
self,
db,
tenant_id: UUID
) -> Dict[str, Any]:
"""
Get lightweight inventory overview for orchestrator health checks.
Returns minimal data needed by dashboard health-status endpoint.
This is a fast endpoint optimized for frequent polling.
"""
try:
# Get only the essential metric needed by orchestrator
inventory_summary = await self.inventory_service.get_inventory_summary(tenant_id)
return {
"out_of_stock_count": inventory_summary.out_of_stock_items,
"tenant_id": str(tenant_id),
"timestamp": datetime.now().isoformat()
}
except Exception as e:
logger.error("Failed to get inventory overview",
tenant_id=str(tenant_id),
error=str(e))
raise
async def export_dashboard_data(
self,
db,

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,33 @@
"""
Inventory Notification Service
Inventory Notification Service - Simplified
Emits informational notifications for inventory state changes:
- stock_received: When deliveries arrive
- stock_movement: Transfers, adjustments
- stock_updated: General stock updates
Emits minimal events using EventPublisher.
All enrichment handled by alert_processor.
These are NOTIFICATIONS (not alerts) - informational state changes that don't require user action.
"""
import logging
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from uuid import UUID
import structlog
from shared.schemas.event_classification import RawEvent, EventClass, EventDomain
from shared.alerts.base_service import BaseAlertService
from shared.messaging import UnifiedEventPublisher
logger = structlog.get_logger()
logger = logging.getLogger(__name__)
class InventoryNotificationService(BaseAlertService):
class InventoryNotificationService:
"""
Service for emitting inventory notifications (informational state changes).
Service for emitting inventory notifications using EventPublisher.
"""
def __init__(self, rabbitmq_url: str = None):
super().__init__(service_name="inventory", rabbitmq_url=rabbitmq_url)
def __init__(self, event_publisher: UnifiedEventPublisher):
self.publisher = event_publisher
async def emit_stock_received_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
stock_receipt_id: str,
ingredient_id: str,
ingredient_name: str,
@@ -43,61 +38,38 @@ class InventoryNotificationService(BaseAlertService):
) -> None:
"""
Emit notification when stock is received.
Args:
db: Database session
tenant_id: Tenant ID
stock_receipt_id: Stock receipt ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity_received: Quantity received
unit: Unit of measurement
supplier_name: Supplier name (optional)
delivery_id: Delivery ID (optional)
"""
try:
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_received",
title=f"Stock Received: {ingredient_name}",
message=f"Received {quantity_received} {unit} of {ingredient_name}"
+ (f" from {supplier_name}" if supplier_name else ""),
service="inventory",
event_metadata={
"stock_receipt_id": stock_receipt_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity_received": quantity_received,
"unit": unit,
"supplier_name": supplier_name,
"delivery_id": delivery_id,
"received_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
message = f"Received {quantity_received} {unit} of {ingredient_name}"
if supplier_name:
message += f" from {supplier_name}"
# Publish to RabbitMQ for processing
await self.publish_item(tenant_id, event.dict(), item_type="notification")
metadata = {
"stock_receipt_id": stock_receipt_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity_received": float(quantity_received),
"unit": unit,
"supplier_name": supplier_name,
"delivery_id": delivery_id,
"received_at": datetime.now(timezone.utc).isoformat(),
}
logger.info(
f"Stock received notification emitted: {ingredient_name} ({quantity_received} {unit})",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
await self.publisher.publish_notification(
event_type="inventory.stock_received",
tenant_id=tenant_id,
data=metadata
)
except Exception as e:
logger.error(
f"Failed to emit stock received notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)
logger.info(
"stock_received_notification_emitted",
tenant_id=str(tenant_id),
ingredient_name=ingredient_name,
quantity_received=quantity_received
)
async def emit_stock_movement_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
movement_id: str,
ingredient_id: str,
ingredient_name: str,
@@ -110,82 +82,54 @@ class InventoryNotificationService(BaseAlertService):
) -> None:
"""
Emit notification for stock movements (transfers, adjustments, waste).
Args:
db: Database session
tenant_id: Tenant ID
movement_id: Movement ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
quantity: Quantity moved
unit: Unit of measurement
movement_type: Type of movement
from_location: Source location (optional)
to_location: Destination location (optional)
reason: Reason for movement (optional)
"""
try:
# Build message based on movement type
if movement_type == "transfer":
message = f"Transferred {quantity} {unit} of {ingredient_name}"
if from_location and to_location:
message += f" from {from_location} to {to_location}"
elif movement_type == "adjustment":
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
if reason:
message += f" - {reason}"
elif movement_type == "waste":
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
if reason:
message += f" - {reason}"
elif movement_type == "return":
message = f"Returned {quantity} {unit} of {ingredient_name}"
else:
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
# Build message based on movement type
if movement_type == "transfer":
message = f"Transferred {quantity} {unit} of {ingredient_name}"
if from_location and to_location:
message += f" from {from_location} to {to_location}"
elif movement_type == "adjustment":
message = f"Adjusted {ingredient_name} by {quantity} {unit}"
if reason:
message += f" - {reason}"
elif movement_type == "waste":
message = f"Waste recorded: {quantity} {unit} of {ingredient_name}"
if reason:
message += f" - {reason}"
elif movement_type == "return":
message = f"Returned {quantity} {unit} of {ingredient_name}"
else:
message = f"Stock movement: {quantity} {unit} of {ingredient_name}"
# Create notification event
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_movement",
title=f"Stock {movement_type.title()}: {ingredient_name}",
message=message,
service="inventory",
event_metadata={
"movement_id": movement_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity": quantity,
"unit": unit,
"movement_type": movement_type,
"from_location": from_location,
"to_location": to_location,
"reason": reason,
"moved_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
metadata = {
"movement_id": movement_id,
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"quantity": float(quantity),
"unit": unit,
"movement_type": movement_type,
"from_location": from_location,
"to_location": to_location,
"reason": reason,
"moved_at": datetime.now(timezone.utc).isoformat(),
}
# Publish to RabbitMQ
await self.publish_item(tenant_id, event.dict(), item_type="notification")
await self.publisher.publish_notification(
event_type="inventory.stock_movement",
tenant_id=tenant_id,
data=metadata
)
logger.info(
f"Stock movement notification emitted: {movement_type} - {ingredient_name}",
extra={"tenant_id": tenant_id, "movement_id": movement_id}
)
except Exception as e:
logger.error(
f"Failed to emit stock movement notification: {e}",
extra={"tenant_id": tenant_id, "movement_id": movement_id},
exc_info=True,
)
logger.info(
"stock_movement_notification_emitted",
tenant_id=str(tenant_id),
movement_type=movement_type,
ingredient_name=ingredient_name
)
async def emit_stock_updated_notification(
self,
db: Session,
tenant_id: str,
tenant_id: UUID,
ingredient_id: str,
ingredient_name: str,
old_quantity: float,
@@ -195,52 +139,32 @@ class InventoryNotificationService(BaseAlertService):
) -> None:
"""
Emit notification when stock is updated.
Args:
db: Database session
tenant_id: Tenant ID
ingredient_id: Ingredient ID
ingredient_name: Ingredient name
old_quantity: Previous quantity
new_quantity: New quantity
unit: Unit of measurement
update_reason: Reason for update
"""
try:
quantity_change = new_quantity - old_quantity
change_direction = "increased" if quantity_change > 0 else "decreased"
quantity_change = new_quantity - old_quantity
change_direction = "increased" if quantity_change > 0 else "decreased"
event = RawEvent(
tenant_id=tenant_id,
event_class=EventClass.NOTIFICATION,
event_domain=EventDomain.INVENTORY,
event_type="stock_updated",
title=f"Stock Updated: {ingredient_name}",
message=f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}",
service="inventory",
event_metadata={
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"old_quantity": old_quantity,
"new_quantity": new_quantity,
"quantity_change": quantity_change,
"unit": unit,
"update_reason": update_reason,
"updated_at": datetime.now(timezone.utc).isoformat(),
},
timestamp=datetime.now(timezone.utc),
)
message = f"Stock {change_direction} by {abs(quantity_change)} {unit} - {update_reason}"
await self.publish_item(tenant_id, event.dict(), item_type="notification")
metadata = {
"ingredient_id": ingredient_id,
"ingredient_name": ingredient_name,
"old_quantity": float(old_quantity),
"new_quantity": float(new_quantity),
"quantity_change": float(quantity_change),
"unit": unit,
"update_reason": update_reason,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
logger.info(
f"Stock updated notification emitted: {ingredient_name}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id}
)
await self.publisher.publish_notification(
event_type="inventory.stock_updated",
tenant_id=tenant_id,
data=metadata
)
except Exception as e:
logger.error(
f"Failed to emit stock updated notification: {e}",
extra={"tenant_id": tenant_id, "ingredient_id": ingredient_id},
exc_info=True,
)
logger.info(
"stock_updated_notification_emitted",
tenant_id=str(tenant_id),
ingredient_name=ingredient_name,
quantity_change=quantity_change
)

View File

@@ -22,6 +22,7 @@ from app.schemas.inventory import (
from app.core.database import get_db_transaction
from shared.database.exceptions import DatabaseError
from shared.utils.batch_generator import BatchNumberGenerator, create_fallback_batch_number
from app.utils.cache import delete_cached, make_cache_key
logger = structlog.get_logger()
@@ -843,6 +844,11 @@ class InventoryService:
ingredient_dict['category'] = ingredient.ingredient_category.value if ingredient.ingredient_category else None
response.ingredient = IngredientResponse(**ingredient_dict)
# PHASE 2: Invalidate inventory dashboard cache
cache_key = make_cache_key("inventory_dashboard", str(tenant_id))
await delete_cached(cache_key)
logger.debug("Invalidated inventory dashboard cache", cache_key=cache_key, tenant_id=str(tenant_id))
logger.info("Stock entry updated successfully", stock_id=stock_id, tenant_id=tenant_id)
return response

View File

@@ -1,244 +0,0 @@
# services/inventory/app/services/messaging.py
"""
Messaging service for inventory events
"""
from typing import Dict, Any, Optional
from uuid import UUID
import structlog
from shared.messaging.rabbitmq import MessagePublisher
from shared.messaging.events import (
EVENT_TYPES,
InventoryEvent,
StockAlertEvent,
StockMovementEvent
)
logger = structlog.get_logger()
class InventoryMessagingService:
"""Service for publishing inventory-related events"""
def __init__(self):
self.publisher = MessagePublisher()
async def publish_ingredient_created(
self,
tenant_id: UUID,
ingredient_id: UUID,
ingredient_data: Dict[str, Any]
):
"""Publish ingredient creation event"""
try:
event = InventoryEvent(
event_type=EVENT_TYPES.INVENTORY.INGREDIENT_CREATED,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
data=ingredient_data
)
await self.publisher.publish_event(
routing_key="inventory.ingredient.created",
event=event
)
logger.info(
"Published ingredient created event",
tenant_id=tenant_id,
ingredient_id=ingredient_id
)
except Exception as e:
logger.error(
"Failed to publish ingredient created event",
error=str(e),
tenant_id=tenant_id,
ingredient_id=ingredient_id
)
async def publish_stock_added(
self,
tenant_id: UUID,
ingredient_id: UUID,
stock_id: UUID,
quantity: float,
batch_number: Optional[str] = None
):
"""Publish stock addition event"""
try:
movement_event = StockMovementEvent(
event_type=EVENT_TYPES.INVENTORY.STOCK_ADDED,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=str(stock_id),
quantity=quantity,
movement_type="purchase",
data={
"batch_number": batch_number,
"movement_type": "purchase"
}
)
await self.publisher.publish_event(
routing_key="inventory.stock.added",
event=movement_event
)
logger.info(
"Published stock added event",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
quantity=quantity
)
except Exception as e:
logger.error(
"Failed to publish stock added event",
error=str(e),
tenant_id=tenant_id,
ingredient_id=ingredient_id
)
async def publish_stock_consumed(
self,
tenant_id: UUID,
ingredient_id: UUID,
consumed_items: list,
total_quantity: float,
reference_number: Optional[str] = None
):
"""Publish stock consumption event"""
try:
for item in consumed_items:
movement_event = StockMovementEvent(
event_type=EVENT_TYPES.INVENTORY.STOCK_CONSUMED,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
stock_id=item['stock_id'],
quantity=item['quantity_consumed'],
movement_type="production_use",
data={
"batch_number": item.get('batch_number'),
"reference_number": reference_number,
"movement_type": "production_use"
}
)
await self.publisher.publish_event(
routing_key="inventory.stock.consumed",
event=movement_event
)
logger.info(
"Published stock consumed events",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
total_quantity=total_quantity,
items_count=len(consumed_items)
)
except Exception as e:
logger.error(
"Failed to publish stock consumed event",
error=str(e),
tenant_id=tenant_id,
ingredient_id=ingredient_id
)
async def publish_low_stock_alert(
self,
tenant_id: UUID,
ingredient_id: UUID,
ingredient_name: str,
current_stock: float,
threshold: float,
needs_reorder: bool = False
):
"""Publish low stock alert event"""
try:
alert_event = StockAlertEvent(
event_type=EVENT_TYPES.INVENTORY.LOW_STOCK_ALERT,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
alert_type="low_stock" if not needs_reorder else "reorder_needed",
severity="medium" if not needs_reorder else "high",
data={
"ingredient_name": ingredient_name,
"current_stock": current_stock,
"threshold": threshold,
"needs_reorder": needs_reorder
}
)
await self.publisher.publish_event(
routing_key="inventory.alerts.low_stock",
event=alert_event
)
logger.info(
"Published low stock alert",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
current_stock=current_stock
)
except Exception as e:
logger.error(
"Failed to publish low stock alert",
error=str(e),
tenant_id=tenant_id,
ingredient_id=ingredient_id
)
async def publish_expiration_alert(
self,
tenant_id: UUID,
ingredient_id: UUID,
stock_id: UUID,
ingredient_name: str,
batch_number: Optional[str],
expiration_date: str,
days_to_expiry: int,
quantity: float
):
"""Publish expiration alert event"""
try:
severity = "critical" if days_to_expiry <= 1 else "high"
alert_event = StockAlertEvent(
event_type=EVENT_TYPES.INVENTORY.EXPIRATION_ALERT,
tenant_id=str(tenant_id),
ingredient_id=str(ingredient_id),
alert_type="expiring_soon",
severity=severity,
data={
"stock_id": str(stock_id),
"ingredient_name": ingredient_name,
"batch_number": batch_number,
"expiration_date": expiration_date,
"days_to_expiry": days_to_expiry,
"quantity": quantity
}
)
await self.publisher.publish_event(
routing_key="inventory.alerts.expiration",
event=alert_event
)
logger.info(
"Published expiration alert",
tenant_id=tenant_id,
ingredient_id=ingredient_id,
days_to_expiry=days_to_expiry
)
except Exception as e:
logger.error(
"Failed to publish expiration alert",
error=str(e),
tenant_id=tenant_id,
ingredient_id=ingredient_id
)

View File

@@ -0,0 +1,26 @@
# services/alert_processor/app/utils/__init__.py
"""
Utility modules for alert processor service
"""
from .cache import (
get_redis_client,
close_redis,
get_cached,
set_cached,
delete_cached,
delete_pattern,
cache_response,
make_cache_key,
)
__all__ = [
'get_redis_client',
'close_redis',
'get_cached',
'set_cached',
'delete_cached',
'delete_pattern',
'cache_response',
'make_cache_key',
]

View File

@@ -0,0 +1,265 @@
# services/orchestrator/app/utils/cache.py
"""
Redis caching utilities for dashboard endpoints
"""
import json
import redis.asyncio as redis
from typing import Optional, Any, Callable
from functools import wraps
import structlog
from app.core.config import settings
from pydantic import BaseModel
logger = structlog.get_logger()
# Redis client instance
_redis_client: Optional[redis.Redis] = None
async def get_redis_client() -> redis.Redis:
"""Get or create Redis client"""
global _redis_client
if _redis_client is None:
try:
# Check if TLS is enabled - convert string to boolean properly
redis_tls_str = str(getattr(settings, 'REDIS_TLS_ENABLED', 'false')).lower()
redis_tls_enabled = redis_tls_str in ('true', '1', 'yes', 'on')
connection_kwargs = {
'host': str(getattr(settings, 'REDIS_HOST', 'localhost')),
'port': int(getattr(settings, 'REDIS_PORT', 6379)),
'db': int(getattr(settings, 'REDIS_DB', 0)),
'decode_responses': True,
'socket_connect_timeout': 5,
'socket_timeout': 5
}
# Add password if configured
redis_password = getattr(settings, 'REDIS_PASSWORD', None)
if redis_password:
connection_kwargs['password'] = redis_password
# Add SSL/TLS support if enabled
if redis_tls_enabled:
import ssl
connection_kwargs['ssl'] = True
connection_kwargs['ssl_cert_reqs'] = ssl.CERT_NONE
logger.debug(f"Redis TLS enabled - connecting with SSL to {connection_kwargs['host']}:{connection_kwargs['port']}")
_redis_client = redis.Redis(**connection_kwargs)
# Test connection
await _redis_client.ping()
logger.info(f"Redis client connected successfully (TLS: {redis_tls_enabled})")
except Exception as e:
logger.warning(f"Failed to connect to Redis: {e}. Caching will be disabled.")
_redis_client = None
return _redis_client
async def close_redis():
"""Close Redis connection"""
global _redis_client
if _redis_client:
await _redis_client.close()
_redis_client = None
logger.info("Redis connection closed")
async def get_cached(key: str) -> Optional[Any]:
"""
Get cached value by key
Args:
key: Cache key
Returns:
Cached value (deserialized from JSON) or None if not found or error
"""
try:
client = await get_redis_client()
if not client:
return None
cached = await client.get(key)
if cached:
logger.debug(f"Cache hit: {key}")
return json.loads(cached)
else:
logger.debug(f"Cache miss: {key}")
return None
except Exception as e:
logger.warning(f"Cache get error for key {key}: {e}")
return None
def _serialize_value(value: Any) -> Any:
"""
Recursively serialize values for JSON storage, handling Pydantic models properly.
Args:
value: Value to serialize
Returns:
JSON-serializable value
"""
if isinstance(value, BaseModel):
# Convert Pydantic model to dictionary
return value.model_dump()
elif isinstance(value, (list, tuple)):
# Recursively serialize list/tuple elements
return [_serialize_value(item) for item in value]
elif isinstance(value, dict):
# Recursively serialize dictionary values
return {key: _serialize_value(val) for key, val in value.items()}
else:
# For other types, use default serialization
return value
async def set_cached(key: str, value: Any, ttl: int = 60) -> bool:
"""
Set cached value with TTL
Args:
key: Cache key
value: Value to cache (will be JSON serialized)
ttl: Time to live in seconds
Returns:
True if successful, False otherwise
"""
try:
client = await get_redis_client()
if not client:
return False
# Serialize value properly before JSON encoding
serialized_value = _serialize_value(value)
serialized = json.dumps(serialized_value)
await client.setex(key, ttl, serialized)
logger.debug(f"Cache set: {key} (TTL: {ttl}s)")
return True
except Exception as e:
logger.warning(f"Cache set error for key {key}: {e}")
return False
async def delete_cached(key: str) -> bool:
"""
Delete cached value
Args:
key: Cache key
Returns:
True if successful, False otherwise
"""
try:
client = await get_redis_client()
if not client:
return False
await client.delete(key)
logger.debug(f"Cache deleted: {key}")
return True
except Exception as e:
logger.warning(f"Cache delete error for key {key}: {e}")
return False
async def delete_pattern(pattern: str) -> int:
"""
Delete all keys matching pattern
Args:
pattern: Redis key pattern (e.g., "dashboard:*")
Returns:
Number of keys deleted
"""
try:
client = await get_redis_client()
if not client:
return 0
keys = []
async for key in client.scan_iter(match=pattern):
keys.append(key)
if keys:
deleted = await client.delete(*keys)
logger.info(f"Deleted {deleted} keys matching pattern: {pattern}")
return deleted
return 0
except Exception as e:
logger.warning(f"Cache delete pattern error for {pattern}: {e}")
return 0
def cache_response(key_prefix: str, ttl: int = 60):
"""
Decorator to cache endpoint responses
Args:
key_prefix: Prefix for cache key (will be combined with tenant_id)
ttl: Time to live in seconds
Usage:
@cache_response("dashboard:health", ttl=30)
async def get_health(tenant_id: str):
...
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
# Extract tenant_id from kwargs or args
tenant_id = kwargs.get('tenant_id')
if not tenant_id and args:
# Try to find tenant_id in args (assuming it's the first argument)
tenant_id = args[0] if len(args) > 0 else None
if not tenant_id:
# No tenant_id, skip caching
return await func(*args, **kwargs)
# Build cache key
cache_key = f"{key_prefix}:{tenant_id}"
# Try to get from cache
cached_value = await get_cached(cache_key)
if cached_value is not None:
return cached_value
# Execute function
result = await func(*args, **kwargs)
# Cache result
await set_cached(cache_key, result, ttl)
return result
return wrapper
return decorator
def make_cache_key(prefix: str, tenant_id: str, **params) -> str:
"""
Create a cache key with optional parameters
Args:
prefix: Key prefix
tenant_id: Tenant ID
**params: Additional parameters to include in key
Returns:
Cache key string
"""
key_parts = [prefix, tenant_id]
for k, v in sorted(params.items()):
if v is not None:
key_parts.append(f"{k}:{v}")
return ":".join(key_parts)