New enterprise feature

This commit is contained in:
Urtzi Alfaro
2025-11-30 09:12:40 +01:00
parent f9d0eec6ec
commit 972db02f6d
176 changed files with 19741 additions and 1361 deletions

View File

@@ -24,17 +24,14 @@ from shared.utils.demo_dates import adjust_date_for_demo, BASE_REFERENCE_DATE
logger = structlog.get_logger()
router = APIRouter(prefix="/internal/demo", tags=["internal"])
# Internal API key for service-to-service auth
INTERNAL_API_KEY = os.getenv("INTERNAL_API_KEY", "dev-internal-key-change-in-production")
# Base demo tenant IDs
DEMO_TENANT_SAN_PABLO = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
DEMO_TENANT_LA_ESPIGA = "b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7"
DEMO_TENANT_PROFESSIONAL = "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"
def verify_internal_api_key(x_internal_api_key: Optional[str] = Header(None)):
"""Verify internal API key for service-to-service communication"""
if x_internal_api_key != INTERNAL_API_KEY:
from app.core.config import settings
if x_internal_api_key != settings.INTERNAL_API_KEY:
logger.warning("Unauthorized internal API access attempted")
raise HTTPException(status_code=403, detail="Invalid internal API key")
return True

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.rabbitmq 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()
}

View File

@@ -0,0 +1,484 @@
"""
Internal Transfer Service for Inventory Management
Handles inventory ownership changes during internal transfers
"""
import logging
from typing import Dict, Any, List
from datetime import datetime
from decimal import Decimal
import uuid
from shared.clients.tenant_client import TenantServiceClient
from shared.clients.inventory_client import InventoryServiceClient
logger = logging.getLogger(__name__)
class InternalTransferInventoryService:
"""
Service for handling inventory transfers during enterprise internal transfers
"""
def __init__(
self,
tenant_client: TenantServiceClient,
inventory_client: InventoryServiceClient
):
self.tenant_client = tenant_client
self.inventory_client = inventory_client
async def process_internal_delivery(
self,
parent_tenant_id: str,
child_tenant_id: str,
shipment_items: List[Dict[str, Any]],
shipment_id: str
) -> Dict[str, Any]:
"""
Process inventory ownership transfer when internal shipment is delivered
Args:
parent_tenant_id: Source tenant (central production)
child_tenant_id: Destination tenant (retail outlet)
shipment_items: List of items being transferred with quantities
shipment_id: ID of the shipment for reference
Returns:
Dict with transfer results
"""
try:
logger.info(
"Processing internal inventory transfer",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
shipment_id=shipment_id,
item_count=len(shipment_items)
)
# Process each item in the shipment
successful_transfers = []
failed_transfers = []
for item in shipment_items:
product_id = item.get('product_id')
quantity = Decimal(str(item.get('delivered_quantity', item.get('quantity', 0))))
if not product_id or quantity <= 0:
logger.warning(
"Skipping invalid transfer item",
product_id=product_id,
quantity=quantity
)
continue
try:
# Step 1: Deduct inventory from parent (central production)
parent_subtraction_result = await self._subtract_from_parent_inventory(
parent_tenant_id=parent_tenant_id,
product_id=product_id,
quantity=quantity,
shipment_id=shipment_id
)
# Step 2: Add inventory to child (retail outlet)
child_addition_result = await self._add_to_child_inventory(
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=quantity,
shipment_id=shipment_id
)
successful_transfers.append({
'product_id': product_id,
'quantity': float(quantity),
'parent_result': parent_subtraction_result,
'child_result': child_addition_result
})
logger.info(
"Internal inventory transfer completed",
product_id=product_id,
quantity=float(quantity),
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id
)
except Exception as item_error:
logger.error(
"Failed to process inventory transfer for item",
product_id=product_id,
quantity=float(quantity),
error=str(item_error),
exc_info=True
)
failed_transfers.append({
'product_id': product_id,
'quantity': float(quantity),
'error': str(item_error)
})
# Update shipment status in inventory records to reflect completed transfer
await self._mark_shipment_as_completed_in_inventory(
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
shipment_id=shipment_id
)
total_transferred = sum(item['quantity'] for item in successful_transfers)
result = {
'shipment_id': shipment_id,
'parent_tenant_id': parent_tenant_id,
'child_tenant_id': child_tenant_id,
'transfers_completed': len(successful_transfers),
'transfers_failed': len(failed_transfers),
'total_quantity_transferred': total_transferred,
'successful_transfers': successful_transfers,
'failed_transfers': failed_transfers,
'status': 'completed' if failed_transfers == 0 else 'partial_success',
'processed_at': datetime.utcnow().isoformat()
}
logger.info(
"Internal inventory transfer processing completed",
shipment_id=shipment_id,
successfully_processed=len(successful_transfers),
failed_count=len(failed_transfers)
)
return result
except Exception as e:
logger.error(
"Error processing internal inventory transfer",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
shipment_id=shipment_id,
error=str(e),
exc_info=True
)
raise
async def _subtract_from_parent_inventory(
self,
parent_tenant_id: str,
product_id: str,
quantity: Decimal,
shipment_id: str
) -> Dict[str, Any]:
"""
Subtract inventory from parent tenant (central production)
"""
try:
# Check current inventory level in parent
parent_stock = await self.inventory_client.get_product_stock(
tenant_id=parent_tenant_id,
product_id=product_id
)
current_stock = Decimal(str(parent_stock.get('available_quantity', 0)))
if current_stock < quantity:
raise ValueError(
f"Insufficient inventory in parent tenant {parent_tenant_id}. "
f"Required: {quantity}, Available: {current_stock}"
)
# Create stock movement record with negative quantity
stock_movement_data = {
'product_id': product_id,
'movement_type': 'INTERNAL_TRANSFER_OUT',
'quantity': float(-quantity), # Negative for outbound
'reference_type': 'internal_transfer',
'reference_id': shipment_id,
'source_tenant_id': parent_tenant_id,
'destination_tenant_id': parent_tenant_id, # Self-reference for tracking
'notes': f'Shipment to child tenant #{shipment_id}'
}
# Execute the stock movement
movement_result = await self.inventory_client.create_stock_movement(
tenant_id=parent_tenant_id,
movement_data=stock_movement_data
)
logger.info(
"Inventory subtracted from parent",
parent_tenant_id=parent_tenant_id,
product_id=product_id,
quantity=float(quantity),
movement_id=movement_result.get('id')
)
return {
'movement_id': movement_result.get('id'),
'quantity_subtracted': float(quantity),
'new_balance': float(current_stock - quantity),
'status': 'success'
}
except Exception as e:
logger.error(
"Error subtracting from parent inventory",
parent_tenant_id=parent_tenant_id,
product_id=product_id,
quantity=float(quantity),
error=str(e)
)
raise
async def _add_to_child_inventory(
self,
child_tenant_id: str,
product_id: str,
quantity: Decimal,
shipment_id: str
) -> Dict[str, Any]:
"""
Add inventory to child tenant (retail outlet)
"""
try:
# Create stock movement record with positive quantity
stock_movement_data = {
'product_id': product_id,
'movement_type': 'INTERNAL_TRANSFER_IN',
'quantity': float(quantity), # Positive for inbound
'reference_type': 'internal_transfer',
'reference_id': shipment_id,
'source_tenant_id': child_tenant_id, # Self-reference from parent
'destination_tenant_id': child_tenant_id,
'notes': f'Internal transfer from parent tenant shipment #{shipment_id}'
}
# Execute the stock movement
movement_result = await self.inventory_client.create_stock_movement(
tenant_id=child_tenant_id,
movement_data=stock_movement_data
)
logger.info(
"Inventory added to child",
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=float(quantity),
movement_id=movement_result.get('id')
)
return {
'movement_id': movement_result.get('id'),
'quantity_added': float(quantity),
'status': 'success'
}
except Exception as e:
logger.error(
"Error adding to child inventory",
child_tenant_id=child_tenant_id,
product_id=product_id,
quantity=float(quantity),
error=str(e)
)
raise
async def _mark_shipment_as_completed_in_inventory(
self,
parent_tenant_id: str,
child_tenant_id: str,
shipment_id: str
):
"""
Update inventory records to mark shipment as completed
"""
try:
# In a real implementation, this would update inventory tracking records
# to reflect that the internal transfer is complete
# For now, we'll just log that we're tracking this
logger.info(
"Marked internal transfer as completed in inventory tracking",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
shipment_id=shipment_id
)
except Exception as e:
logger.error(
"Error updating inventory completion status",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
shipment_id=shipment_id,
error=str(e)
)
# This is not critical enough to fail the entire operation
async def get_internal_transfer_history(
self,
parent_tenant_id: str,
child_tenant_id: str = None,
start_date: str = None,
end_date: str = None,
limit: int = 100
) -> List[Dict[str, Any]]:
"""
Get history of internal inventory transfers
Args:
parent_tenant_id: Parent tenant ID
child_tenant_id: Optional child tenant ID to filter by
start_date: Optional start date filter
end_date: Optional end date filter
limit: Max results to return
Returns:
List of internal transfer records
"""
try:
# Build filter conditions
filters = {
'reference_type': 'internal_transfer'
}
if child_tenant_id:
filters['destination_tenant_id'] = child_tenant_id
if start_date:
filters['created_after'] = start_date
if end_date:
filters['created_before'] = end_date
# Query inventory movements for internal transfers
parent_movements = await self.inventory_client.get_stock_movements(
tenant_id=parent_tenant_id,
filters=filters,
limit=limit
)
# Filter for outbound transfers (negative values)
outbound_transfers = [m for m in parent_movements if m.get('quantity', 0) < 0]
# Also get inbound transfers for the children if specified
all_transfers = outbound_transfers
if child_tenant_id:
child_movements = await self.inventory_client.get_stock_movements(
tenant_id=child_tenant_id,
filters=filters,
limit=limit
)
# Filter for inbound transfers (positive values)
inbound_transfers = [m for m in child_movements if m.get('quantity', 0) > 0]
all_transfers.extend(inbound_transfers)
# Sort by creation date (most recent first)
all_transfers.sort(key=lambda x: x.get('created_at', ''), reverse=True)
return all_transfers[:limit]
except Exception as e:
logger.error(
"Error getting internal transfer history",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
error=str(e)
)
raise
async def validate_internal_transfer_eligibility(
self,
parent_tenant_id: str,
child_tenant_id: str,
items: List[Dict[str, Any]]
) -> Dict[str, Any]:
"""
Validate that internal transfer is possible (sufficient inventory, etc.)
Args:
parent_tenant_id: Parent tenant ID (supplier)
child_tenant_id: Child tenant ID (recipient)
items: List of items to transfer
Returns:
Dict with validation results
"""
try:
logger.info(
"Validating internal transfer eligibility",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
item_count=len(items)
)
validation_results = {
'eligible': True,
'errors': [],
'warnings': [],
'inventory_check': []
}
for item in items:
product_id = item.get('product_id')
quantity = Decimal(str(item.get('quantity', 0)))
if quantity <= 0:
validation_results['errors'].append({
'product_id': product_id,
'error': 'Quantity must be greater than 0',
'quantity': float(quantity)
})
continue
# Check if parent has sufficient inventory
try:
parent_stock = await self.inventory_client.get_product_stock(
tenant_id=parent_tenant_id,
product_id=product_id
)
available_quantity = Decimal(str(parent_stock.get('available_quantity', 0)))
if available_quantity < quantity:
validation_results['errors'].append({
'product_id': product_id,
'error': 'Insufficient inventory in parent tenant',
'available': float(available_quantity),
'requested': float(quantity)
})
else:
validation_results['inventory_check'].append({
'product_id': product_id,
'available': float(available_quantity),
'requested': float(quantity),
'sufficient': True
})
except Exception as stock_error:
logger.error(
"Error checking parent inventory for validation",
product_id=product_id,
error=str(stock_error)
)
validation_results['errors'].append({
'product_id': product_id,
'error': f'Error checking inventory: {str(stock_error)}'
})
# Overall eligibility based on errors
validation_results['eligible'] = len(validation_results['errors']) == 0
logger.info(
"Internal transfer validation completed",
eligible=validation_results['eligible'],
error_count=len(validation_results['errors'])
)
return validation_results
except Exception as e:
logger.error(
"Error validating internal transfer eligibility",
parent_tenant_id=parent_tenant_id,
child_tenant_id=child_tenant_id,
error=str(e)
)
raise