Initial commit - production deployment

This commit is contained in:
2026-01-21 17:17:16 +01:00
commit c23d00dd92
2289 changed files with 638440 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,272 @@
"""
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

View File

@@ -0,0 +1,256 @@
"""
Inventory Transfer Event Consumer
Listens for completed internal transfers and handles inventory ownership transfer
"""
import asyncio
import structlog
from typing import Dict, Any
import json
from app.services.internal_transfer_service import InternalTransferInventoryService
from shared.messaging import RabbitMQClient
logger = structlog.get_logger()
class InventoryTransferEventConsumer:
"""
Consumer for inventory transfer events triggered by internal transfers
"""
def __init__(
self,
internal_transfer_service: InternalTransferInventoryService,
rabbitmq_client: RabbitMQClient
):
self.internal_transfer_service = internal_transfer_service
self.rabbitmq_client = rabbitmq_client
self.is_running = False
async def start_consuming(self):
"""
Start consuming inventory transfer events
"""
logger.info("Starting inventory transfer event consumer")
self.is_running = True
# Declare exchange and queue for internal transfer events
await self.rabbitmq_client.declare_exchange("internal_transfers", "topic")
await self.rabbitmq_client.declare_queue("inventory_service_internal_transfers")
await self.rabbitmq_client.bind_queue_to_exchange(
queue_name="inventory_service_internal_transfers",
exchange_name="internal_transfers",
routing_key="internal_transfer.completed"
)
# Start consuming
await self.rabbitmq_client.consume(
queue_name="inventory_service_internal_transfers",
callback=self.handle_internal_transfer_completed,
auto_ack=False
)
logger.info("Inventory transfer event consumer started")
async def handle_internal_transfer_completed(self, message):
"""
Handle internal transfer completed event
This means a shipment has been delivered and inventory ownership should transfer
"""
try:
event_data = json.loads(message.body.decode())
logger.info("Processing internal transfer completed event", event_data=event_data)
# Extract data from the event
shipment_id = event_data.get('shipment_id')
parent_tenant_id = event_data.get('parent_tenant_id')
child_tenant_id = event_data.get('child_tenant_id')
items = event_data.get('items', [])
if not all([shipment_id, parent_tenant_id, child_tenant_id, items]):
logger.error("Missing required data in internal transfer event", event_data=event_data)
await message.nack(requeue=False) # Don't retry invalid messages
return
# Process the inventory transfer for each item
transfer_results = []
errors = []
for item in items:
product_id = item.get('product_id')
delivered_quantity = item.get('delivered_quantity')
if not all([product_id, delivered_quantity]):
errors.append({
'error': 'Missing product_id or delivered_quantity',
'item': item
})
continue
try:
# Deduct from parent inventory
await self._transfer_inventory_from_parent(
parent_tenant_id=parent_tenant_id,
product_id=product_id,
quantity=delivered_quantity
)
# Add to child inventory
await self._transfer_inventory_to_child(
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=delivered_quantity
)
transfer_results.append({
'product_id': product_id,
'quantity': delivered_quantity,
'status': 'completed'
})
logger.info(
"Inventory transferred successfully",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=delivered_quantity
)
except Exception as item_error:
logger.error(
"Failed to transfer inventory for item",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
product_id=product_id,
error=str(item_error)
)
errors.append({
'product_id': product_id,
'quantity': delivered_quantity,
'error': str(item_error)
})
# Acknowledge message after processing
await message.ack()
logger.info(
"Internal transfer processed",
shipment_id=shipment_id,
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
successful_transfers=len(transfer_results),
failed_transfers=len(errors)
)
except Exception as e:
logger.error("Error processing internal transfer event", error=str(e), exc_info=True)
# Nack with requeue=True to retry on transient errors
await message.nack(requeue=True)
async def _transfer_inventory_from_parent(
self,
parent_tenant_id: str,
product_id: str,
quantity: float
):
"""
Deduct inventory from parent tenant
"""
try:
# Create stock movement to reduce parent inventory
stock_movement_data = {
"product_id": product_id,
"movement_type": "internal_transfer_out",
"quantity": -float(quantity), # Negative for outflow
"reference_type": "internal_transfer",
"reference_id": f"transfer_{parent_tenant_id}_to_{product_id}", # Would have actual transfer ID
"source_tenant_id": parent_tenant_id,
"destination_tenant_id": None, # Will be set when we know the child
"notes": f"Internal transfer to child tenant"
}
# Call inventory service to process the movement
await self.internal_transfer_service.inventory_client.create_stock_movement(
tenant_id=parent_tenant_id,
movement_data=stock_movement_data
)
logger.info(
"Inventory deducted from parent tenant",
parent_tenant_id=parent_tenant_id,
product_id=product_id,
quantity=quantity
)
except Exception as e:
logger.error(
"Error deducting inventory from parent",
parent_tenant_id=parent_tenant_id,
product_id=product_id,
error=str(e)
)
raise
async def _transfer_inventory_to_child(
self,
child_tenant_id: str,
product_id: str,
quantity: float
):
"""
Add inventory to child tenant
"""
try:
# Create stock movement to increase child inventory
stock_movement_data = {
"product_id": product_id,
"movement_type": "internal_transfer_in",
"quantity": float(quantity), # Positive for inflow
"reference_type": "internal_transfer",
"reference_id": f"transfer_from_parent_{product_id}_to_{child_tenant_id}", # Would have actual transfer ID
"source_tenant_id": None, # Will be set when we know the parent
"destination_tenant_id": child_tenant_id,
"notes": f"Internal transfer from parent tenant"
}
# Call inventory service to process the movement
await self.internal_transfer_service.inventory_client.create_stock_movement(
tenant_id=child_tenant_id,
movement_data=stock_movement_data
)
logger.info(
"Inventory added to child tenant",
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=quantity
)
except Exception as e:
logger.error(
"Error adding inventory to child",
child_tenant_id=child_tenant_id,
product_id=product_id,
error=str(e)
)
raise
async def stop_consuming(self):
"""
Stop consuming inventory transfer events
"""
logger.info("Stopping inventory transfer event consumer")
self.is_running = False
# In a real implementation, we would close the RabbitMQ connection
logger.info("Inventory transfer event consumer stopped")
async def health_check(self) -> Dict[str, Any]:
"""
Health check for the consumer
"""
return {
"consumer": "inventory_transfer_event_consumer",
"status": "running" if self.is_running else "stopped",
"timestamp": datetime.utcnow().isoformat()
}