273 lines
12 KiB
Python
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
|