Improve the frontend and repository layer
This commit is contained in:
@@ -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
|
||||
360
shared/utils/tenant_settings_client.py
Normal file
360
shared/utils/tenant_settings_client.py
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user