Add equipment fail feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user