Improve demo seed

This commit is contained in:
Urtzi Alfaro
2025-10-17 07:31:14 +02:00
parent b6cb800758
commit d4060962e4
56 changed files with 8235 additions and 339 deletions

View File

@@ -0,0 +1,542 @@
"""
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
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,
metadata: Dict[str, Any] = None,
created_at: Optional[datetime] = None
):
"""
Create and persist a demo alert
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
metadata: Additional alert-specific data
created_at: When the alert was created (defaults to now)
Returns:
Created Alert instance (dict for cross-service compatibility)
"""
# Import here to avoid circular dependencies
try:
from app.models.alerts import Alert
alert = Alert(
id=uuid.uuid4(),
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=created_at or datetime.now(timezone.utc)
)
db.add(alert)
return alert
except ImportError:
# If Alert model not available, return dict representation
# This allows the function to work across services
alert_dict = {
"id": uuid.uuid4(),
"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": created_at or datetime.now(timezone.utc)
}
return alert_dict
async def generate_inventory_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime
) -> 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
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
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: {stock.current_quantity:.2f} {ingredient.unit_of_measure.value}. "
f"Acción requerida: Retirar inmediatamente del inventario y registrar como pérdida.",
service="inventory",
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": float(stock.current_quantity),
"unit": ingredient.unit_of_measure.value,
"estimated_loss": float(stock.total_cost) if stock.total_cost else 0.0
}
)
alerts_created += 1
elif days_until_expiry <= 3:
# Expiring soon
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: {stock.current_quantity:.2f} {ingredient.unit_of_measure.value}. "
f"Recomendación: Planificar uso prioritario en producción inmediata.",
service="inventory",
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": float(stock.current_quantity),
"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
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: {stock.current_quantity:.2f} {ingredient.unit_of_measure.value}. "
f"Umbral mínimo: {ingredient.low_stock_threshold:.2f}. "
f"Faltante: {shortage:.2f} {ingredient.unit_of_measure.value}. "
f"Se recomienda realizar pedido de {ingredient.reorder_quantity:.2f} {ingredient.unit_of_measure.value}.",
service="inventory",
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"current_quantity": float(stock.current_quantity),
"threshold": float(ingredient.low_stock_threshold),
"reorder_point": float(ingredient.reorder_point),
"reorder_quantity": float(ingredient.reorder_quantity),
"shortage": float(shortage)
}
)
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
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: {stock.current_quantity:.2f} {ingredient.unit_of_measure.value}. "
f"Nivel máximo recomendado: {ingredient.max_stock_level:.2f}. "
f"Exceso: {excess:.2f} {ingredient.unit_of_measure.value}. "
f"Considerar reducir cantidad en próximos pedidos o buscar uso alternativo.",
service="inventory",
metadata={
"stock_id": str(stock.id),
"ingredient_id": str(ingredient.id),
"current_quantity": float(stock.current_quantity),
"max_level": float(ingredient.max_stock_level),
"excess": float(excess)
}
)
alerts_created += 1
await db.flush()
return alerts_created
async def generate_equipment_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime
) -> 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
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",
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",
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",
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",
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:
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 ({equipment.efficiency_percentage:.1f}%). "
f"Eficiencia objetivo: e 85%. "
f"Revisar causas: limpieza, calibración, desgaste de componentes.",
service="production",
metadata={
"equipment_id": str(equipment.id),
"equipment_name": equipment.name,
"efficiency": float(equipment.efficiency_percentage)
}
)
alerts_created += 1
await db.flush()
return alerts_created
async def generate_order_alerts(
db,
tenant_id: uuid.UUID,
session_created_at: datetime
) -> 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
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",
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",
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':
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: ¬{float(order.total_amount):.2f}. "
f"Revisar disponibilidad de ingredientes y confirmar producción.",
service="orders",
metadata={
"order_id": str(order.id),
"order_number": order.order_number,
"priority": order.priority,
"total_amount": float(order.total_amount)
}
)
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

210
shared/utils/demo_dates.py Normal file
View File

@@ -0,0 +1,210 @@
"""
Demo Date Offset Utilities
Provides functions for adjusting dates during demo session cloning
to ensure all temporal data is relative to the demo session creation time
"""
from datetime import datetime, timezone, timedelta
from typing import Optional
# Base reference date for all demo seed data
# All seed scripts should use this as the "logical seed date"
BASE_REFERENCE_DATE = datetime(2025, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
def adjust_date_for_demo(
original_date: Optional[datetime],
session_created_at: datetime,
base_reference_date: datetime = BASE_REFERENCE_DATE
) -> Optional[datetime]:
"""
Adjust a date from seed data to be relative to demo session creation time
This ensures that demo data appears fresh and relevant regardless of when
the demo session is created. For example, expiration dates that were "15 days
from seed date" will become "15 days from session creation date".
Args:
original_date: The original date from the seed data (or None)
session_created_at: When the demo session was created
base_reference_date: The logical date when seed data was created (default: 2025-01-15)
Returns:
Adjusted date relative to session creation, or None if original_date was None
Example:
# Seed data created on 2025-01-15
# Stock expiration: 2025-01-30 (15 days from seed date)
# Demo session created: 2025-10-16
# Result: 2025-10-31 (15 days from session date)
>>> original = datetime(2025, 1, 30, 12, 0, tzinfo=timezone.utc)
>>> session = datetime(2025, 10, 16, 10, 0, tzinfo=timezone.utc)
>>> adjusted = adjust_date_for_demo(original, session)
>>> print(adjusted)
2025-10-31 10:00:00+00:00
"""
if original_date is None:
return None
# Ensure timezone-aware datetimes
if original_date.tzinfo is None:
original_date = original_date.replace(tzinfo=timezone.utc)
if session_created_at.tzinfo is None:
session_created_at = session_created_at.replace(tzinfo=timezone.utc)
if base_reference_date.tzinfo is None:
base_reference_date = base_reference_date.replace(tzinfo=timezone.utc)
# Calculate offset from base reference
offset = original_date - base_reference_date
# Apply offset to session creation date
return session_created_at + offset
def adjust_date_relative_to_now(
days_offset: int,
hours_offset: int = 0,
reference_time: Optional[datetime] = None
) -> datetime:
"""
Create a date relative to now (or a reference time) with specified offset
Useful for creating dates during cloning without needing to store seed dates.
Args:
days_offset: Number of days to add (negative for past dates)
hours_offset: Number of hours to add (negative for past times)
reference_time: Reference datetime (defaults to now)
Returns:
Calculated datetime
Example:
>>> # Create a date 7 days in the future
>>> future = adjust_date_relative_to_now(days_offset=7)
>>> # Create a date 3 days in the past
>>> past = adjust_date_relative_to_now(days_offset=-3)
"""
if reference_time is None:
reference_time = datetime.now(timezone.utc)
elif reference_time.tzinfo is None:
reference_time = reference_time.replace(tzinfo=timezone.utc)
return reference_time + timedelta(days=days_offset, hours=hours_offset)
def calculate_expiration_date(
received_date: datetime,
shelf_life_days: int
) -> datetime:
"""
Calculate expiration date based on received date and shelf life
Args:
received_date: When the product was received
shelf_life_days: Number of days until expiration
Returns:
Calculated expiration datetime
"""
if received_date.tzinfo is None:
received_date = received_date.replace(tzinfo=timezone.utc)
return received_date + timedelta(days=shelf_life_days)
def get_days_until_expiration(
expiration_date: datetime,
reference_date: Optional[datetime] = None
) -> int:
"""
Calculate number of days until expiration
Args:
expiration_date: The expiration datetime
reference_date: Reference datetime (defaults to now)
Returns:
Number of days until expiration (negative if already expired)
"""
if reference_date is None:
reference_date = datetime.now(timezone.utc)
elif reference_date.tzinfo is None:
reference_date = reference_date.replace(tzinfo=timezone.utc)
if expiration_date.tzinfo is None:
expiration_date = expiration_date.replace(tzinfo=timezone.utc)
delta = expiration_date - reference_date
return delta.days
def is_expiring_soon(
expiration_date: datetime,
threshold_days: int = 3,
reference_date: Optional[datetime] = None
) -> bool:
"""
Check if a product is expiring soon
Args:
expiration_date: The expiration datetime
threshold_days: Number of days to consider as "soon" (default: 3)
reference_date: Reference datetime (defaults to now)
Returns:
True if expiring within threshold_days, False otherwise
"""
days_until = get_days_until_expiration(expiration_date, reference_date)
return 0 <= days_until <= threshold_days
def is_expired(
expiration_date: datetime,
reference_date: Optional[datetime] = None
) -> bool:
"""
Check if a product is expired
Args:
expiration_date: The expiration datetime
reference_date: Reference datetime (defaults to now)
Returns:
True if expired, False otherwise
"""
days_until = get_days_until_expiration(expiration_date, reference_date)
return days_until < 0
def adjust_multiple_dates(
dates_dict: dict,
session_created_at: datetime,
base_reference_date: datetime = BASE_REFERENCE_DATE
) -> dict:
"""
Adjust multiple dates in a dictionary
Args:
dates_dict: Dictionary with datetime values to adjust
session_created_at: When the demo session was created
base_reference_date: The logical date when seed data was created
Returns:
Dictionary with adjusted dates (preserves None values)
Example:
>>> dates = {
... 'expiration_date': datetime(2025, 1, 30, tzinfo=timezone.utc),
... 'received_date': datetime(2025, 1, 15, tzinfo=timezone.utc),
... 'optional_date': None
... }
>>> session = datetime(2025, 10, 16, tzinfo=timezone.utc)
>>> adjusted = adjust_multiple_dates(dates, session)
"""
return {
key: adjust_date_for_demo(value, session_created_at, base_reference_date)
for key, value in dates_dict.items()
}