Improve the frontend and repository layer

This commit is contained in:
Urtzi Alfaro
2025-10-23 07:44:54 +02:00
parent 8d30172483
commit 07c33fa578
112 changed files with 14726 additions and 2733 deletions

View File

@@ -379,8 +379,47 @@ def extract_tenant_from_headers(request: Request) -> Optional[str]:
# ================================================================
async def get_current_user_dep(request: Request) -> Dict[str, Any]:
"""FastAPI dependency to get current user"""
return get_current_user(request)
"""FastAPI dependency to get current user - ENHANCED with detailed logging"""
try:
# Log all incoming headers for debugging 401 issues
logger.debug(
"Authentication attempt",
path=request.url.path,
method=request.method,
has_auth_header=bool(request.headers.get("authorization")),
has_x_user_id=bool(request.headers.get("x-user-id")),
has_x_user_type=bool(request.headers.get("x-user-type")),
has_x_service_name=bool(request.headers.get("x-service-name")),
x_user_type=request.headers.get("x-user-type", ""),
x_service_name=request.headers.get("x-service-name", ""),
client_ip=request.client.host if request.client else "unknown"
)
user = get_current_user(request)
logger.info(
"User authenticated successfully",
user_id=user.get("user_id"),
user_type=user.get("type", "user"),
is_service=user.get("type") == "service",
role=user.get("role"),
path=request.url.path
)
return user
except HTTPException as e:
logger.warning(
"Authentication failed - 401",
path=request.url.path,
status_code=e.status_code,
detail=e.detail,
has_x_user_id=bool(request.headers.get("x-user-id")),
x_user_type=request.headers.get("x-user-type", "none"),
x_service_name=request.headers.get("x-service-name", "none"),
client_ip=request.client.host if request.client else "unknown"
)
raise
async def get_current_tenant_id_dep(request: Request) -> Optional[str]:
"""FastAPI dependency to get current tenant ID"""

View File

@@ -15,6 +15,7 @@ from .orders_client import OrdersServiceClient
from .production_client import ProductionServiceClient
from .recipes_client import RecipesServiceClient
from .suppliers_client import SuppliersServiceClient
from .tenant_client import TenantServiceClient
# Import config
from shared.config.base import BaseServiceSettings
@@ -221,6 +222,7 @@ __all__ = [
'ProductionServiceClient',
'RecipesServiceClient',
'SuppliersServiceClient',
'TenantServiceClient',
'ServiceClients',
'get_training_client',
'get_sales_client',

View File

@@ -274,6 +274,76 @@ class ProductionServiceClient(BaseServiceClient):
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
return None
# ================================================================
# WASTE AND SUSTAINABILITY ANALYTICS
# ================================================================
async def get_waste_analytics(
self,
tenant_id: str,
start_date: str,
end_date: str
) -> Optional[Dict[str, Any]]:
"""
Get production waste analytics for sustainability reporting
Args:
tenant_id: Tenant ID
start_date: Start date (ISO format)
end_date: End date (ISO format)
Returns:
Dictionary with waste analytics data:
- total_production_waste: Total waste in kg
- total_defects: Total defect waste in kg
- total_planned: Total planned production in kg
- total_actual: Total actual production in kg
- ai_assisted_batches: Number of AI-assisted batches
"""
try:
params = {
"start_date": start_date,
"end_date": end_date
}
result = await self.get("production/waste-analytics", tenant_id=tenant_id, params=params)
if result:
logger.info("Retrieved production waste analytics",
tenant_id=tenant_id,
start_date=start_date,
end_date=end_date)
return result
except Exception as e:
logger.error("Error getting production waste analytics",
error=str(e), tenant_id=tenant_id)
return None
async def get_baseline(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get baseline waste percentage for SDG compliance calculations
Args:
tenant_id: Tenant ID
Returns:
Dictionary with baseline data:
- waste_percentage: Baseline waste percentage
- period: Information about the baseline period
- data_available: Whether real data is available
- total_production_kg: Total production during baseline
- total_waste_kg: Total waste during baseline
"""
try:
result = await self.get("production/baseline", tenant_id=tenant_id)
if result:
logger.info("Retrieved production baseline data",
tenant_id=tenant_id,
data_available=result.get('data_available', False))
return result
except Exception as e:
logger.error("Error getting production baseline",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================

View File

@@ -251,10 +251,33 @@ class RecipesServiceClient(BaseServiceClient):
error=str(e), tenant_id=tenant_id)
return []
# ================================================================
# COUNT AND STATISTICS
# ================================================================
async def count_recipes(self, tenant_id: str) -> int:
"""
Get the count of recipes for a tenant
Used for subscription limit tracking
Returns:
int: Number of recipes for the tenant
"""
try:
result = await self.get("recipes/count", tenant_id=tenant_id)
count = result.get('count', 0) if result else 0
logger.info("Retrieved recipe count from recipes service",
count=count, tenant_id=tenant_id)
return count
except Exception as e:
logger.error("Error getting recipe count",
error=str(e), tenant_id=tenant_id)
return 0
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if recipes service is healthy"""
try:

View File

@@ -381,24 +381,47 @@ class SuppliersServiceClient(BaseServiceClient):
# ================================================================
# ALERTS AND NOTIFICATIONS
# ================================================================
async def acknowledge_alert(self, tenant_id: str, alert_id: str) -> Optional[Dict[str, Any]]:
"""Acknowledge a supplier-related alert"""
try:
result = await self.post(f"suppliers/alerts/{alert_id}/acknowledge", data={}, tenant_id=tenant_id)
if result:
logger.info("Acknowledged supplier alert",
logger.info("Acknowledged supplier alert",
alert_id=alert_id, tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error acknowledging supplier alert",
logger.error("Error acknowledging supplier alert",
error=str(e), alert_id=alert_id, tenant_id=tenant_id)
return None
# ================================================================
# COUNT AND STATISTICS
# ================================================================
async def count_suppliers(self, tenant_id: str) -> int:
"""
Get the count of suppliers for a tenant
Used for subscription limit tracking
Returns:
int: Number of suppliers for the tenant
"""
try:
result = await self.get("suppliers/count", tenant_id=tenant_id)
count = result.get('count', 0) if result else 0
logger.info("Retrieved supplier count from suppliers service",
count=count, tenant_id=tenant_id)
return count
except Exception as e:
logger.error("Error getting supplier count",
error=str(e), tenant_id=tenant_id)
return 0
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if suppliers service is healthy"""
try:

View File

@@ -0,0 +1,220 @@
# shared/clients/tenant_client.py
"""
Tenant Service Client for Inter-Service Communication
Provides access to tenant settings and configuration from other services
"""
import structlog
from typing import Dict, Any, Optional
from uuid import UUID
from shared.clients.base_service_client import BaseServiceClient
from shared.config.base import BaseServiceSettings
logger = structlog.get_logger()
class TenantServiceClient(BaseServiceClient):
"""Client for communicating with the Tenant Service"""
def __init__(self, config: BaseServiceSettings):
super().__init__("tenant", config)
def get_service_base_path(self) -> str:
return "/api/v1"
# ================================================================
# TENANT SETTINGS ENDPOINTS
# ================================================================
async def get_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get all settings for a tenant
Args:
tenant_id: Tenant ID (UUID as string)
Returns:
Dictionary with all settings categories
"""
try:
result = await self.get("settings", tenant_id=tenant_id)
if result:
logger.info("Retrieved all settings from tenant service",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting all settings",
error=str(e), tenant_id=tenant_id)
return None
async def get_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""
Get settings for a specific category
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name (procurement, inventory, production, supplier, pos, order)
Returns:
Dictionary with category settings
"""
try:
result = await self.get(f"settings/{category}", tenant_id=tenant_id)
if result:
logger.info("Retrieved category settings from tenant service",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error getting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def get_procurement_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get procurement settings for a tenant"""
result = await self.get_category_settings(tenant_id, "procurement")
return result.get('settings', {}) if result else {}
async def get_inventory_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get inventory settings for a tenant"""
result = await self.get_category_settings(tenant_id, "inventory")
return result.get('settings', {}) if result else {}
async def get_production_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get production settings for a tenant"""
result = await self.get_category_settings(tenant_id, "production")
return result.get('settings', {}) if result else {}
async def get_supplier_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get supplier settings for a tenant"""
result = await self.get_category_settings(tenant_id, "supplier")
return result.get('settings', {}) if result else {}
async def get_pos_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get POS settings for a tenant"""
result = await self.get_category_settings(tenant_id, "pos")
return result.get('settings', {}) if result else {}
async def get_order_settings(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""Get order settings for a tenant"""
result = await self.get_category_settings(tenant_id, "order")
return result.get('settings', {}) if result else {}
async def update_settings(self, tenant_id: str, settings_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Update settings for a tenant
Args:
tenant_id: Tenant ID (UUID as string)
settings_data: Settings data to update
Returns:
Updated settings dictionary
"""
try:
result = await self.put("settings", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated tenant settings",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error updating tenant settings",
error=str(e), tenant_id=tenant_id)
return None
async def update_category_settings(
self,
tenant_id: str,
category: str,
settings_data: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
"""
Update settings for a specific category
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name
settings_data: Settings data to update
Returns:
Updated settings dictionary
"""
try:
result = await self.put(f"settings/{category}", data=settings_data, tenant_id=tenant_id)
if result:
logger.info("Updated category settings",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error updating category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
async def reset_category_settings(self, tenant_id: str, category: str) -> Optional[Dict[str, Any]]:
"""
Reset category settings to default values
Args:
tenant_id: Tenant ID (UUID as string)
category: Category name
Returns:
Reset settings dictionary
"""
try:
result = await self.post(f"settings/{category}/reset", data={}, tenant_id=tenant_id)
if result:
logger.info("Reset category settings to defaults",
tenant_id=tenant_id,
category=category)
return result
except Exception as e:
logger.error("Error resetting category settings",
error=str(e), tenant_id=tenant_id, category=category)
return None
# ================================================================
# TENANT MANAGEMENT
# ================================================================
async def get_tenant(self, tenant_id: str) -> Optional[Dict[str, Any]]:
"""
Get tenant details
Args:
tenant_id: Tenant ID (UUID as string)
Returns:
Tenant data dictionary
"""
try:
# The tenant endpoint is not tenant-scoped, it's a direct path
result = await self._make_request("GET", f"tenants/{tenant_id}")
if result:
logger.info("Retrieved tenant details",
tenant_id=tenant_id)
return result
except Exception as e:
logger.error("Error getting tenant details",
error=str(e), tenant_id=tenant_id)
return None
# ================================================================
# UTILITY METHODS
# ================================================================
async def health_check(self) -> bool:
"""Check if tenant service is healthy"""
try:
result = await self.get("../health") # Health endpoint is not tenant-scoped
return result is not None
except Exception as e:
logger.error("Tenant service health check failed", error=str(e))
return False
# Factory function for dependency injection
def create_tenant_client(config: BaseServiceSettings) -> TenantServiceClient:
"""Create tenant service client instance"""
return TenantServiceClient(config)

View File

@@ -5,11 +5,61 @@ Provides common settings and patterns
"""
import os
from typing import List, Dict, Optional, Any
from typing import List, Dict, Optional, Any, Set
from pydantic_settings import BaseSettings
from pydantic import validator, Field
# ================================================================
# INTERNAL SERVICE REGISTRY
# ================================================================
# Central registry of all internal microservices that should have
# automatic access to tenant resources without user membership
# Service names should match the naming convention used in JWT tokens
INTERNAL_SERVICES: Set[str] = {
# Core services
"auth-service",
"tenant-service",
# Business logic services
"inventory-service",
"production-service",
"recipes-service",
"suppliers-service",
"pos-service",
"orders-service",
"sales-service",
# ML and analytics services
"training-service",
"forecasting-service",
# Support services
"notification-service",
"alert-service",
"alert-processor-service",
"demo-session-service",
"external-service",
# Legacy/alternative naming (for backwards compatibility)
"data-service", # May be used by older components
}
def is_internal_service(service_identifier: str) -> bool:
"""
Check if a service identifier represents an internal service.
Args:
service_identifier: Service name (e.g., 'production-service')
Returns:
bool: True if the identifier is a recognized internal service
"""
return service_identifier in INTERNAL_SERVICES
class BaseServiceSettings(BaseSettings):
"""
Base configuration class for all microservices
@@ -333,29 +383,16 @@ class BaseServiceSettings(BaseSettings):
# PROCUREMENT AUTOMATION
# ================================================================
# Auto-PO Creation
# NOTE: Tenant-specific procurement settings (auto-approval thresholds, supplier scores,
# approval rules, lead times, forecast days, etc.) have been moved to TenantSettings.
# Services should fetch these using TenantSettingsClient from shared/utils/tenant_settings_client.py
# System-level procurement settings (apply to all tenants):
AUTO_CREATE_POS_FROM_PLAN: bool = os.getenv("AUTO_CREATE_POS_FROM_PLAN", "true").lower() == "true"
AUTO_APPROVE_ENABLED: bool = os.getenv("AUTO_APPROVE_ENABLED", "true").lower() == "true"
AUTO_APPROVE_THRESHOLD_EUR: float = float(os.getenv("AUTO_APPROVE_THRESHOLD_EUR", "500.0"))
AUTO_APPROVE_TRUSTED_SUPPLIERS: bool = os.getenv("AUTO_APPROVE_TRUSTED_SUPPLIERS", "true").lower() == "true"
AUTO_APPROVE_MIN_SUPPLIER_SCORE: float = float(os.getenv("AUTO_APPROVE_MIN_SUPPLIER_SCORE", "0.80"))
# Approval Rules
REQUIRE_APPROVAL_ABOVE_EUR: float = float(os.getenv("REQUIRE_APPROVAL_ABOVE_EUR", "500.0"))
REQUIRE_APPROVAL_NEW_SUPPLIERS: bool = os.getenv("REQUIRE_APPROVAL_NEW_SUPPLIERS", "true").lower() == "true"
REQUIRE_APPROVAL_CRITICAL_ITEMS: bool = os.getenv("REQUIRE_APPROVAL_CRITICAL_ITEMS", "true").lower() == "true"
# Notifications
PO_APPROVAL_REMINDER_HOURS: int = int(os.getenv("PO_APPROVAL_REMINDER_HOURS", "24"))
PO_CRITICAL_ESCALATION_HOURS: int = int(os.getenv("PO_CRITICAL_ESCALATION_HOURS", "12"))
PROCUREMENT_TEST_MODE: bool = os.getenv("PROCUREMENT_TEST_MODE", "false").lower() == "true"
SEND_AUTO_APPROVAL_SUMMARY: bool = os.getenv("SEND_AUTO_APPROVAL_SUMMARY", "true").lower() == "true"
AUTO_APPROVAL_SUMMARY_TIME_HOUR: int = int(os.getenv("AUTO_APPROVAL_SUMMARY_TIME_HOUR", "18"))
# Procurement Planning
PROCUREMENT_PLANNING_ENABLED: bool = os.getenv("PROCUREMENT_PLANNING_ENABLED", "true").lower() == "true"
PROCUREMENT_PLAN_HORIZON_DAYS: int = int(os.getenv("PROCUREMENT_PLAN_HORIZON_DAYS", "14"))
PROCUREMENT_TEST_MODE: bool = os.getenv("PROCUREMENT_TEST_MODE", "false").lower() == "true"
# ================================================================
# DEVELOPMENT & TESTING
# ================================================================

View File

@@ -1,665 +0,0 @@
"""
Alert Generation Utilities for Demo Sessions
Provides functions to create realistic alerts during data cloning
All alert messages are in Spanish for demo purposes.
"""
from datetime import datetime, timezone
from typing import List, Optional, Dict, Any
import uuid
from decimal import Decimal
import structlog
logger = structlog.get_logger()
def format_quantity(value: float, decimals: int = 2) -> str:
"""
Format quantity with proper rounding to avoid floating point errors
Args:
value: The numeric value to format
decimals: Number of decimal places (default 2)
Returns:
Formatted string with proper decimal representation
"""
return f"{round(value, decimals):.{decimals}f}"
def format_currency(value: float) -> str:
"""
Format currency value with proper rounding
Args:
value: The currency value to format
Returns:
Formatted currency string
"""
return f"{round(value, 2):.2f}"
class AlertSeverity:
"""Alert severity levels"""
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
URGENT = "urgent"
class AlertStatus:
"""Alert status values"""
ACTIVE = "active"
RESOLVED = "resolved"
ACKNOWLEDGED = "acknowledged"
IGNORED = "ignored"
async def create_demo_alert(
db,
tenant_id: uuid.UUID,
alert_type: str,
severity: str,
title: str,
message: str,
service: str,
rabbitmq_client,
metadata: Dict[str, Any] = None,
created_at: Optional[datetime] = None
):
"""
Create and persist a demo alert, then publish to RabbitMQ
Args:
db: Database session
tenant_id: Tenant UUID
alert_type: Type of alert (e.g., 'expiration_imminent')
severity: Alert severity level (low, medium, high, urgent)
title: Alert title (in Spanish)
message: Alert message (in Spanish)
service: Service name that generated the alert
rabbitmq_client: RabbitMQ client for publishing alerts
metadata: Additional alert-specific data
created_at: When the alert was created (defaults to now)
Returns:
Created Alert instance (dict for cross-service compatibility)
"""
from shared.config.rabbitmq_config import get_routing_key
alert_id = uuid.uuid4()
alert_created_at = created_at or datetime.now(timezone.utc)
# Import here to avoid circular dependencies
try:
from app.models.alerts import Alert
alert = Alert(
id=alert_id,
tenant_id=tenant_id,
item_type="alert",
alert_type=alert_type,
severity=severity,
status=AlertStatus.ACTIVE,
service=service,
title=title,
message=message,
alert_metadata=metadata or {},
created_at=alert_created_at
)
db.add(alert)
await db.flush()
except ImportError:
# If Alert model not available, skip DB insert
logger.warning("Alert model not available, skipping DB insert", service=service)
# Publish alert to RabbitMQ for processing by Alert Processor
if rabbitmq_client:
try:
alert_message = {
'id': str(alert_id),
'tenant_id': str(tenant_id),
'item_type': 'alert',
'type': alert_type,
'severity': severity,
'service': service,
'title': title,
'message': message,
'metadata': metadata or {},
'timestamp': alert_created_at.isoformat()
}
routing_key = get_routing_key('alert', severity, service)
published = await rabbitmq_client.publish_event(
exchange_name='alerts.exchange',
routing_key=routing_key,
event_data=alert_message
)
if published:
logger.info(
"Demo alert published to RabbitMQ",
alert_id=str(alert_id),
alert_type=alert_type,
severity=severity,
service=service,
routing_key=routing_key
)
else:
logger.warning(
"Failed to publish demo alert to RabbitMQ",
alert_id=str(alert_id),
alert_type=alert_type
)
except Exception as e:
logger.error(
"Error publishing demo alert to RabbitMQ",
alert_id=str(alert_id),
error=str(e),
exc_info=True
)
else:
logger.warning("No RabbitMQ client provided, alert will not be streamed", alert_id=str(alert_id))
# Return alert dict for compatibility
return {
"id": str(alert_id),
"tenant_id": str(tenant_id),
"item_type": "alert",
"alert_type": alert_type,
"severity": severity,
"status": AlertStatus.ACTIVE,
"service": service,
"title": title,
"message": message,
"alert_metadata": metadata or {},
"created_at": alert_created_at
}
async def generate_inventory_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime,
rabbitmq_client=None
) -> int:
"""
Generate inventory-related alerts for demo session
Generates alerts for:
- Expired stock
- Expiring soon stock (<= 3 days)
- Low stock levels
- Overstock situations
Args:
db: Database session
tenant_id: Virtual tenant UUID
session_created_at: When the demo session was created
rabbitmq_client: RabbitMQ client for publishing alerts
Returns:
Number of alerts created
"""
try:
from app.models.inventory import Stock, Ingredient
from sqlalchemy import select
from shared.utils.demo_dates import get_days_until_expiration
except ImportError:
# Models not available in this context
return 0
alerts_created = 0
# Query stocks with joins to ingredients
result = await db.execute(
select(Stock, Ingredient).join(
Ingredient, Stock.ingredient_id == Ingredient.id
).where(
Stock.tenant_id == tenant_id
)
)
stock_ingredient_pairs = result.all()
for stock, ingredient in stock_ingredient_pairs:
# Expiration alerts
if stock.expiration_date:
days_until_expiry = get_days_until_expiration(
stock.expiration_date,
session_created_at
)
if days_until_expiry < 0:
# Expired stock
qty_formatted = format_quantity(float(stock.current_quantity))
loss_formatted = format_currency(float(stock.total_cost)) if stock.total_cost else "0.00"
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="expired_stock",
severity=AlertSeverity.URGENT,
title=f"Stock Caducado: {ingredient.name}",
message=f"El lote {stock.batch_number} caducó hace {abs(days_until_expiry)} días. "
f"Cantidad: {qty_formatted} {ingredient.unit_of_measure.value}. "
f"Acción requerida: Retirar inmediatamente del inventario y registrar como pérdida.",
service="inventory",
rabbitmq_client=rabbitmq_client,
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"batch_number": stock.batch_number,
"expiration_date": stock.expiration_date.isoformat(),
"days_expired": abs(days_until_expiry),
"quantity": qty_formatted,
"unit": ingredient.unit_of_measure.value,
"estimated_loss": loss_formatted
}
)
alerts_created += 1
elif days_until_expiry <= 3:
# Expiring soon
qty_formatted = format_quantity(float(stock.current_quantity))
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="expiration_imminent",
severity=AlertSeverity.HIGH,
title=f"Próximo a Caducar: {ingredient.name}",
message=f"El lote {stock.batch_number} caduca en {days_until_expiry} día{'s' if days_until_expiry > 1 else ''}. "
f"Cantidad: {qty_formatted} {ingredient.unit_of_measure.value}. "
f"Recomendación: Planificar uso prioritario en producción inmediata.",
service="inventory",
rabbitmq_client=rabbitmq_client,
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"batch_number": stock.batch_number,
"expiration_date": stock.expiration_date.isoformat(),
"days_until_expiry": days_until_expiry,
"quantity": qty_formatted,
"unit": ingredient.unit_of_measure.value
}
)
alerts_created += 1
# Low stock alert
if stock.current_quantity < ingredient.low_stock_threshold:
shortage = ingredient.low_stock_threshold - stock.current_quantity
current_qty = format_quantity(float(stock.current_quantity))
threshold_qty = format_quantity(float(ingredient.low_stock_threshold))
shortage_qty = format_quantity(float(shortage))
reorder_qty = format_quantity(float(ingredient.reorder_quantity))
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="low_stock",
severity=AlertSeverity.MEDIUM,
title=f"Stock Bajo: {ingredient.name}",
message=f"Stock actual: {current_qty} {ingredient.unit_of_measure.value}. "
f"Umbral mínimo: {threshold_qty}. "
f"Faltante: {shortage_qty} {ingredient.unit_of_measure.value}. "
f"Se recomienda realizar pedido de {reorder_qty} {ingredient.unit_of_measure.value}.",
service="inventory",
rabbitmq_client=rabbitmq_client,
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"current_quantity": current_qty,
"threshold": threshold_qty,
"reorder_point": format_quantity(float(ingredient.reorder_point)),
"reorder_quantity": reorder_qty,
"shortage": shortage_qty
}
)
alerts_created += 1
# Overstock alert (if max_stock_level is defined)
if ingredient.max_stock_level and stock.current_quantity > ingredient.max_stock_level:
excess = stock.current_quantity - ingredient.max_stock_level
current_qty = format_quantity(float(stock.current_quantity))
max_level_qty = format_quantity(float(ingredient.max_stock_level))
excess_qty = format_quantity(float(excess))
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="overstock",
severity=AlertSeverity.LOW,
title=f"Exceso de Stock: {ingredient.name}",
message=f"Stock actual: {current_qty} {ingredient.unit_of_measure.value}. "
f"Nivel máximo recomendado: {max_level_qty}. "
f"Exceso: {excess_qty} {ingredient.unit_of_measure.value}. "
f"Considerar reducir cantidad en próximos pedidos o buscar uso alternativo.",
service="inventory",
rabbitmq_client=rabbitmq_client,
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"current_quantity": current_qty,
"max_level": max_level_qty,
"excess": excess_qty
}
)
alerts_created += 1
await db.flush()
return alerts_created
async def generate_equipment_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime,
rabbitmq_client=None
) -> int:
"""
Generate equipment-related alerts for demo session
Generates alerts for:
- Equipment needing maintenance
- Equipment in maintenance/down status
- Equipment with low efficiency
Args:
db: Database session
tenant_id: Virtual tenant UUID
session_created_at: When the demo session was created
rabbitmq_client: RabbitMQ client for publishing alerts
Returns:
Number of alerts created
"""
try:
from app.models.production import Equipment, EquipmentStatus
from sqlalchemy import select
except ImportError:
return 0
alerts_created = 0
# Query equipment
result = await db.execute(
select(Equipment).where(Equipment.tenant_id == tenant_id)
)
equipment_list = result.scalars().all()
for equipment in equipment_list:
# Maintenance required alert
if equipment.next_maintenance_date and equipment.next_maintenance_date <= session_created_at:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="equipment_maintenance_due",
severity=AlertSeverity.MEDIUM,
title=f"Mantenimiento Vencido: {equipment.name}",
message=f"El equipo {equipment.name} ({equipment.type.value}) tiene mantenimiento vencido. "
f"Último mantenimiento: {equipment.last_maintenance_date.strftime('%d/%m/%Y') if equipment.last_maintenance_date else 'No registrado'}. "
f"Programar mantenimiento preventivo lo antes posible.",
service="production",
rabbitmq_client=rabbitmq_client,
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"equipment_type": equipment.type.value,
"last_maintenance": equipment.last_maintenance_date.isoformat() if equipment.last_maintenance_date else None,
"next_maintenance": equipment.next_maintenance_date.isoformat()
}
)
alerts_created += 1
# Equipment status alerts
if equipment.status == EquipmentStatus.MAINTENANCE:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="equipment_in_maintenance",
severity=AlertSeverity.MEDIUM,
title=f"Equipo en Mantenimiento: {equipment.name}",
message=f"El equipo {equipment.name} está actualmente en mantenimiento y no disponible para producción. "
f"Ajustar planificación de producción según capacidad reducida.",
service="production",
rabbitmq_client=rabbitmq_client,
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"equipment_type": equipment.type.value
}
)
alerts_created += 1
elif equipment.status == EquipmentStatus.DOWN:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="equipment_down",
severity=AlertSeverity.URGENT,
title=f"Equipo Fuera de Servicio: {equipment.name}",
message=f"URGENTE: El equipo {equipment.name} está fuera de servicio. "
f"Contactar con servicio técnico inmediatamente. "
f"Revisar planificación de producción y reasignar lotes a otros equipos.",
service="production",
rabbitmq_client=rabbitmq_client,
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"equipment_type": equipment.type.value
}
)
alerts_created += 1
elif equipment.status == EquipmentStatus.WARNING:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="equipment_warning",
severity=AlertSeverity.MEDIUM,
title=f"Advertencia de Equipo: {equipment.name}",
message=f"El equipo {equipment.name} presenta signos de advertencia. "
f"Eficiencia actual: {equipment.efficiency_percentage:.1f}%. "
f"Monitorear de cerca y considerar inspección preventiva.",
service="production",
rabbitmq_client=rabbitmq_client,
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"equipment_type": equipment.type.value,
"efficiency": float(equipment.efficiency_percentage) if equipment.efficiency_percentage else None
}
)
alerts_created += 1
# Low efficiency alert
if equipment.efficiency_percentage and equipment.efficiency_percentage < 80.0:
efficiency_formatted = format_quantity(float(equipment.efficiency_percentage), 1)
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="equipment_low_efficiency",
severity=AlertSeverity.LOW,
title=f"Eficiencia Baja: {equipment.name}",
message=f"El equipo {equipment.name} está operando con eficiencia reducida ({efficiency_formatted}%). "
f"Eficiencia objetivo: ≥ 85%. "
f"Revisar causas: limpieza, calibración, desgaste de componentes.",
service="production",
rabbitmq_client=rabbitmq_client,
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"efficiency": efficiency_formatted
}
)
alerts_created += 1
await db.flush()
return alerts_created
async def generate_order_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime,
rabbitmq_client=None
) -> int:
"""
Generate order-related alerts for demo session
Generates alerts for:
- Orders with approaching delivery dates
- Delayed orders
- High-priority pending orders
Args:
db: Database session
tenant_id: Virtual tenant UUID
session_created_at: When the demo session was created
rabbitmq_client: RabbitMQ client for publishing alerts
Returns:
Number of alerts created
"""
try:
from app.models.order import CustomerOrder
from sqlalchemy import select
from shared.utils.demo_dates import get_days_until_expiration
except ImportError:
return 0
alerts_created = 0
# Query orders
result = await db.execute(
select(CustomerOrder).where(
CustomerOrder.tenant_id == tenant_id,
CustomerOrder.status.in_(['pending', 'confirmed', 'in_production'])
)
)
orders = result.scalars().all()
for order in orders:
if order.requested_delivery_date:
days_until_delivery = (order.requested_delivery_date - session_created_at).days
# Approaching delivery date
if 0 <= days_until_delivery <= 2 and order.status in ['pending', 'confirmed']:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="order_delivery_soon",
severity=AlertSeverity.HIGH,
title=f"Entrega Próxima: Pedido {order.order_number}",
message=f"El pedido {order.order_number} debe entregarse en {days_until_delivery} día{'s' if days_until_delivery > 1 else ''}. "
f"Cliente: {order.customer.name if hasattr(order, 'customer') else 'N/A'}. "
f"Estado actual: {order.status}. "
f"Verificar que esté en producción.",
service="orders",
rabbitmq_client=rabbitmq_client,
metadata={
"order_id": str(order.id),
"order_number": order.order_number,
"status": order.status,
"delivery_date": order.requested_delivery_date.isoformat(),
"days_until_delivery": days_until_delivery
}
)
alerts_created += 1
# Delayed order
if days_until_delivery < 0:
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="order_delayed",
severity=AlertSeverity.URGENT,
title=f"Pedido Retrasado: {order.order_number}",
message=f"URGENTE: El pedido {order.order_number} está retrasado {abs(days_until_delivery)} días. "
f"Fecha de entrega prevista: {order.requested_delivery_date.strftime('%d/%m/%Y')}. "
f"Contactar al cliente y renegociar fecha de entrega.",
service="orders",
rabbitmq_client=rabbitmq_client,
metadata={
"order_id": str(order.id),
"order_number": order.order_number,
"status": order.status,
"delivery_date": order.requested_delivery_date.isoformat(),
"days_delayed": abs(days_until_delivery)
}
)
alerts_created += 1
# High priority pending orders
if order.priority == 'high' and order.status == 'pending':
amount_formatted = format_currency(float(order.total_amount))
await create_demo_alert(
db=db,
tenant_id=tenant_id,
alert_type="high_priority_order_pending",
severity=AlertSeverity.MEDIUM,
title=f"Pedido Prioritario Pendiente: {order.order_number}",
message=f"El pedido de alta prioridad {order.order_number} está pendiente de confirmación. "
f"Monto: €{amount_formatted}. "
f"Revisar disponibilidad de ingredientes y confirmar producción.",
service="orders",
rabbitmq_client=rabbitmq_client,
metadata={
"order_id": str(order.id),
"order_number": order.order_number,
"priority": order.priority,
"total_amount": amount_formatted
}
)
alerts_created += 1
await db.flush()
return alerts_created
# Utility function for cross-service alert creation
async def create_alert_via_api(
alert_processor_url: str,
tenant_id: uuid.UUID,
alert_data: Dict[str, Any],
internal_api_key: str
) -> bool:
"""
Create an alert via the alert processor service API
This function is useful when creating alerts from services that don't
have direct database access to the alert processor database.
Args:
alert_processor_url: Base URL of alert processor service
tenant_id: Tenant UUID
alert_data: Alert data dictionary
internal_api_key: Internal API key for service-to-service auth
Returns:
True if alert created successfully, False otherwise
"""
import httpx
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"{alert_processor_url}/internal/alerts",
json={
"tenant_id": str(tenant_id),
**alert_data
},
headers={
"X-Internal-API-Key": internal_api_key
},
timeout=5.0
)
return response.status_code == 201
except Exception:
return False

View File

@@ -0,0 +1,360 @@
# shared/utils/tenant_settings_client.py
"""
Tenant Settings Client
Shared utility for services to fetch tenant-specific settings from Tenant Service
Includes Redis caching for performance
"""
import httpx
import json
from typing import Dict, Any, Optional
from uuid import UUID
import redis.asyncio as aioredis
from datetime import timedelta
import logging
logger = logging.getLogger(__name__)
class TenantSettingsClient:
"""
Client for fetching tenant settings from Tenant Service
Features:
- HTTP client to fetch settings from Tenant Service API
- Redis caching with configurable TTL (default 5 minutes)
- Automatic cache invalidation support
- Fallback to defaults if Tenant Service is unavailable
"""
def __init__(
self,
tenant_service_url: str,
redis_client: Optional[aioredis.Redis] = None,
cache_ttl: int = 300, # 5 minutes default
http_timeout: int = 10
):
"""
Initialize TenantSettingsClient
Args:
tenant_service_url: Base URL of Tenant Service (e.g., "http://tenant-service:8000")
redis_client: Optional Redis client for caching
cache_ttl: Cache TTL in seconds (default 300 = 5 minutes)
http_timeout: HTTP request timeout in seconds
"""
self.tenant_service_url = tenant_service_url.rstrip('/')
self.redis = redis_client
self.cache_ttl = cache_ttl
self.http_timeout = http_timeout
# HTTP client with connection pooling
self.http_client = httpx.AsyncClient(
timeout=http_timeout,
limits=httpx.Limits(max_keepalive_connections=20, max_connections=100)
)
async def get_procurement_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get procurement settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with procurement settings
"""
return await self._get_category_settings(tenant_id, "procurement")
async def get_inventory_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get inventory settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with inventory settings
"""
return await self._get_category_settings(tenant_id, "inventory")
async def get_production_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get production settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with production settings
"""
return await self._get_category_settings(tenant_id, "production")
async def get_supplier_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get supplier settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with supplier settings
"""
return await self._get_category_settings(tenant_id, "supplier")
async def get_pos_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get POS settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with POS settings
"""
return await self._get_category_settings(tenant_id, "pos")
async def get_order_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get order settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with order settings
"""
return await self._get_category_settings(tenant_id, "order")
async def get_all_settings(self, tenant_id: UUID) -> Dict[str, Any]:
"""
Get all settings for a tenant
Args:
tenant_id: UUID of the tenant
Returns:
Dictionary with all setting categories
"""
cache_key = f"tenant_settings:{tenant_id}:all"
# Try cache first
if self.redis:
cached = await self._get_from_cache(cache_key)
if cached:
return cached
# Fetch from Tenant Service
try:
url = f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/settings"
response = await self.http_client.get(url)
response.raise_for_status()
settings = response.json()
# Cache the result
if self.redis:
await self._set_in_cache(cache_key, settings)
return settings
except Exception as e:
logger.error(f"Failed to fetch all settings for tenant {tenant_id}: {e}")
return self._get_default_settings()
async def invalidate_cache(self, tenant_id: UUID, category: Optional[str] = None):
"""
Invalidate cache for a tenant's settings
Args:
tenant_id: UUID of the tenant
category: Optional category to invalidate. If None, invalidates all categories.
"""
if not self.redis:
return
if category:
cache_key = f"tenant_settings:{tenant_id}:{category}"
await self.redis.delete(cache_key)
logger.info(f"Invalidated cache for tenant {tenant_id}, category {category}")
else:
# Invalidate all categories
pattern = f"tenant_settings:{tenant_id}:*"
keys = await self.redis.keys(pattern)
if keys:
await self.redis.delete(*keys)
logger.info(f"Invalidated all cached settings for tenant {tenant_id}")
async def _get_category_settings(self, tenant_id: UUID, category: str) -> Dict[str, Any]:
"""
Internal method to fetch settings for a specific category
Args:
tenant_id: UUID of the tenant
category: Category name
Returns:
Dictionary with category settings
"""
cache_key = f"tenant_settings:{tenant_id}:{category}"
# Try cache first
if self.redis:
cached = await self._get_from_cache(cache_key)
if cached:
return cached
# Fetch from Tenant Service
try:
url = f"{self.tenant_service_url}/api/v1/tenants/{tenant_id}/settings/{category}"
response = await self.http_client.get(url)
response.raise_for_status()
data = response.json()
settings = data.get("settings", {})
# Cache the result
if self.redis:
await self._set_in_cache(cache_key, settings)
return settings
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
logger.warning(f"Settings not found for tenant {tenant_id}, using defaults")
else:
logger.error(f"HTTP error fetching {category} settings for tenant {tenant_id}: {e}")
return self._get_default_category_settings(category)
except Exception as e:
logger.error(f"Failed to fetch {category} settings for tenant {tenant_id}: {e}")
return self._get_default_category_settings(category)
async def _get_from_cache(self, key: str) -> Optional[Dict[str, Any]]:
"""Get value from Redis cache"""
try:
cached = await self.redis.get(key)
if cached:
return json.loads(cached)
except Exception as e:
logger.warning(f"Redis get error for key {key}: {e}")
return None
async def _set_in_cache(self, key: str, value: Dict[str, Any]):
"""Set value in Redis cache with TTL"""
try:
await self.redis.setex(
key,
timedelta(seconds=self.cache_ttl),
json.dumps(value)
)
except Exception as e:
logger.warning(f"Redis set error for key {key}: {e}")
def _get_default_category_settings(self, category: str) -> Dict[str, Any]:
"""Get default settings for a category as fallback"""
defaults = self._get_default_settings()
return defaults.get(f"{category}_settings", {})
def _get_default_settings(self) -> Dict[str, Any]:
"""Get default settings for all categories as fallback"""
return {
"procurement_settings": {
"auto_approve_enabled": True,
"auto_approve_threshold_eur": 500.0,
"auto_approve_min_supplier_score": 0.80,
"require_approval_new_suppliers": True,
"require_approval_critical_items": True,
"procurement_lead_time_days": 3,
"demand_forecast_days": 14,
"safety_stock_percentage": 20.0,
"po_approval_reminder_hours": 24,
"po_critical_escalation_hours": 12
},
"inventory_settings": {
"low_stock_threshold": 10,
"reorder_point": 20,
"reorder_quantity": 50,
"expiring_soon_days": 7,
"expiration_warning_days": 3,
"quality_score_threshold": 8.0,
"temperature_monitoring_enabled": True,
"refrigeration_temp_min": 1.0,
"refrigeration_temp_max": 4.0,
"freezer_temp_min": -20.0,
"freezer_temp_max": -15.0,
"room_temp_min": 18.0,
"room_temp_max": 25.0,
"temp_deviation_alert_minutes": 15,
"critical_temp_deviation_minutes": 5
},
"production_settings": {
"planning_horizon_days": 7,
"minimum_batch_size": 1.0,
"maximum_batch_size": 100.0,
"production_buffer_percentage": 10.0,
"working_hours_per_day": 12,
"max_overtime_hours": 4,
"capacity_utilization_target": 0.85,
"capacity_warning_threshold": 0.95,
"quality_check_enabled": True,
"minimum_yield_percentage": 85.0,
"quality_score_threshold": 8.0,
"schedule_optimization_enabled": True,
"prep_time_buffer_minutes": 30,
"cleanup_time_buffer_minutes": 15,
"labor_cost_per_hour_eur": 15.0,
"overhead_cost_percentage": 20.0
},
"supplier_settings": {
"default_payment_terms_days": 30,
"default_delivery_days": 3,
"excellent_delivery_rate": 95.0,
"good_delivery_rate": 90.0,
"excellent_quality_rate": 98.0,
"good_quality_rate": 95.0,
"critical_delivery_delay_hours": 24,
"critical_quality_rejection_rate": 10.0,
"high_cost_variance_percentage": 15.0
},
"pos_settings": {
"sync_interval_minutes": 5,
"auto_sync_products": True,
"auto_sync_transactions": True
},
"order_settings": {
"max_discount_percentage": 50.0,
"default_delivery_window_hours": 48,
"dynamic_pricing_enabled": False,
"discount_enabled": True,
"delivery_tracking_enabled": True
}
}
async def close(self):
"""Close HTTP client connections"""
await self.http_client.aclose()
# Factory function for easy instantiation
def create_tenant_settings_client(
tenant_service_url: str,
redis_client: Optional[aioredis.Redis] = None,
cache_ttl: int = 300
) -> TenantSettingsClient:
"""
Factory function to create a TenantSettingsClient
Args:
tenant_service_url: Base URL of Tenant Service
redis_client: Optional Redis client for caching
cache_ttl: Cache TTL in seconds
Returns:
TenantSettingsClient instance
"""
return TenantSettingsClient(
tenant_service_url=tenant_service_url,
redis_client=redis_client,
cache_ttl=cache_ttl
)