Add POI feature and imporve the overall backend implementation

This commit is contained in:
Urtzi Alfaro
2025-11-12 15:34:10 +01:00
parent e8096cd979
commit 5783c7ed05
173 changed files with 16862 additions and 9078 deletions

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
"""
Event consumers for inventory service
"""
from .delivery_event_consumer import DeliveryEventConsumer
__all__ = ["DeliveryEventConsumer"]

View 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

View File

@@ -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 [

View File

@@ -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

View File

@@ -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:

View File

@@ -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)