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

@@ -29,6 +29,16 @@ The **Inventory Service** is the operational backbone of Bakery-IA, managing ing
- **Zero Manual Entry** - Eliminates manual stock entry after deliveries
- **Real-Time Synchronization** - Stock levels update immediately when deliveries are recorded
### 🆕 Enterprise Tier: Internal Transfer Processing (NEW)
- **Automatic Ownership Transfer** - When shipments are delivered, inventory ownership automatically transfers from parent to child
- **Stock Deduction at Parent** - Parent's inventory is reduced when shipment departs
- **Stock Addition at Child** - Child's inventory increases when shipment is delivered
- **Transfer Event Processing** - Consumes `shipment.delivered` events from Distribution Service
- **Dual-Sided Recording** - Creates stock movement records for both source (parent) and destination (child)
- **Transfer Movement Type** - Special stock movement type `transfer_out` (parent) and `transfer_in` (child)
- **Audit Trail** - Complete visibility into inter-location transfers
- **Subscription Validation** - Enterprise transfer processing requires Enterprise tier
### Food Safety Compliance (HACCP)
- **Temperature Monitoring** - Critical control point temperature logs
- **Food Safety Alerts** - Automated safety notifications
@@ -178,16 +188,21 @@ CREATE TABLE stock_movements (
tenant_id UUID NOT NULL,
stock_id UUID REFERENCES stock(id),
ingredient_id UUID REFERENCES ingredients(id),
movement_type VARCHAR(50) NOT NULL, -- in, out, adjustment, waste, production
movement_type VARCHAR(50) NOT NULL, -- in, out, adjustment, waste, production, 🆕 transfer_in, transfer_out (NEW)
quantity DECIMAL(10, 2) NOT NULL,
unit VARCHAR(50) NOT NULL,
reference_id UUID, -- production_batch_id, order_id, etc.
reference_type VARCHAR(50), -- production, sale, adjustment, waste
reference_id UUID, -- production_batch_id, order_id, shipment_id, etc.
reference_type VARCHAR(50), -- production, sale, adjustment, waste, 🆕 internal_transfer (NEW)
reason TEXT,
performed_by UUID,
-- 🆕 Enterprise internal transfer fields (NEW)
source_tenant_id UUID, -- For transfer_out: parent tenant
destination_tenant_id UUID, -- For transfer_in: child tenant
created_at TIMESTAMP DEFAULT NOW(),
INDEX idx_tenant_date (tenant_id, created_at),
INDEX idx_ingredient (ingredient_id)
INDEX idx_ingredient (ingredient_id),
-- 🆕 NEW index for internal transfers
INDEX idx_transfer_tenants (source_tenant_id, destination_tenant_id) WHERE reference_type = 'internal_transfer'
);
```
@@ -351,6 +366,14 @@ CREATE TABLE food_safety_alerts (
- Handles accepted quantities from delivery receipts
- Links stock movements to delivery reference IDs for full traceability
**🆕 From Distribution Service (NEW)**
- **Shipment Delivered** (`shipment.delivered`) - Automatically processes internal transfers when shipments are delivered
- Decreases stock at parent tenant (creates `transfer_out` stock movement)
- Increases stock at child tenant (creates `transfer_in` stock movement)
- Records source_tenant_id and destination_tenant_id for full transfer traceability
- Links both movements to shipment_id for audit trail
- Enterprise tier validation required
**From Other Services**
- **From Production**: Ingredient consumption in production
- **From Sales**: Finished product sales (for inventory valuation)
@@ -486,6 +509,8 @@ pytest --cov=app tests/ --cov-report=html
- **Production Service** - Consume ingredients in production
- **Forecasting Service** - Provide consumption data for forecasts
- **Suppliers Service** - Supplier information for stock items
- **🆕 Distribution Service** (NEW) - Process internal transfers via shipment.delivered events
- **🆕 Tenant Service** (NEW) - Validate tenant hierarchy for internal transfers
- **PostgreSQL** - Inventory data storage
- **Redis** - Dashboard KPI cache
- **RabbitMQ** - Alert publishing and delivery event consumption (🆕)
@@ -496,6 +521,7 @@ pytest --cov=app tests/ --cov-report=html
- **AI Insights Service** - Analyze inventory patterns
- **Frontend Dashboard** - Display inventory status
- **Notification Service** - Send inventory alerts
- **🆕 Distribution Service** (NEW) - Verify inventory availability before creating shipments
## Delivery Event Processing (🆕)

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

View File

@@ -46,8 +46,8 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
def load_ingredients_data():
@@ -205,24 +205,25 @@ async def seed_inventory(db: AsyncSession):
results = []
# Seed for San Pablo (Traditional Bakery)
# Seed for Professional Bakery (single location)
logger.info("")
result_san_pablo = await seed_ingredients_for_tenant(
result_professional = await seed_ingredients_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
DEMO_TENANT_PROFESSIONAL,
"Panadería Artesana Madrid (Professional)",
ingredients_data
)
results.append(result_san_pablo)
results.append(result_professional)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_ingredients_for_tenant(
# Seed for Enterprise Parent (central production - Obrador)
logger.info("")
result_enterprise_parent = await seed_ingredients_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
DEMO_TENANT_ENTERPRISE_CHAIN,
"Panadería Central - Obrador Madrid (Enterprise Parent)",
ingredients_data
)
results.append(result_la_espiga)
results.append(result_enterprise_parent)
# Calculate totals
total_created = sum(r["created"] for r in results)

View File

@@ -0,0 +1,347 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Inventory Retail Seeding Script for Inventory Service
Creates finished product inventory for enterprise child tenants (retail outlets)
This script runs as a Kubernetes init job inside the inventory-service container.
It populates the child retail tenants with FINISHED PRODUCTS ONLY (no raw ingredients).
Usage:
python /app/scripts/demo/seed_demo_inventory_retail.py
Environment Variables Required:
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
DEMO_MODE - Set to 'production' for production seeding
LOG_LEVEL - Logging level (default: INFO)
"""
import asyncio
import uuid
import sys
import os
import json
from datetime import datetime, timezone
from pathlib import Path
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Add shared to path for demo utilities
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from shared.utils.demo_dates import BASE_REFERENCE_DATE
from app.models.inventory import Ingredient, ProductType
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
# Child tenant configurations
CHILD_TENANTS = [
(DEMO_TENANT_CHILD_1, "Madrid Centro"),
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia"),
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa")
]
def load_finished_products_data():
"""Load ONLY finished products from JSON file (no raw ingredients)"""
# Look for data file in the same directory as this script
data_file = Path(__file__).parent / "ingredientes_es.json"
if not data_file.exists():
raise FileNotFoundError(
f"Ingredients data file not found: {data_file}. "
"Make sure ingredientes_es.json is in the same directory as this script."
)
logger.info("Loading finished products data", file=str(data_file))
with open(data_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Extract ONLY finished products (not raw ingredients)
finished_products = data.get("productos_terminados", [])
logger.info(f"Loaded {len(finished_products)} finished products from JSON")
logger.info("NOTE: Raw ingredients (flour, yeast, etc.) are NOT seeded for retail outlets")
return finished_products
async def seed_retail_inventory_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
parent_tenant_id: uuid.UUID,
tenant_name: str,
products_data: list
) -> dict:
"""
Seed finished product inventory for a child retail tenant using XOR ID transformation
This ensures retail outlets have the same product catalog as their parent (central production),
using deterministic UUIDs that map correctly across tenants.
Args:
db: Database session
tenant_id: UUID of the child tenant
parent_tenant_id: UUID of the parent tenant (for XOR transformation)
tenant_name: Name of the tenant (for logging)
products_data: List of finished product dictionaries with pre-defined IDs
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding retail inventory for: {tenant_name}")
logger.info(f"Child Tenant ID: {tenant_id}")
logger.info(f"Parent Tenant ID: {parent_tenant_id}")
logger.info("" * 80)
created_count = 0
skipped_count = 0
for product_data in products_data:
sku = product_data["sku"]
name = product_data["name"]
# Check if product already exists for this tenant with this SKU
result = await db.execute(
select(Ingredient).where(
Ingredient.tenant_id == tenant_id,
Ingredient.sku == sku
)
)
existing_product = result.scalars().first()
if existing_product:
logger.debug(f" ⏭️ Skipping (exists): {sku} - {name}")
skipped_count += 1
continue
# Generate tenant-specific UUID using XOR transformation
# This ensures the child's product IDs map to the parent's product IDs
base_id = uuid.UUID(product_data["id"])
tenant_int = int(tenant_id.hex, 16)
base_int = int(base_id.hex, 16)
product_id = uuid.UUID(int=tenant_int ^ base_int)
# Create new finished product for retail outlet
product = Ingredient(
id=product_id,
tenant_id=tenant_id,
name=name,
sku=sku,
barcode=None, # Could be set by retail outlet
product_type=ProductType.FINISHED_PRODUCT, # CRITICAL: Only finished products
ingredient_category=None, # Not applicable for finished products
product_category=product_data["product_category"], # BREAD, CROISSANTS, PASTRIES, etc.
subcategory=product_data.get("subcategory"),
description=product_data["description"],
brand=f"Obrador Madrid", # Branded from central production
unit_of_measure=product_data["unit_of_measure"],
package_size=None,
average_cost=product_data["average_cost"], # Transfer price from central production
last_purchase_price=product_data["average_cost"],
standard_cost=product_data["average_cost"],
# Retail outlets typically don't manage reorder points - they order from parent
low_stock_threshold=None,
reorder_point=None,
reorder_quantity=None,
max_stock_level=None,
shelf_life_days=product_data.get("shelf_life_days"),
is_perishable=product_data.get("is_perishable", True), # Bakery products are perishable
is_active=True,
allergen_info=product_data.get("allergen_info") if product_data.get("allergen_info") else None,
# Retail outlets receive products, don't produce them locally
produced_locally=False,
recipe_id=None, # Recipes belong to central production, not retail
created_at=BASE_REFERENCE_DATE,
updated_at=BASE_REFERENCE_DATE
)
db.add(product)
created_count += 1
logger.debug(f" ✅ Created: {sku} - {name}")
# Commit all changes for this tenant
await db.commit()
logger.info(f" 📊 Created: {created_count}, Skipped: {skipped_count}")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"created": created_count,
"skipped": skipped_count,
"total": len(products_data)
}
async def seed_retail_inventory(db: AsyncSession):
"""
Seed retail inventory for all child tenant templates
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("🏪 Starting Demo Retail Inventory Seeding")
logger.info("=" * 80)
logger.info("NOTE: Seeding FINISHED PRODUCTS ONLY for child retail outlets")
logger.info("Raw ingredients (flour, yeast, etc.) are NOT seeded for retail tenants")
logger.info("")
# Load finished products data once
try:
products_data = load_finished_products_data()
except FileNotFoundError as e:
logger.error(str(e))
raise
results = []
# Seed for each child retail outlet
for child_tenant_id, child_tenant_name in CHILD_TENANTS:
logger.info("")
result = await seed_retail_inventory_for_tenant(
db,
child_tenant_id,
DEMO_TENANT_ENTERPRISE_CHAIN,
f"{child_tenant_name} (Retail Outlet)",
products_data
)
results.append(result)
# Calculate totals
total_created = sum(r["created"] for r in results)
total_skipped = sum(r["skipped"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Retail Inventory Seeding Completed")
logger.info("=" * 80)
return {
"service": "inventory_retail",
"tenants_seeded": len(results),
"total_created": total_created,
"total_skipped": total_skipped,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Retail Inventory Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to inventory database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
result = await seed_retail_inventory(session)
logger.info("")
logger.info("📊 Retail Inventory Seeding Summary:")
logger.info(f" ✅ Retail outlets seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Total products created: {result['total_created']}")
logger.info(f" ⏭️ Total skipped: {result['total_skipped']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['created']} products created, {tenant_result['skipped']} skipped"
)
logger.info("")
logger.info("🎉 Success! Retail inventory catalog is ready for cloning.")
logger.info("")
logger.info("Finished products seeded:")
logger.info(" • Baguette Tradicional")
logger.info(" • Croissant de Mantequilla")
logger.info(" • Pan de Pueblo")
logger.info(" • Napolitana de Chocolate")
logger.info("")
logger.info("Key points:")
logger.info(" ✓ Only finished products seeded (no raw ingredients)")
logger.info(" ✓ Product IDs use XOR transformation to match parent catalog")
logger.info(" ✓ All products marked as produced_locally=False (received from parent)")
logger.info(" ✓ Retail outlets will receive stock from central production via distribution")
logger.info("")
logger.info("Next steps:")
logger.info(" 1. Seed retail stock levels (initial inventory)")
logger.info(" 2. Seed retail sales history")
logger.info(" 3. Seed customer data and orders")
logger.info(" 4. Test enterprise demo session creation")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Retail Inventory Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)

View File

@@ -52,8 +52,8 @@ structlog.configure(
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_SAN_PABLO = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_LA_ESPIGA = uuid.UUID("b2c3d4e5-f6a7-48b9-c0d1-e2f3a4b5c6d7")
DEMO_TENANT_PROFESSIONAL = uuid.UUID("a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6")
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
# Daily consumption rates (kg/day) - aligned with procurement seed script
# Used to create realistic stock levels that trigger appropriate PO scenarios
@@ -925,22 +925,22 @@ async def seed_stock(db: AsyncSession):
# Seed for San Pablo (Traditional Bakery)
logger.info("")
result_san_pablo = await seed_stock_for_tenant(
result_professional = await seed_stock_for_tenant(
db,
DEMO_TENANT_SAN_PABLO,
"Panadería San Pablo (Traditional)",
DEMO_TENANT_PROFESSIONAL,
"Panadería Artesana Madrid (Professional)",
BASE_REFERENCE_DATE
)
results.append(result_san_pablo)
results.append(result_professional)
# Seed for La Espiga (Central Workshop)
result_la_espiga = await seed_stock_for_tenant(
# Seed for Enterprise Parent (central production - Obrador)
result_enterprise_parent = await seed_stock_for_tenant(
db,
DEMO_TENANT_LA_ESPIGA,
"Panadería La Espiga (Central Workshop)",
DEMO_TENANT_ENTERPRISE_CHAIN,
"Panadería Central - Obrador Madrid (Enterprise Parent)",
BASE_REFERENCE_DATE
)
results.append(result_la_espiga)
results.append(result_enterprise_parent)
# Calculate totals
total_stock = sum(r["stock_created"] for r in results)

View File

@@ -0,0 +1,394 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Demo Retail Stock Seeding Script for Inventory Service
Creates realistic stock levels for finished products at child retail outlets
This script runs as a Kubernetes init job inside the inventory-service container.
It populates child retail tenants with stock levels for FINISHED PRODUCTS ONLY.
Usage:
python /app/scripts/demo/seed_demo_stock_retail.py
Environment Variables Required:
INVENTORY_DATABASE_URL - PostgreSQL connection string for inventory database
DEMO_MODE - Set to 'production' for production seeding
LOG_LEVEL - Logging level (default: INFO)
"""
import asyncio
import uuid
import sys
import os
import random
from datetime import datetime, timezone, timedelta
from pathlib import Path
from decimal import Decimal
# Add app to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Add shared to path for demo utilities
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent))
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
import structlog
from shared.utils.demo_dates import BASE_REFERENCE_DATE
from app.models.inventory import Ingredient, Stock, ProductType
# Configure logging
structlog.configure(
processors=[
structlog.stdlib.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer()
]
)
logger = structlog.get_logger()
# Fixed Demo Tenant IDs (must match tenant service)
DEMO_TENANT_ENTERPRISE_CHAIN = uuid.UUID("c3d4e5f6-a7b8-49c0-d1e2-f3a4b5c6d7e8") # Enterprise parent (Obrador)
DEMO_TENANT_CHILD_1 = uuid.UUID("d4e5f6a7-b8c9-40d1-e2f3-a4b5c6d7e8f9") # Madrid Centro
DEMO_TENANT_CHILD_2 = uuid.UUID("e5f6a7b8-c9d0-41e2-f3a4-b5c6d7e8f9a0") # Barcelona Gràcia
DEMO_TENANT_CHILD_3 = uuid.UUID("f6a7b8c9-d0e1-42f3-a4b5-c6d7e8f9a0b1") # Valencia Ruzafa
# Child tenant configurations
CHILD_TENANTS = [
(DEMO_TENANT_CHILD_1, "Madrid Centro", 1.2), # Larger store, 20% more stock
(DEMO_TENANT_CHILD_2, "Barcelona Gràcia", 1.0), # Medium store, baseline stock
(DEMO_TENANT_CHILD_3, "Valencia Ruzafa", 0.8) # Smaller store, 20% less stock
]
# Retail stock configuration for finished products
# Daily sales estimates (units per day) for each product type
DAILY_SALES_BY_SKU = {
"PRO-BAG-001": 80, # Baguette Tradicional - high volume
"PRO-CRO-001": 50, # Croissant de Mantequilla - popular breakfast item
"PRO-PUE-001": 30, # Pan de Pueblo - specialty item
"PRO-NAP-001": 40 # Napolitana de Chocolate - pastry item
}
# Storage locations for retail outlets
RETAIL_STORAGE_LOCATIONS = ["Display Case", "Back Room", "Cooling Shelf", "Storage Area"]
def generate_retail_batch_number(tenant_id: uuid.UUID, product_sku: str, days_ago: int) -> str:
"""Generate a realistic batch number for retail stock"""
tenant_short = str(tenant_id).split('-')[0].upper()[:4]
date_code = (BASE_REFERENCE_DATE - timedelta(days=days_ago)).strftime("%Y%m%d")
return f"RET-{tenant_short}-{product_sku}-{date_code}"
def calculate_retail_stock_quantity(
product_sku: str,
size_multiplier: float,
create_some_low_stock: bool = False
) -> float:
"""
Calculate realistic retail stock quantity based on daily sales
Args:
product_sku: SKU of the finished product
size_multiplier: Store size multiplier (0.8 for small, 1.0 for medium, 1.2 for large)
create_some_low_stock: If True, 20% chance of low stock scenario
Returns:
Stock quantity in units
"""
daily_sales = DAILY_SALES_BY_SKU.get(product_sku, 20)
# Retail outlets typically stock 1-3 days worth (fresh bakery products)
if create_some_low_stock and random.random() < 0.2:
# Low stock: 0.3-0.8 days worth (need restock soon)
days_of_supply = random.uniform(0.3, 0.8)
else:
# Normal: 1-2.5 days worth
days_of_supply = random.uniform(1.0, 2.5)
quantity = daily_sales * days_of_supply * size_multiplier
# Add realistic variability
quantity *= random.uniform(0.85, 1.15)
return max(5.0, round(quantity)) # Minimum 5 units
async def seed_retail_stock_for_tenant(
db: AsyncSession,
tenant_id: uuid.UUID,
tenant_name: str,
size_multiplier: float
) -> dict:
"""
Seed realistic stock levels for a child retail tenant
Creates multiple stock batches per product with varied freshness levels,
simulating realistic retail bakery inventory with:
- Fresh stock from today's/yesterday's delivery
- Some expiring soon items
- Varied batch sizes and locations
Args:
db: Database session
tenant_id: UUID of the child tenant
tenant_name: Name of the tenant (for logging)
size_multiplier: Store size multiplier for stock quantities
Returns:
Dict with seeding statistics
"""
logger.info("" * 80)
logger.info(f"Seeding retail stock for: {tenant_name}")
logger.info(f"Tenant ID: {tenant_id}")
logger.info(f"Size Multiplier: {size_multiplier}x")
logger.info("" * 80)
# Get all finished products for this tenant
result = await db.execute(
select(Ingredient).where(
Ingredient.tenant_id == tenant_id,
Ingredient.product_type == ProductType.FINISHED_PRODUCT,
Ingredient.is_active == True
)
)
products = result.scalars().all()
if not products:
logger.warning(f"No finished products found for tenant {tenant_id}")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"stock_batches_created": 0,
"products_stocked": 0
}
created_batches = 0
for product in products:
# Create 2-4 batches per product (simulating multiple deliveries/batches)
num_batches = random.randint(2, 4)
for batch_index in range(num_batches):
# Vary delivery dates (0-2 days ago for fresh bakery products)
days_ago = random.randint(0, 2)
received_date = BASE_REFERENCE_DATE - timedelta(days=days_ago)
# Calculate expiration based on shelf life
shelf_life_days = product.shelf_life_days or 2 # Default 2 days for bakery
expiration_date = received_date + timedelta(days=shelf_life_days)
# Calculate quantity for this batch
# Split total quantity across batches with variation
batch_quantity_factor = random.uniform(0.3, 0.7) # Each batch is 30-70% of average
quantity = calculate_retail_stock_quantity(
product.sku,
size_multiplier,
create_some_low_stock=(batch_index == 0) # First batch might be low
) * batch_quantity_factor
# Determine if product is still good
days_until_expiration = (expiration_date - BASE_REFERENCE_DATE).days
is_expired = days_until_expiration < 0
is_available = not is_expired
quality_status = "expired" if is_expired else "good"
# Random storage location
storage_location = random.choice(RETAIL_STORAGE_LOCATIONS)
# Create stock batch
stock_batch = Stock(
id=uuid.uuid4(),
tenant_id=tenant_id,
ingredient_id=product.id,
supplier_id=DEMO_TENANT_ENTERPRISE_CHAIN, # Supplied by parent (Obrador)
batch_number=generate_retail_batch_number(tenant_id, product.sku, days_ago),
lot_number=f"LOT-{BASE_REFERENCE_DATE.strftime('%Y%m%d')}-{batch_index+1:02d}",
supplier_batch_ref=f"OBRADOR-{received_date.strftime('%Y%m%d')}-{random.randint(1000, 9999)}",
production_stage="fully_baked", # Retail receives fully baked products
transformation_reference=None,
current_quantity=quantity,
reserved_quantity=0.0,
available_quantity=quantity if is_available else 0.0,
received_date=received_date,
expiration_date=expiration_date,
best_before_date=expiration_date - timedelta(hours=12) if shelf_life_days == 1 else None,
original_expiration_date=None,
transformation_date=None,
final_expiration_date=expiration_date,
unit_cost=Decimal(str(product.average_cost or 0.5)),
total_cost=Decimal(str(product.average_cost or 0.5)) * Decimal(str(quantity)),
storage_location=storage_location,
warehouse_zone=None, # Retail outlets don't have warehouse zones
shelf_position=None,
requires_refrigeration=False, # Most bakery products don't require refrigeration
requires_freezing=False,
storage_temperature_min=None,
storage_temperature_max=25.0 if product.is_perishable else None, # Room temp
storage_humidity_max=65.0 if product.is_perishable else None,
shelf_life_days=shelf_life_days,
storage_instructions=product.storage_instructions if hasattr(product, 'storage_instructions') else None,
is_available=is_available,
is_expired=is_expired,
quality_status=quality_status,
created_at=received_date,
updated_at=BASE_REFERENCE_DATE
)
db.add(stock_batch)
created_batches += 1
logger.debug(
f" ✅ Created stock batch: {product.name} - "
f"{quantity:.0f} units, expires in {days_until_expiration} days"
)
# Commit all changes for this tenant
await db.commit()
logger.info(f" 📊 Stock batches created: {created_batches} across {len(products)} products")
logger.info("")
return {
"tenant_id": str(tenant_id),
"tenant_name": tenant_name,
"stock_batches_created": created_batches,
"products_stocked": len(products)
}
async def seed_retail_stock(db: AsyncSession):
"""
Seed retail stock for all child tenant templates
Args:
db: Database session
Returns:
Dict with overall seeding statistics
"""
logger.info("=" * 80)
logger.info("📦 Starting Demo Retail Stock Seeding")
logger.info("=" * 80)
logger.info("Creating stock levels for finished products at retail outlets")
logger.info("")
results = []
# Seed for each child retail outlet
for child_tenant_id, child_tenant_name, size_multiplier in CHILD_TENANTS:
logger.info("")
result = await seed_retail_stock_for_tenant(
db,
child_tenant_id,
f"{child_tenant_name} (Retail Outlet)",
size_multiplier
)
results.append(result)
# Calculate totals
total_batches = sum(r["stock_batches_created"] for r in results)
total_products = sum(r["products_stocked"] for r in results)
logger.info("=" * 80)
logger.info("✅ Demo Retail Stock Seeding Completed")
logger.info("=" * 80)
return {
"service": "inventory_stock_retail",
"tenants_seeded": len(results),
"total_batches_created": total_batches,
"total_products_stocked": total_products,
"results": results
}
async def main():
"""Main execution function"""
logger.info("Demo Retail Stock Seeding Script Starting")
logger.info("Mode: %s", os.getenv("DEMO_MODE", "development"))
logger.info("Log Level: %s", os.getenv("LOG_LEVEL", "INFO"))
# Get database URL from environment
database_url = os.getenv("INVENTORY_DATABASE_URL") or os.getenv("DATABASE_URL")
if not database_url:
logger.error("❌ INVENTORY_DATABASE_URL or DATABASE_URL environment variable must be set")
return 1
# Convert to async URL if needed
if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
logger.info("Connecting to inventory database")
# Create engine and session
engine = create_async_engine(
database_url,
echo=False,
pool_pre_ping=True,
pool_size=5,
max_overflow=10
)
async_session = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
try:
async with async_session() as session:
result = await seed_retail_stock(session)
logger.info("")
logger.info("📊 Retail Stock Seeding Summary:")
logger.info(f" ✅ Retail outlets seeded: {result['tenants_seeded']}")
logger.info(f" ✅ Total stock batches: {result['total_batches_created']}")
logger.info(f" ✅ Products stocked: {result['total_products_stocked']}")
logger.info("")
# Print per-tenant details
for tenant_result in result['results']:
logger.info(
f" {tenant_result['tenant_name']}: "
f"{tenant_result['stock_batches_created']} batches, "
f"{tenant_result['products_stocked']} products"
)
logger.info("")
logger.info("🎉 Success! Retail stock levels are ready for cloning.")
logger.info("")
logger.info("Stock characteristics:")
logger.info(" ✓ Multiple batches per product (2-4 batches)")
logger.info(" ✓ Varied freshness levels (0-2 days old)")
logger.info(" ✓ Realistic quantities based on store size")
logger.info(" ✓ Some low-stock scenarios for demo alerts")
logger.info(" ✓ Expiration tracking enabled")
logger.info("")
logger.info("Next steps:")
logger.info(" 1. Seed retail sales history")
logger.info(" 2. Seed customer data")
logger.info(" 3. Test stock alerts and reorder triggers")
logger.info("")
return 0
except Exception as e:
logger.error("=" * 80)
logger.error("❌ Demo Retail Stock Seeding Failed")
logger.error("=" * 80)
logger.error("Error: %s", str(e))
logger.error("", exc_info=True)
return 1
finally:
await engine.dispose()
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)