Add POI feature and imporve the overall backend implementation
This commit is contained in:
@@ -207,9 +207,33 @@ async def trigger_safety_stock_optimization(
|
||||
)
|
||||
continue
|
||||
|
||||
# Get lead time from supplier if available
|
||||
lead_time_days = 7 # Default fallback
|
||||
if product.supplier_id:
|
||||
try:
|
||||
from shared.clients.suppliers_client import SuppliersClient
|
||||
suppliers_client = SuppliersClient()
|
||||
supplier_data = await suppliers_client.get_supplier_by_id(
|
||||
tenant_id=str(tenant_id),
|
||||
supplier_id=str(product.supplier_id)
|
||||
)
|
||||
if supplier_data and 'standard_lead_time' in supplier_data:
|
||||
lead_time_days = supplier_data['standard_lead_time']
|
||||
logger.debug(
|
||||
f"Using supplier lead time for product {product_id}",
|
||||
lead_time=lead_time_days,
|
||||
supplier_id=str(product.supplier_id)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch supplier lead time for product {product_id}, using default",
|
||||
error=str(e),
|
||||
supplier_id=str(product.supplier_id)
|
||||
)
|
||||
|
||||
# Product characteristics
|
||||
product_characteristics = {
|
||||
'lead_time_days': 7, # TODO: Get from supplier data
|
||||
'lead_time_days': lead_time_days,
|
||||
'shelf_life_days': 30 if product.is_perishable else 365,
|
||||
'perishable': product.is_perishable
|
||||
}
|
||||
|
||||
6
services/inventory/app/consumers/__init__.py
Normal file
6
services/inventory/app/consumers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Event consumers for inventory service
|
||||
"""
|
||||
from .delivery_event_consumer import DeliveryEventConsumer
|
||||
|
||||
__all__ = ["DeliveryEventConsumer"]
|
||||
241
services/inventory/app/consumers/delivery_event_consumer.py
Normal file
241
services/inventory/app/consumers/delivery_event_consumer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""
|
||||
Delivery Event Consumer
|
||||
|
||||
Listens for delivery.received events from procurement service
|
||||
and automatically updates inventory stock levels.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, Any
|
||||
from decimal import Decimal
|
||||
import structlog
|
||||
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
from app.core.database import database_manager
|
||||
from app.repositories.stock_repository import StockRepository
|
||||
from app.repositories.stock_movement_repository import StockMovementRepository
|
||||
|
||||
logger = structlog.get_logger()
|
||||
|
||||
|
||||
class DeliveryEventConsumer:
|
||||
"""
|
||||
Consumes delivery.received events and updates inventory stock.
|
||||
|
||||
When a delivery is recorded in procurement service:
|
||||
1. Listens for delivery.received event
|
||||
2. Creates stock entries for each delivered item
|
||||
3. Updates stock levels (quantity_available)
|
||||
4. Records batch numbers and expiry dates
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize delivery event consumer"""
|
||||
self.service_name = "inventory"
|
||||
|
||||
async def consume_delivery_received_events(
|
||||
self,
|
||||
rabbitmq_client: RabbitMQClient
|
||||
):
|
||||
"""
|
||||
Start consuming delivery.received events from RabbitMQ
|
||||
|
||||
Args:
|
||||
rabbitmq_client: RabbitMQ client instance
|
||||
"""
|
||||
async def process_message(message):
|
||||
"""Process a single delivery.received event message"""
|
||||
try:
|
||||
async with message.process():
|
||||
# Parse event data
|
||||
event_data = json.loads(message.body.decode())
|
||||
logger.info(
|
||||
"Received delivery.received event",
|
||||
event_id=event_data.get('event_id'),
|
||||
delivery_id=event_data.get('data', {}).get('delivery_id')
|
||||
)
|
||||
|
||||
# Process the delivery and update stock
|
||||
success = await self.process_delivery_stock_update(event_data)
|
||||
|
||||
if success:
|
||||
logger.info(
|
||||
"Successfully processed delivery stock update",
|
||||
delivery_id=event_data.get('data', {}).get('delivery_id')
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to process delivery stock update",
|
||||
delivery_id=event_data.get('data', {}).get('delivery_id')
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error processing delivery.received event",
|
||||
error=str(e),
|
||||
exc_info=True
|
||||
)
|
||||
|
||||
# Start consuming events
|
||||
await rabbitmq_client.consume_events(
|
||||
exchange_name="procurement.events",
|
||||
queue_name="inventory.delivery.received",
|
||||
routing_key="delivery.received",
|
||||
callback=process_message
|
||||
)
|
||||
|
||||
logger.info("Started consuming delivery.received events")
|
||||
|
||||
async def process_delivery_stock_update(self, event_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Process delivery event and update stock levels.
|
||||
|
||||
Args:
|
||||
event_data: Full event payload from RabbitMQ
|
||||
|
||||
Returns:
|
||||
bool: True if stock updated successfully
|
||||
"""
|
||||
try:
|
||||
data = event_data.get('data', {})
|
||||
|
||||
# Extract delivery information
|
||||
tenant_id = uuid.UUID(data.get('tenant_id'))
|
||||
delivery_id = uuid.UUID(data.get('delivery_id'))
|
||||
po_id = uuid.UUID(data.get('po_id'))
|
||||
items = data.get('items', [])
|
||||
received_by = data.get('received_by')
|
||||
received_at = data.get('received_at')
|
||||
|
||||
if not items:
|
||||
logger.warning(
|
||||
"No items in delivery event, skipping stock update",
|
||||
delivery_id=str(delivery_id)
|
||||
)
|
||||
return False
|
||||
|
||||
# Process each item
|
||||
async with database_manager.get_session() as session:
|
||||
stock_repo = StockRepository(session)
|
||||
movement_repo = StockMovementRepository(session)
|
||||
|
||||
for item in items:
|
||||
try:
|
||||
# inventory_product_id is the same as ingredient_id
|
||||
# The ingredients table serves as a unified catalog for both raw materials and products
|
||||
ingredient_id = uuid.UUID(item.get('inventory_product_id'))
|
||||
accepted_quantity = Decimal(str(item.get('accepted_quantity', 0)))
|
||||
|
||||
# Only process if quantity was accepted
|
||||
if accepted_quantity <= 0:
|
||||
logger.debug(
|
||||
"Skipping item with zero accepted quantity",
|
||||
ingredient_id=str(ingredient_id)
|
||||
)
|
||||
continue
|
||||
|
||||
# Create a new stock batch entry for this delivery
|
||||
# The Stock model uses batch tracking - each delivery creates a new batch entry
|
||||
stock_data = {
|
||||
'tenant_id': tenant_id,
|
||||
'ingredient_id': ingredient_id,
|
||||
'batch_number': item.get('batch_lot_number'),
|
||||
'lot_number': item.get('batch_lot_number'), # Use same as batch_number
|
||||
'supplier_batch_ref': item.get('batch_lot_number'),
|
||||
|
||||
# Quantities
|
||||
'current_quantity': float(accepted_quantity),
|
||||
'reserved_quantity': 0.0,
|
||||
'available_quantity': float(accepted_quantity),
|
||||
|
||||
# Dates
|
||||
'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'),
|
||||
|
||||
# Production stage - default to raw ingredient for deliveries
|
||||
'production_stage': 'raw_ingredient',
|
||||
|
||||
# Status
|
||||
'is_available': True,
|
||||
'quality_status': 'GOOD'
|
||||
}
|
||||
|
||||
from app.schemas.inventory import StockCreate
|
||||
stock_create = StockCreate(**stock_data)
|
||||
stock = await stock_repo.create_stock_entry(stock_create, tenant_id)
|
||||
|
||||
logger.info(
|
||||
"Created new stock batch from delivery",
|
||||
ingredient_id=str(ingredient_id),
|
||||
stock_id=str(stock.id),
|
||||
batch_number=item.get('batch_lot_number'),
|
||||
quantity=float(accepted_quantity),
|
||||
delivery_id=str(delivery_id)
|
||||
)
|
||||
|
||||
# Create stock movement record for audit trail
|
||||
from app.models.inventory import StockMovementType
|
||||
from app.schemas.inventory import StockMovementCreate
|
||||
|
||||
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
|
||||
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')}",
|
||||
movement_date=datetime.fromisoformat(received_at.replace('Z', '+00:00')) if received_at else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
movement = await movement_repo.create_movement(
|
||||
movement_data=movement_data,
|
||||
tenant_id=tenant_id,
|
||||
created_by=uuid.UUID(received_by) if received_by else None,
|
||||
quantity_before=0.0, # New batch starts at 0
|
||||
quantity_after=float(accepted_quantity)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Created stock movement for delivery",
|
||||
movement_id=str(movement.id),
|
||||
ingredient_id=str(ingredient_id),
|
||||
quantity=float(accepted_quantity),
|
||||
batch=item.get('batch_lot_number')
|
||||
)
|
||||
|
||||
except Exception as item_error:
|
||||
logger.error(
|
||||
"Error processing delivery item",
|
||||
error=str(item_error),
|
||||
item=item,
|
||||
exc_info=True
|
||||
)
|
||||
# Continue processing other items even if one fails
|
||||
continue
|
||||
|
||||
# Commit all changes
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Successfully processed delivery stock update",
|
||||
delivery_id=str(delivery_id),
|
||||
items_processed=len(items)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error in delivery stock update",
|
||||
error=str(e),
|
||||
delivery_id=data.get('delivery_id'),
|
||||
exc_info=True
|
||||
)
|
||||
return False
|
||||
@@ -11,7 +11,9 @@ from sqlalchemy import text
|
||||
from app.core.config import settings
|
||||
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
|
||||
import asyncio
|
||||
|
||||
from app.api import (
|
||||
ingredients,
|
||||
@@ -34,12 +36,7 @@ from app.api import (
|
||||
class InventoryService(StandardFastAPIService):
|
||||
"""Inventory Service with standardized setup"""
|
||||
|
||||
expected_migration_version = "00001"
|
||||
|
||||
async def on_startup(self, app):
|
||||
"""Custom startup logic including migration verification"""
|
||||
await self.verify_migrations()
|
||||
await super().on_startup(app)
|
||||
expected_migration_version = "make_stock_fields_nullable"
|
||||
|
||||
async def verify_migrations(self):
|
||||
"""Verify database schema matches the latest migrations."""
|
||||
@@ -62,6 +59,11 @@ class InventoryService(StandardFastAPIService):
|
||||
'stock_alerts', 'food_safety_compliance', 'temperature_logs', 'food_safety_alerts'
|
||||
]
|
||||
|
||||
# Initialize delivery consumer and rabbitmq client
|
||||
self.delivery_consumer = None
|
||||
self.delivery_consumer_task = None
|
||||
self.rabbitmq_client = None
|
||||
|
||||
super().__init__(
|
||||
service_name="inventory-service",
|
||||
app_name=settings.APP_NAME,
|
||||
@@ -71,11 +73,38 @@ class InventoryService(StandardFastAPIService):
|
||||
cors_origins=settings.CORS_ORIGINS,
|
||||
api_prefix="", # Empty because RouteBuilder already includes /api/v1
|
||||
database_manager=database_manager,
|
||||
expected_tables=inventory_expected_tables
|
||||
expected_tables=inventory_expected_tables,
|
||||
enable_messaging=True # Enable RabbitMQ for event consumption
|
||||
)
|
||||
|
||||
async def _setup_messaging(self):
|
||||
"""Setup messaging for inventory service"""
|
||||
from shared.messaging.rabbitmq import RabbitMQClient
|
||||
try:
|
||||
self.rabbitmq_client = RabbitMQClient(settings.RABBITMQ_URL, service_name="inventory-service")
|
||||
await self.rabbitmq_client.connect()
|
||||
self.logger.info("Inventory service messaging setup completed")
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to setup inventory messaging", error=str(e))
|
||||
raise
|
||||
|
||||
async def _cleanup_messaging(self):
|
||||
"""Cleanup messaging for inventory service"""
|
||||
try:
|
||||
if self.rabbitmq_client:
|
||||
await self.rabbitmq_client.disconnect()
|
||||
self.logger.info("Inventory service messaging cleanup completed")
|
||||
except Exception as e:
|
||||
self.logger.error("Error during inventory messaging cleanup", error=str(e))
|
||||
|
||||
async def on_startup(self, app: FastAPI):
|
||||
"""Custom startup logic for inventory service"""
|
||||
# Verify migrations first
|
||||
await self.verify_migrations()
|
||||
|
||||
# Call parent startup (includes database, messaging, etc.)
|
||||
await super().on_startup(app)
|
||||
|
||||
# Initialize alert service
|
||||
alert_service = InventoryAlertService(settings)
|
||||
await alert_service.start()
|
||||
@@ -84,13 +113,37 @@ class InventoryService(StandardFastAPIService):
|
||||
# Store alert service in app state
|
||||
app.state.alert_service = alert_service
|
||||
|
||||
# Initialize and start delivery event consumer
|
||||
self.delivery_consumer = DeliveryEventConsumer()
|
||||
|
||||
# Start consuming delivery.received events in background
|
||||
if self.rabbitmq_client and self.rabbitmq_client.connected:
|
||||
self.delivery_consumer_task = asyncio.create_task(
|
||||
self.delivery_consumer.consume_delivery_received_events(self.rabbitmq_client)
|
||||
)
|
||||
self.logger.info("Delivery event consumer started successfully")
|
||||
else:
|
||||
self.logger.warning("RabbitMQ not connected, delivery event consumer not started")
|
||||
|
||||
app.state.delivery_consumer = self.delivery_consumer
|
||||
|
||||
async def on_shutdown(self, app: FastAPI):
|
||||
"""Custom shutdown logic for inventory service"""
|
||||
# Cancel delivery consumer task
|
||||
if self.delivery_consumer_task and not self.delivery_consumer_task.done():
|
||||
self.delivery_consumer_task.cancel()
|
||||
try:
|
||||
await self.delivery_consumer_task
|
||||
except asyncio.CancelledError:
|
||||
self.logger.info("Delivery event consumer task cancelled")
|
||||
|
||||
# Stop alert service
|
||||
if hasattr(app.state, 'alert_service'):
|
||||
await app.state.alert_service.stop()
|
||||
self.logger.info("Alert service stopped")
|
||||
|
||||
await super().on_shutdown(app)
|
||||
|
||||
def get_service_features(self):
|
||||
"""Return inventory-specific features"""
|
||||
return [
|
||||
|
||||
@@ -332,7 +332,10 @@ class IngredientRepository(BaseRepository[Ingredient, IngredientCreate, Ingredie
|
||||
'ingredient': ingredient,
|
||||
'current_stock': float(current_stock) if current_stock else 0.0,
|
||||
'threshold': ingredient.low_stock_threshold,
|
||||
'needs_reorder': current_stock <= ingredient.reorder_point if current_stock else True
|
||||
'needs_reorder': (
|
||||
current_stock <= ingredient.reorder_point
|
||||
if current_stock and ingredient.reorder_point is not None else True
|
||||
)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@@ -98,6 +98,28 @@ class StockRepository(BaseRepository[Stock, StockCreate, StockUpdate], BatchCoun
|
||||
logger.error("Failed to get stock by ingredient", error=str(e), ingredient_id=ingredient_id)
|
||||
raise
|
||||
|
||||
async def get_stock_by_product(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
inventory_product_id: UUID,
|
||||
include_unavailable: bool = False
|
||||
) -> List[Stock]:
|
||||
"""
|
||||
Get all stock entries for a specific product.
|
||||
|
||||
Note: inventory_product_id and ingredient_id refer to the same entity.
|
||||
The 'ingredients' table is used as a unified catalog for both raw ingredients
|
||||
and finished products, distinguished by the product_type field.
|
||||
|
||||
This method is an alias for get_stock_by_ingredient for clarity when called
|
||||
from contexts that use 'product' terminology (e.g., procurement service).
|
||||
"""
|
||||
return await self.get_stock_by_ingredient(
|
||||
tenant_id=tenant_id,
|
||||
ingredient_id=inventory_product_id,
|
||||
include_unavailable=include_unavailable
|
||||
)
|
||||
|
||||
async def get_total_stock_by_ingredient(self, tenant_id: UUID, ingredient_id: UUID) -> Dict[str, float]:
|
||||
"""Get total stock quantities for an ingredient"""
|
||||
try:
|
||||
|
||||
@@ -115,8 +115,14 @@ class InventoryService:
|
||||
|
||||
response = IngredientResponse(**ingredient_dict)
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
|
||||
response.is_low_stock = (
|
||||
stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
if ingredient.low_stock_threshold is not None else False
|
||||
)
|
||||
response.needs_reorder = (
|
||||
stock_totals['total_available'] <= ingredient.reorder_point
|
||||
if ingredient.reorder_point is not None else False
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -171,8 +177,14 @@ class InventoryService:
|
||||
|
||||
response = IngredientResponse(**ingredient_dict)
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= updated_ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= updated_ingredient.reorder_point
|
||||
response.is_low_stock = (
|
||||
stock_totals['total_available'] <= updated_ingredient.low_stock_threshold
|
||||
if updated_ingredient.low_stock_threshold is not None else False
|
||||
)
|
||||
response.needs_reorder = (
|
||||
stock_totals['total_available'] <= updated_ingredient.reorder_point
|
||||
if updated_ingredient.reorder_point is not None else False
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
@@ -214,8 +226,14 @@ class InventoryService:
|
||||
|
||||
response = IngredientResponse(**ingredient_dict)
|
||||
response.current_stock = stock_totals['total_available']
|
||||
response.is_low_stock = stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
response.needs_reorder = stock_totals['total_available'] <= ingredient.reorder_point
|
||||
response.is_low_stock = (
|
||||
stock_totals['total_available'] <= ingredient.low_stock_threshold
|
||||
if ingredient.low_stock_threshold is not None else False
|
||||
)
|
||||
response.needs_reorder = (
|
||||
stock_totals['total_available'] <= ingredient.reorder_point
|
||||
if ingredient.reorder_point is not None else False
|
||||
)
|
||||
|
||||
responses.append(response)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user