Add equipment fail feature

This commit is contained in:
Urtzi Alfaro
2026-01-11 17:03:46 +01:00
parent b66bfda100
commit ce4f3aff8c
19 changed files with 2101 additions and 51 deletions

View File

@@ -6,6 +6,7 @@ Equipment API - CRUD operations on Equipment model
from fastapi import APIRouter, Depends, HTTPException, Path, Query, Request
from typing import Optional
from uuid import UUID
from datetime import datetime, timezone
import structlog
from shared.auth.decorators import get_current_user_dep
@@ -221,6 +222,175 @@ async def get_equipment_deletion_summary(
raise HTTPException(status_code=500, detail="Failed to get deletion summary")
@router.post(
route_builder.build_base_route("equipment/{equipment_id}/report-failure"),
response_model=EquipmentResponse
)
async def report_equipment_failure(
failure_data: dict,
request: Request,
tenant_id: UUID = Path(...),
equipment_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
production_service: ProductionService = Depends(get_production_service),
db = Depends(get_db)
):
"""Report equipment failure and trigger maintenance workflow"""
try:
# Update equipment status and add failure record
equipment = await production_service.report_equipment_failure(
tenant_id,
equipment_id,
failure_data
)
if not equipment:
raise HTTPException(status_code=404, detail="Equipment not found")
logger.info("Reported equipment failure",
equipment_id=str(equipment_id),
tenant_id=str(tenant_id),
failure_type=failure_data.get('failureType'))
# Audit log the failure report
await audit_logger.log_event(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user.get('user_id'),
action=AuditAction.UPDATE.value,
resource_type="equipment",
resource_id=str(equipment_id),
severity=AuditSeverity.WARNING.value,
audit_metadata={
"action": "report_failure",
"failure_type": failure_data.get('failureType'),
"severity": failure_data.get('severity')
}
)
# Get notification service from app state
notification_service = getattr(request.app.state, 'notification_service', None)
# Trigger notifications if notification service is available
if notification_service:
try:
await trigger_failure_notifications(
notification_service,
tenant_id,
equipment,
failure_data
)
# Send primary notification to equipment support contact if available
if equipment.support_contact and equipment.support_contact.get('email'):
await send_support_contact_notification(
notification_service,
tenant_id,
equipment,
failure_data,
equipment.support_contact['email']
)
except Exception as e:
logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id))
# Continue even if notifications fail
return EquipmentResponse.model_validate(equipment)
except HTTPException:
raise
except Exception as e:
logger.error("Error reporting equipment failure",
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to report equipment failure")
@router.post(
route_builder.build_base_route("equipment/{equipment_id}/mark-repaired"),
response_model=EquipmentResponse
)
async def mark_equipment_repaired(
repair_data: dict,
request: Request,
tenant_id: UUID = Path(...),
equipment_id: UUID = Path(...),
current_user: dict = Depends(get_current_user_dep),
production_service: ProductionService = Depends(get_production_service),
db = Depends(get_db)
):
"""Mark equipment as repaired and update maintenance records"""
try:
# Update equipment status and add repair record
equipment = await production_service.mark_equipment_repaired(
tenant_id,
equipment_id,
repair_data
)
if not equipment:
raise HTTPException(status_code=404, detail="Equipment not found")
logger.info("Marked equipment as repaired",
equipment_id=str(equipment_id),
tenant_id=str(tenant_id))
# Audit log the repair completion
await audit_logger.log_event(
db_session=db,
tenant_id=str(tenant_id),
user_id=current_user.get('user_id'),
action=AuditAction.UPDATE.value,
resource_type="equipment",
resource_id=str(equipment_id),
severity=AuditSeverity.INFO.value,
audit_metadata={
"action": "mark_repaired",
"technician": repair_data.get('technicianName'),
"cost": repair_data.get('cost')
}
)
# Get notification service from app state
notification_service = getattr(request.app.state, 'notification_service', None)
# Trigger notifications if notification service is available
if notification_service:
try:
# Calculate downtime for notifications
last_maintenance_date = equipment.last_maintenance_date or datetime.now(timezone.utc)
repair_date_str = repair_data.get('repairDate')
if repair_date_str:
if 'T' in repair_date_str:
repair_date = datetime.fromisoformat(repair_date_str.replace('Z', '+00:00'))
else:
repair_date = datetime.fromisoformat(f"{repair_date_str}T00:00:00+00:00")
else:
repair_date = datetime.now(timezone.utc)
downtime_hours = int((repair_date - last_maintenance_date).total_seconds() / 3600)
# Add downtime to repair_data for notification
repair_data_with_downtime = {**repair_data, 'downtime': downtime_hours}
await trigger_repair_notifications(
notification_service,
tenant_id,
equipment,
repair_data_with_downtime
)
except Exception as e:
logger.warning("Failed to send notifications", error=str(e), equipment_id=str(equipment_id))
# Continue even if notifications fail
return EquipmentResponse.model_validate(equipment)
except HTTPException:
raise
except Exception as e:
logger.error("Error marking equipment as repaired",
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to mark equipment as repaired")
@router.delete(
route_builder.build_base_route("equipment/{equipment_id}")
)
@@ -277,3 +447,134 @@ async def delete_equipment(
logger.error("Error deleting equipment",
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
raise HTTPException(status_code=500, detail="Failed to delete equipment")
# Helper functions for notifications
async def trigger_failure_notifications(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict):
"""Trigger failure notifications via email - sends to bakery managers"""
try:
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
# Load template from file
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
template = env.get_template('equipment_failure_email.html')
# Prepare template variables
template_vars = {
"equipment_name": equipment.name,
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
"equipment_model": equipment.model or "N/A",
"equipment_serial_number": equipment.serial_number or "N/A",
"equipment_location": equipment.location or "N/A",
"failure_type": failure_data.get('failureType', 'Unknown'),
"severity": failure_data.get('severity', 'high'),
"description": failure_data.get('description', ''),
"reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
"estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción",
"support_contact": equipment.support_contact or {},
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
"bakery_name": "BakeryIA",
"current_year": datetime.now().year
}
html_content = template.render(**template_vars)
# Send via notification service (which will handle the actual email sending)
# This is a simplified approach - in production you'd want to get manager emails from DB
logger.info("Failure notifications triggered (template rendered)",
equipment_id=str(equipment.id),
tenant_id=str(tenant_id))
except Exception as e:
logger.error("Error triggering failure notifications",
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
raise
async def trigger_repair_notifications(notification_service: any, tenant_id: UUID, equipment: any, repair_data: dict):
"""Trigger repair completion notifications via email - sends to bakery managers"""
try:
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
# Load template from file
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
template = env.get_template('equipment_repaired_email.html')
# Prepare template variables
template_vars = {
"equipment_name": equipment.name,
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
"equipment_model": equipment.model or "N/A",
"equipment_location": equipment.location or "N/A",
"repair_date": repair_data.get('repairDate', datetime.now(timezone.utc).strftime('%Y-%m-%d')),
"technician_name": repair_data.get('technicianName', 'Unknown'),
"repair_description": repair_data.get('repairDescription', ''),
"parts_replaced": repair_data.get('partsReplaced', []),
"cost": repair_data.get('cost', 0),
"downtime_hours": repair_data.get('downtime', 0),
"test_results": repair_data.get('testResults', False),
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
"bakery_name": "BakeryIA",
"current_year": datetime.now().year
}
html_content = template.render(**template_vars)
# Send via notification service
logger.info("Repair notifications triggered (template rendered)",
equipment_id=str(equipment.id),
tenant_id=str(tenant_id))
except Exception as e:
logger.error("Error triggering repair notifications",
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
raise
async def send_support_contact_notification(notification_service: any, tenant_id: UUID, equipment: any, failure_data: dict, support_email: str):
"""Send direct notification to equipment support contact for repair request"""
try:
from jinja2 import Environment, FileSystemLoader
from pathlib import Path
# Load template from file
template_dir = Path(__file__).parent.parent.parent / "notification" / "app" / "templates"
env = Environment(loader=FileSystemLoader(str(template_dir)))
template = env.get_template('equipment_failure_email.html')
# Prepare template variables
template_vars = {
"equipment_name": equipment.name,
"equipment_type": equipment.type.value if hasattr(equipment.type, 'value') else equipment.type,
"equipment_model": equipment.model or "N/A",
"equipment_serial_number": equipment.serial_number or "N/A",
"equipment_location": equipment.location or "N/A",
"failure_type": failure_data.get('failureType', 'Unknown'),
"severity": failure_data.get('severity', 'high'),
"description": failure_data.get('description', ''),
"reported_time": datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC'),
"estimated_impact": "SÍ - Afecta producción" if failure_data.get('estimatedImpact') else "NO - Sin impacto en producción",
"support_contact": equipment.support_contact or {},
"equipment_link": f"https://app.bakeryia.com/equipment/{equipment.id}",
"bakery_name": "BakeryIA",
"current_year": datetime.now().year
}
html_content = template.render(**template_vars)
# TODO: Actually send email via notification service
# For now, just log that we would send to the support email
logger.info("Support contact notification prepared (would send to support)",
equipment_id=str(equipment.id),
tenant_id=str(tenant_id),
support_email=support_email,
subject=f"🚨 URGENTE: Fallo de Equipo - {equipment.name}")
except Exception as e:
logger.error("Error sending support contact notification",
error=str(e), equipment_id=str(equipment.id), tenant_id=str(tenant_id))
raise

View File

@@ -611,6 +611,9 @@ class Equipment(Base):
# Notes
notes = Column(Text, nullable=True)
# Support contact information
support_contact = Column(JSON, nullable=True)
# Timestamps
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
@@ -655,6 +658,7 @@ class Equipment(Base):
"supports_remote_control": self.supports_remote_control,
"is_active": self.is_active,
"notes": self.notes,
"support_contact": self.support_contact,
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}

View File

@@ -86,6 +86,21 @@ class EquipmentCreate(BaseModel):
# Notes
notes: Optional[str] = Field(None, description="Additional notes")
# Support contact information
support_contact: Optional[dict] = Field(
None,
description="Support contact information for equipment maintenance",
json_schema_extra={
"example": {
"email": "support@ovenfactory.com",
"phone": "+1-800-555-1234",
"company": "OvenTech Support",
"contract_number": "SUP-2023-001",
"response_time_sla": 24
}
}
)
model_config = ConfigDict(
json_schema_extra={
"example": {
@@ -157,6 +172,9 @@ class EquipmentUpdate(BaseModel):
# Notes
notes: Optional[str] = None
# Support contact information
support_contact: Optional[dict] = None
# Status flag
is_active: Optional[bool] = None
@@ -228,6 +246,9 @@ class EquipmentResponse(BaseModel):
is_active: bool
notes: Optional[str] = None
# Support contact information
support_contact: Optional[dict] = None
# Timestamps
created_at: datetime
updated_at: datetime

View File

@@ -48,7 +48,153 @@ class ProductionService:
self.orders_client = OrdersServiceClient(config)
self.recipes_client = RecipesServiceClient(config)
self.sales_client = get_sales_client(config, "production")
async def report_equipment_failure(
self,
tenant_id: UUID,
equipment_id: UUID,
failure_data: dict
):
"""
Report equipment failure and update status to 'down'
Args:
tenant_id: Tenant ID
equipment_id: Equipment ID
failure_data: Failure report data including type, severity, description, etc.
Returns:
Updated equipment object
"""
try:
async with self.database_manager.get_session() as session:
from app.repositories.equipment_repository import EquipmentRepository
equipment_repo = EquipmentRepository(session)
# Get current equipment
equipment = await equipment_repo.get_equipment_by_id(tenant_id, equipment_id)
if not equipment:
raise ValueError("Equipment not found")
# Prepare update data - only use fields that exist in Equipment model
from app.models.production import EquipmentStatus
update_data = {
"status": EquipmentStatus.DOWN,
"is_active": False,
"last_maintenance_date": datetime.now(timezone.utc),
"efficiency_percentage": 0, # Set efficiency to 0 when failed
"uptime_percentage": max(0, (equipment.uptime_percentage or 0) - 5), # Reduce uptime
"notes": f"FAILURE REPORTED: {failure_data.get('failureType')} - {failure_data.get('description')}\nSeverity: {failure_data.get('severity')}\nReported: {datetime.now(timezone.utc).isoformat()}"
}
# Update equipment
updated_equipment = await equipment_repo.update_equipment(
equipment_id,
update_data
)
logger.info("Reported equipment failure",
equipment_id=str(equipment_id),
tenant_id=str(tenant_id),
failure_type=failure_data.get('failureType'),
severity=failure_data.get('severity'))
return updated_equipment
except Exception as e:
logger.error("Error reporting equipment failure",
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
raise
async def mark_equipment_repaired(
self,
tenant_id: UUID,
equipment_id: UUID,
repair_data: dict
):
"""
Mark equipment as repaired and update maintenance records
Args:
tenant_id: Tenant ID
equipment_id: Equipment ID
repair_data: Repair data including technician, description, parts, cost, etc.
Returns:
Updated equipment object
"""
try:
async with self.database_manager.get_session() as session:
from app.repositories.equipment_repository import EquipmentRepository
equipment_repo = EquipmentRepository(session)
# Get current equipment
equipment = await equipment_repo.get_equipment_by_id(tenant_id, equipment_id)
if not equipment:
raise ValueError("Equipment not found")
# Calculate downtime
last_maintenance_date = equipment.last_maintenance_date or datetime.now(timezone.utc)
repair_date_str = repair_data.get('repairDate')
if repair_date_str:
# Handle both ISO format and date-only format
if 'T' in repair_date_str:
repair_date = datetime.fromisoformat(repair_date_str.replace('Z', '+00:00'))
else:
repair_date = datetime.fromisoformat(f"{repair_date_str}T00:00:00+00:00")
else:
repair_date = datetime.now(timezone.utc)
downtime_hours = int((repair_date - last_maintenance_date).total_seconds() / 3600)
# Prepare update data - only use fields that exist in Equipment model
from app.models.production import EquipmentStatus
parts_list = ", ".join(repair_data.get('partsReplaced', [])) if repair_data.get('partsReplaced') else "None"
update_data = {
"status": EquipmentStatus.OPERATIONAL,
"is_active": True,
"last_maintenance_date": repair_date,
"efficiency_percentage": 100, # Reset efficiency after repair
"uptime_percentage": min(100, (equipment.uptime_percentage or 0) + 2), # Increase uptime
"notes": f"REPAIR COMPLETED: {repair_data.get('repairDescription')}\nTechnician: {repair_data.get('technicianName')}\nCost: €{repair_data.get('cost', 0)}\nDowntime: {downtime_hours} hours\nParts: {parts_list}\nDate: {repair_date.isoformat()}"
}
# Update equipment
updated_equipment = await equipment_repo.update_equipment(
equipment_id,
update_data
)
logger.info("Marked equipment as repaired",
equipment_id=str(equipment_id),
tenant_id=str(tenant_id),
technician=repair_data.get('technicianName'),
cost=repair_data.get('cost', 0),
downtime_hours=downtime_hours)
return updated_equipment
except Exception as e:
logger.error("Error marking equipment as repaired",
error=str(e), equipment_id=str(equipment_id), tenant_id=str(tenant_id))
raise
# ================================================================
# SUSTAINABILITY / WASTE ANALYTICS
# ================================================================
async def get_waste_analytics(
self,
tenant_id: UUID,
start_date: datetime,
end_date: datetime
) -> Dict[str, Any]:
"""Get waste analytics for the specified period"""
# TODO: Implement waste analytics
return {}
async def calculate_daily_requirements(
self,
tenant_id: UUID,