Files
bakery-ia/services/inventory/app/consumers/delivery_event_consumer.py

273 lines
12 KiB
Python

"""
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 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
# 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,
'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 - extracted from delivery item
'unit_cost': unit_cost,
'total_cost': total_cost,
# 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
# 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=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')}",
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