Add improved production UI 3

This commit is contained in:
Urtzi Alfaro
2025-09-23 19:24:22 +02:00
parent 7f871fc933
commit 7892c5a739
47 changed files with 6211 additions and 267 deletions

View File

@@ -22,6 +22,7 @@ from app.schemas.production import (
ProductionStatusEnum
)
from app.core.config import settings
from .quality_templates import router as quality_templates_router
logger = structlog.get_logger()

View File

@@ -0,0 +1,174 @@
# services/production/app/api/quality_templates.py
"""
Quality Check Template API endpoints for production service
"""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from uuid import UUID
from ..core.database import get_db
from ..models.production import QualityCheckTemplate, ProcessStage
from ..services.quality_template_service import QualityTemplateService
from ..schemas.quality_templates import (
QualityCheckTemplateCreate,
QualityCheckTemplateUpdate,
QualityCheckTemplateResponse,
QualityCheckTemplateList
)
from shared.auth.tenant_access import get_current_tenant_id
router = APIRouter(prefix="/quality-templates", tags=["quality-templates"])
@router.post("", response_model=QualityCheckTemplateResponse, status_code=status.HTTP_201_CREATED)
async def create_quality_template(
template_data: QualityCheckTemplateCreate,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Create a new quality check template"""
try:
service = QualityTemplateService(db)
template = await service.create_template(tenant_id, template_data)
return QualityCheckTemplateResponse.from_orm(template)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("", response_model=QualityCheckTemplateList)
async def get_quality_templates(
tenant_id: str = Depends(get_current_tenant_id),
stage: Optional[ProcessStage] = Query(None, description="Filter by process stage"),
check_type: Optional[str] = Query(None, description="Filter by check type"),
is_active: Optional[bool] = Query(True, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=1000),
db: Session = Depends(get_db)
):
"""Get quality check templates for tenant"""
try:
service = QualityTemplateService(db)
templates, total = await service.get_templates(
tenant_id=tenant_id,
stage=stage,
check_type=check_type,
is_active=is_active,
skip=skip,
limit=limit
)
return QualityCheckTemplateList(
templates=[QualityCheckTemplateResponse.from_orm(t) for t in templates],
total=total,
skip=skip,
limit=limit
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{template_id}", response_model=QualityCheckTemplateResponse)
async def get_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Get a specific quality check template"""
try:
service = QualityTemplateService(db)
template = await service.get_template(tenant_id, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.put("/{template_id}", response_model=QualityCheckTemplateResponse)
async def update_quality_template(
template_id: UUID,
template_data: QualityCheckTemplateUpdate,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Update a quality check template"""
try:
service = QualityTemplateService(db)
template = await service.update_template(tenant_id, template_id, template_data)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.delete("/{template_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Delete a quality check template"""
try:
service = QualityTemplateService(db)
success = await service.delete_template(tenant_id, template_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/stages/{stage}", response_model=QualityCheckTemplateList)
async def get_templates_for_stage(
stage: ProcessStage,
tenant_id: str = Depends(get_current_tenant_id),
is_active: Optional[bool] = Query(True, description="Filter by active status"),
db: Session = Depends(get_db)
):
"""Get quality check templates applicable to a specific process stage"""
try:
service = QualityTemplateService(db)
templates = await service.get_templates_for_stage(tenant_id, stage, is_active)
return QualityCheckTemplateList(
templates=[QualityCheckTemplateResponse.from_orm(t) for t in templates],
total=len(templates),
skip=0,
limit=len(templates)
)
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/{template_id}/duplicate", response_model=QualityCheckTemplateResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_quality_template(
template_id: UUID,
tenant_id: str = Depends(get_current_tenant_id),
db: Session = Depends(get_db)
):
"""Duplicate an existing quality check template"""
try:
service = QualityTemplateService(db)
template = await service.duplicate_template(tenant_id, template_id)
if not template:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Template not found")
return QualityCheckTemplateResponse.from_orm(template)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))

View File

@@ -14,6 +14,7 @@ import structlog
from app.core.config import settings
from app.core.database import init_database, get_db_health
from app.api.production import router as production_router
from app.api.quality_templates import router as quality_templates_router
from app.services.production_alert_service import ProductionAlertService
# Configure logging
@@ -73,6 +74,7 @@ app.add_middleware(
# Include routers
app.include_router(production_router, prefix="/api/v1")
app.include_router(quality_templates_router, prefix="/api/v1")
@app.get("/health")

View File

@@ -35,6 +35,22 @@ class ProductionPriority(str, enum.Enum):
URGENT = "URGENT"
class EquipmentStatus(str, enum.Enum):
"""Equipment status enumeration"""
OPERATIONAL = "operational"
MAINTENANCE = "maintenance"
DOWN = "down"
WARNING = "warning"
class EquipmentType(str, enum.Enum):
"""Equipment type enumeration"""
OVEN = "oven"
MIXER = "mixer"
PROOFER = "proofer"
FREEZER = "freezer"
PACKAGING = "packaging"
OTHER = "other"
class ProductionBatch(Base):
@@ -56,16 +72,22 @@ class ProductionBatch(Base):
planned_end_time = Column(DateTime(timezone=True), nullable=False)
planned_quantity = Column(Float, nullable=False)
planned_duration_minutes = Column(Integer, nullable=False)
# Actual production tracking
actual_start_time = Column(DateTime(timezone=True), nullable=True)
actual_end_time = Column(DateTime(timezone=True), nullable=True)
actual_quantity = Column(Float, nullable=True)
actual_duration_minutes = Column(Integer, nullable=True)
# Status and priority
status = Column(SQLEnum(ProductionStatus), nullable=False, default=ProductionStatus.PENDING, index=True)
priority = Column(SQLEnum(ProductionPriority), nullable=False, default=ProductionPriority.MEDIUM)
# Process stage tracking
current_process_stage = Column(SQLEnum(ProcessStage), nullable=True, index=True)
process_stage_history = Column(JSON, nullable=True) # Track stage transitions with timestamps
pending_quality_checks = Column(JSON, nullable=True) # Required quality checks for current stage
completed_quality_checks = Column(JSON, nullable=True) # Completed quality checks by stage
# Cost tracking
estimated_cost = Column(Float, nullable=True)
@@ -307,48 +329,138 @@ class ProductionCapacity(Base):
}
class ProcessStage(str, enum.Enum):
"""Production process stages where quality checks can occur"""
MIXING = "mixing"
PROOFING = "proofing"
SHAPING = "shaping"
BAKING = "baking"
COOLING = "cooling"
PACKAGING = "packaging"
FINISHING = "finishing"
class QualityCheckTemplate(Base):
"""Quality check templates for tenant-specific quality standards"""
__tablename__ = "quality_check_templates"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Template identification
name = Column(String(255), nullable=False)
template_code = Column(String(100), nullable=True, index=True)
check_type = Column(String(50), nullable=False) # visual, measurement, temperature, weight, boolean
category = Column(String(100), nullable=True) # appearance, structure, texture, etc.
# Template configuration
description = Column(Text, nullable=True)
instructions = Column(Text, nullable=True)
parameters = Column(JSON, nullable=True) # Dynamic check parameters
thresholds = Column(JSON, nullable=True) # Pass/fail criteria
scoring_criteria = Column(JSON, nullable=True) # Scoring methodology
# Configurability settings
is_active = Column(Boolean, default=True)
is_required = Column(Boolean, default=False)
is_critical = Column(Boolean, default=False) # Critical failures block production
weight = Column(Float, default=1.0) # Weight in overall quality score
# Measurement specifications
min_value = Column(Float, nullable=True)
max_value = Column(Float, nullable=True)
target_value = Column(Float, nullable=True)
unit = Column(String(20), nullable=True)
tolerance_percentage = Column(Float, nullable=True)
# Process stage applicability
applicable_stages = Column(JSON, nullable=True) # List of ProcessStage values
# Metadata
created_by = Column(UUID(as_uuid=True), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary following shared pattern"""
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"name": self.name,
"template_code": self.template_code,
"check_type": self.check_type,
"category": self.category,
"description": self.description,
"instructions": self.instructions,
"parameters": self.parameters,
"thresholds": self.thresholds,
"scoring_criteria": self.scoring_criteria,
"is_active": self.is_active,
"is_required": self.is_required,
"is_critical": self.is_critical,
"weight": self.weight,
"min_value": self.min_value,
"max_value": self.max_value,
"target_value": self.target_value,
"unit": self.unit,
"tolerance_percentage": self.tolerance_percentage,
"applicable_stages": self.applicable_stages,
"created_by": str(self.created_by),
"created_at": self.created_at.isoformat() if self.created_at else None,
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
}
class QualityCheck(Base):
"""Quality check model for tracking production quality metrics"""
"""Quality check model for tracking production quality metrics with stage support"""
__tablename__ = "quality_checks"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
batch_id = Column(UUID(as_uuid=True), nullable=False, index=True) # FK to ProductionBatch
template_id = Column(UUID(as_uuid=True), nullable=True, index=True) # FK to QualityCheckTemplate
# Check information
check_type = Column(String(50), nullable=False) # visual, weight, temperature, etc.
process_stage = Column(SQLEnum(ProcessStage), nullable=True, index=True) # Stage when check was performed
check_time = Column(DateTime(timezone=True), nullable=False)
checker_id = Column(String(100), nullable=True) # Staff member who performed check
# Quality metrics
quality_score = Column(Float, nullable=False) # 1-10 scale
pass_fail = Column(Boolean, nullable=False)
defect_count = Column(Integer, nullable=False, default=0)
defect_types = Column(JSON, nullable=True) # List of defect categories
# Measurements
measured_weight = Column(Float, nullable=True)
measured_temperature = Column(Float, nullable=True)
measured_moisture = Column(Float, nullable=True)
measured_dimensions = Column(JSON, nullable=True)
stage_specific_data = Column(JSON, nullable=True) # Stage-specific measurements
# Standards comparison
target_weight = Column(Float, nullable=True)
target_temperature = Column(Float, nullable=True)
target_moisture = Column(Float, nullable=True)
tolerance_percentage = Column(Float, nullable=True)
# Results
within_tolerance = Column(Boolean, nullable=True)
corrective_action_needed = Column(Boolean, default=False)
corrective_actions = Column(JSON, nullable=True)
# Template-based results
template_results = Column(JSON, nullable=True) # Results from template-based checks
criteria_scores = Column(JSON, nullable=True) # Individual criteria scores
# Notes and documentation
check_notes = Column(Text, nullable=True)
photos_urls = Column(JSON, nullable=True) # URLs to quality check photos
certificate_url = Column(String(500), 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())
@@ -385,3 +497,81 @@ class QualityCheck(Base):
}
class Equipment(Base):
"""Equipment model for tracking production equipment"""
__tablename__ = "equipment"
# Primary identification
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
tenant_id = Column(UUID(as_uuid=True), nullable=False, index=True)
# Equipment identification
name = Column(String(255), nullable=False)
type = Column(SQLEnum(EquipmentType), nullable=False)
model = Column(String(100), nullable=True)
serial_number = Column(String(100), nullable=True)
location = Column(String(255), nullable=True)
# Status tracking
status = Column(SQLEnum(EquipmentStatus), nullable=False, default=EquipmentStatus.OPERATIONAL)
# Dates
install_date = Column(DateTime(timezone=True), nullable=True)
last_maintenance_date = Column(DateTime(timezone=True), nullable=True)
next_maintenance_date = Column(DateTime(timezone=True), nullable=True)
maintenance_interval_days = Column(Integer, nullable=True) # Maintenance interval in days
# Performance metrics
efficiency_percentage = Column(Float, nullable=True) # Current efficiency
uptime_percentage = Column(Float, nullable=True) # Overall equipment effectiveness
energy_usage_kwh = Column(Float, nullable=True) # Current energy usage
# Specifications
power_kw = Column(Float, nullable=True) # Power in kilowatts
capacity = Column(Float, nullable=True) # Capacity (units depend on equipment type)
weight_kg = Column(Float, nullable=True) # Weight in kilograms
# Temperature monitoring
current_temperature = Column(Float, nullable=True) # Current temperature reading
target_temperature = Column(Float, nullable=True) # Target temperature
# Status
is_active = Column(Boolean, default=True)
# Notes
notes = Column(Text, 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())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary following shared pattern"""
return {
"id": str(self.id),
"tenant_id": str(self.tenant_id),
"name": self.name,
"type": self.type.value if self.type else None,
"model": self.model,
"serial_number": self.serial_number,
"location": self.location,
"status": self.status.value if self.status else None,
"install_date": self.install_date.isoformat() if self.install_date else None,
"last_maintenance_date": self.last_maintenance_date.isoformat() if self.last_maintenance_date else None,
"next_maintenance_date": self.next_maintenance_date.isoformat() if self.next_maintenance_date else None,
"maintenance_interval_days": self.maintenance_interval_days,
"efficiency_percentage": self.efficiency_percentage,
"uptime_percentage": self.uptime_percentage,
"energy_usage_kwh": self.energy_usage_kwh,
"power_kw": self.power_kw,
"capacity": self.capacity,
"weight_kg": self.weight_kg,
"current_temperature": self.current_temperature,
"target_temperature": self.target_temperature,
"is_active": self.is_active,
"notes": self.notes,
"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

@@ -0,0 +1,179 @@
# services/production/app/schemas/quality_templates.py
"""
Quality Check Template Pydantic schemas for validation and serialization
"""
from pydantic import BaseModel, Field, validator
from typing import Optional, List, Dict, Any, Union
from uuid import UUID
from datetime import datetime
from enum import Enum
from ..models.production import ProcessStage
class QualityCheckType(str, Enum):
"""Quality check types"""
VISUAL = "visual"
MEASUREMENT = "measurement"
TEMPERATURE = "temperature"
WEIGHT = "weight"
BOOLEAN = "boolean"
TIMING = "timing"
class QualityCheckTemplateBase(BaseModel):
"""Base schema for quality check templates"""
name: str = Field(..., min_length=1, max_length=255, description="Template name")
template_code: Optional[str] = Field(None, max_length=100, description="Template code for reference")
check_type: QualityCheckType = Field(..., description="Type of quality check")
category: Optional[str] = Field(None, max_length=100, description="Check category (e.g., appearance, structure)")
description: Optional[str] = Field(None, description="Template description")
instructions: Optional[str] = Field(None, description="Check instructions for staff")
# Configuration
parameters: Optional[Dict[str, Any]] = Field(None, description="Dynamic check parameters")
thresholds: Optional[Dict[str, Any]] = Field(None, description="Pass/fail criteria")
scoring_criteria: Optional[Dict[str, Any]] = Field(None, description="Scoring methodology")
# Settings
is_active: bool = Field(True, description="Whether template is active")
is_required: bool = Field(False, description="Whether check is required")
is_critical: bool = Field(False, description="Whether failure blocks production")
weight: float = Field(1.0, ge=0.0, le=10.0, description="Weight in overall quality score")
# Measurement specifications
min_value: Optional[float] = Field(None, description="Minimum acceptable value")
max_value: Optional[float] = Field(None, description="Maximum acceptable value")
target_value: Optional[float] = Field(None, description="Target value")
unit: Optional[str] = Field(None, max_length=20, description="Unit of measurement")
tolerance_percentage: Optional[float] = Field(None, ge=0.0, le=100.0, description="Tolerance percentage")
# Process stage applicability
applicable_stages: Optional[List[ProcessStage]] = Field(None, description="Applicable process stages")
@validator('applicable_stages')
def validate_stages(cls, v):
if v is not None:
# Ensure all values are valid ProcessStage enums
for stage in v:
if stage not in ProcessStage:
raise ValueError(f"Invalid process stage: {stage}")
return v
@validator('min_value', 'max_value', 'target_value')
def validate_measurement_values(cls, v, values):
if v is not None and values.get('check_type') not in [QualityCheckType.MEASUREMENT, QualityCheckType.TEMPERATURE, QualityCheckType.WEIGHT]:
return None # Clear values for non-measurement types
return v
class QualityCheckTemplateCreate(QualityCheckTemplateBase):
"""Schema for creating quality check templates"""
created_by: UUID = Field(..., description="User ID who created the template")
class QualityCheckTemplateUpdate(BaseModel):
"""Schema for updating quality check templates"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
template_code: Optional[str] = Field(None, max_length=100)
check_type: Optional[QualityCheckType] = None
category: Optional[str] = Field(None, max_length=100)
description: Optional[str] = None
instructions: Optional[str] = None
parameters: Optional[Dict[str, Any]] = None
thresholds: Optional[Dict[str, Any]] = None
scoring_criteria: Optional[Dict[str, Any]] = None
is_active: Optional[bool] = None
is_required: Optional[bool] = None
is_critical: Optional[bool] = None
weight: Optional[float] = Field(None, ge=0.0, le=10.0)
min_value: Optional[float] = None
max_value: Optional[float] = None
target_value: Optional[float] = None
unit: Optional[str] = Field(None, max_length=20)
tolerance_percentage: Optional[float] = Field(None, ge=0.0, le=100.0)
applicable_stages: Optional[List[ProcessStage]] = None
class QualityCheckTemplateResponse(QualityCheckTemplateBase):
"""Schema for quality check template responses"""
id: UUID
tenant_id: UUID
created_by: UUID
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class QualityCheckTemplateList(BaseModel):
"""Schema for paginated quality check template lists"""
templates: List[QualityCheckTemplateResponse]
total: int
skip: int
limit: int
class QualityCheckCriterion(BaseModel):
"""Individual quality check criterion within a template"""
id: str = Field(..., description="Unique criterion identifier")
name: str = Field(..., description="Criterion name")
description: str = Field(..., description="Criterion description")
check_type: QualityCheckType = Field(..., description="Type of check")
required: bool = Field(True, description="Whether criterion is required")
weight: float = Field(1.0, ge=0.0, le=10.0, description="Weight in template score")
acceptable_criteria: str = Field(..., description="Description of acceptable criteria")
min_value: Optional[float] = None
max_value: Optional[float] = None
unit: Optional[str] = None
is_critical: bool = Field(False, description="Whether failure is critical")
class QualityCheckResult(BaseModel):
"""Result of a quality check criterion"""
criterion_id: str = Field(..., description="Criterion identifier")
value: Union[float, str, bool] = Field(..., description="Check result value")
score: float = Field(..., ge=0.0, le=10.0, description="Score for this criterion")
notes: Optional[str] = Field(None, description="Additional notes")
photos: Optional[List[str]] = Field(None, description="Photo URLs")
pass_check: bool = Field(..., description="Whether criterion passed")
timestamp: datetime = Field(..., description="When check was performed")
class QualityCheckExecutionRequest(BaseModel):
"""Schema for executing a quality check using a template"""
template_id: UUID = Field(..., description="Quality check template ID")
batch_id: UUID = Field(..., description="Production batch ID")
process_stage: ProcessStage = Field(..., description="Current process stage")
checker_id: Optional[str] = Field(None, description="Staff member performing check")
results: List[QualityCheckResult] = Field(..., description="Check results")
final_notes: Optional[str] = Field(None, description="Final notes")
photos: Optional[List[str]] = Field(None, description="Additional photo URLs")
class QualityCheckExecutionResponse(BaseModel):
"""Schema for quality check execution results"""
check_id: UUID = Field(..., description="Created quality check ID")
overall_score: float = Field(..., ge=0.0, le=10.0, description="Overall quality score")
overall_pass: bool = Field(..., description="Whether check passed overall")
critical_failures: List[str] = Field(..., description="List of critical failures")
corrective_actions: List[str] = Field(..., description="Recommended corrective actions")
timestamp: datetime = Field(..., description="When check was completed")
class ProcessStageQualityConfig(BaseModel):
"""Configuration for quality checks at a specific process stage"""
stage: ProcessStage = Field(..., description="Process stage")
template_ids: List[UUID] = Field(..., description="Required template IDs")
custom_parameters: Optional[Dict[str, Any]] = Field(None, description="Stage-specific parameters")
is_required: bool = Field(True, description="Whether stage requires quality checks")
blocking: bool = Field(True, description="Whether stage blocks on failed checks")
class RecipeQualityConfiguration(BaseModel):
"""Quality check configuration for a recipe"""
stages: Dict[str, ProcessStageQualityConfig] = Field(..., description="Stage configurations")
global_parameters: Optional[Dict[str, Any]] = Field(None, description="Global quality parameters")
default_templates: Optional[List[UUID]] = Field(None, description="Default template IDs")

View File

@@ -49,14 +49,14 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
max_instances=1
)
# Equipment monitoring - disabled (equipment tables not available in production database)
# self.scheduler.add_job(
# self.check_equipment_status,
# CronTrigger(minute='*/3'),
# id='equipment_check',
# misfire_grace_time=30,
# max_instances=1
# )
# Equipment monitoring - check equipment status for maintenance alerts
self.scheduler.add_job(
self.check_equipment_status,
CronTrigger(minute='*/30'), # Check every 30 minutes
id='equipment_check',
misfire_grace_time=30,
max_instances=1
)
# Efficiency recommendations - every 30 minutes (recommendations)
self.scheduler.add_job(
@@ -394,19 +394,61 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
error=str(e))
async def check_equipment_status(self):
"""Check equipment status and failures (alerts)"""
# Equipment tables don't exist in production database - skip this check
logger.debug("Equipment check skipped - equipment tables not available in production database")
return
"""Check equipment status and maintenance requirements (alerts)"""
try:
self._checks_performed += 1
# Query equipment that needs attention
query = """
SELECT
e.id, e.tenant_id, e.name, e.type, e.status,
e.efficiency_percentage, e.uptime_percentage,
e.last_maintenance_date, e.next_maintenance_date,
e.maintenance_interval_days,
EXTRACT(DAYS FROM (e.next_maintenance_date - NOW())) as days_to_maintenance,
COUNT(ea.id) as active_alerts
FROM equipment e
LEFT JOIN alerts ea ON ea.equipment_id = e.id
AND ea.is_active = true
AND ea.is_resolved = false
WHERE e.is_active = true
AND e.tenant_id = $1
GROUP BY e.id, e.tenant_id, e.name, e.type, e.status,
e.efficiency_percentage, e.uptime_percentage,
e.last_maintenance_date, e.next_maintenance_date,
e.maintenance_interval_days
ORDER BY e.next_maintenance_date ASC
"""
tenants = await self.get_active_tenants()
for tenant_id in tenants:
try:
from sqlalchemy import text
async with self.db_manager.get_session() as session:
result = await session.execute(text(query), {"tenant_id": tenant_id})
equipment_list = result.fetchall()
for equipment in equipment_list:
await self._process_equipment_issue(equipment)
except Exception as e:
logger.error("Error checking equipment status",
tenant_id=str(tenant_id),
error=str(e))
except Exception as e:
logger.error("Equipment status check failed", error=str(e))
self._errors_count += 1
async def _process_equipment_issue(self, equipment: Dict[str, Any]):
"""Process equipment issue"""
try:
status = equipment['status']
efficiency = equipment.get('efficiency_percent', 100)
efficiency = equipment.get('efficiency_percentage', 100)
days_to_maintenance = equipment.get('days_to_maintenance', 30)
if status == 'error':
if status == 'down':
template_data = self.format_spanish_message(
'equipment_failure',
equipment_name=equipment['name']
@@ -422,41 +464,52 @@ class ProductionAlertService(BaseAlertService, AlertServiceMixin):
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
'equipment_type': equipment['type'],
'error_count': equipment.get('error_count', 0),
'last_reading': equipment.get('last_reading').isoformat() if equipment.get('last_reading') else None
'efficiency': efficiency
}
}, item_type='alert')
elif status == 'maintenance_required' or days_to_maintenance <= 1:
severity = 'high' if days_to_maintenance <= 1 else 'medium'
elif status == 'maintenance' or (days_to_maintenance is not None and days_to_maintenance <= 3):
severity = 'high' if (days_to_maintenance is not None and days_to_maintenance <= 1) else 'medium'
template_data = self.format_spanish_message(
'maintenance_required',
equipment_name=equipment['name'],
days_until_maintenance=max(0, int(days_to_maintenance)) if days_to_maintenance is not None else 3
)
await self.publish_item(equipment['tenant_id'], {
'type': 'maintenance_required',
'severity': severity,
'title': f'🔧 Mantenimiento Requerido: {equipment["name"]}',
'message': f'Equipo {equipment["name"]} requiere mantenimiento en {days_to_maintenance} días.',
'actions': ['Programar mantenimiento', 'Revisar historial', 'Preparar repuestos', 'Planificar parada'],
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'metadata': {
'equipment_id': str(equipment['id']),
'equipment_name': equipment['name'],
'days_to_maintenance': days_to_maintenance,
'last_maintenance': equipment.get('last_maintenance').isoformat() if equipment.get('last_maintenance') else None
'last_maintenance': equipment.get('last_maintenance_date')
}
}, item_type='alert')
elif efficiency < 80:
elif efficiency is not None and efficiency < 80:
severity = 'medium' if efficiency < 70 else 'low'
template_data = self.format_spanish_message(
'low_equipment_efficiency',
equipment_name=equipment['name'],
efficiency_percent=round(efficiency, 1)
)
await self.publish_item(equipment['tenant_id'], {
'type': 'low_equipment_efficiency',
'severity': severity,
'title': f'📉 Baja Eficiencia: {equipment["name"]}',
'message': f'Eficiencia del {equipment["name"]} bajó a {efficiency:.1f}%. Revisar funcionamiento.',
'actions': ['Revisar configuración', 'Limpiar equipo', 'Calibrar sensores', 'Revisar mantenimiento'],
'title': template_data['title'],
'message': template_data['message'],
'actions': template_data['actions'],
'metadata': {
'equipment_id': str(equipment['id']),
'efficiency_percent': float(efficiency),
'temperature': equipment.get('temperature'),
'vibration_level': equipment.get('vibration_level')
'equipment_name': equipment['name'],
'efficiency_percent': float(efficiency)
}
}, item_type='alert')

View File

@@ -0,0 +1,306 @@
# services/production/app/services/quality_template_service.py
"""
Quality Check Template Service for business logic and data operations
"""
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, func
from typing import List, Optional, Tuple
from uuid import UUID, uuid4
from datetime import datetime, timezone
from ..models.production import QualityCheckTemplate, ProcessStage
from ..schemas.quality_templates import QualityCheckTemplateCreate, QualityCheckTemplateUpdate
class QualityTemplateService:
"""Service for managing quality check templates"""
def __init__(self, db: Session):
self.db = db
async def create_template(
self,
tenant_id: str,
template_data: QualityCheckTemplateCreate
) -> QualityCheckTemplate:
"""Create a new quality check template"""
# Validate template code uniqueness if provided
if template_data.template_code:
existing = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.template_code == template_data.template_code
)
).first()
if existing:
raise ValueError(f"Template code '{template_data.template_code}' already exists")
# Create template
template = QualityCheckTemplate(
id=uuid4(),
tenant_id=UUID(tenant_id),
**template_data.dict()
)
self.db.add(template)
self.db.commit()
self.db.refresh(template)
return template
async def get_templates(
self,
tenant_id: str,
stage: Optional[ProcessStage] = None,
check_type: Optional[str] = None,
is_active: Optional[bool] = True,
skip: int = 0,
limit: int = 100
) -> Tuple[List[QualityCheckTemplate], int]:
"""Get quality check templates with filtering and pagination"""
query = self.db.query(QualityCheckTemplate).filter(
QualityCheckTemplate.tenant_id == tenant_id
)
# Apply filters
if is_active is not None:
query = query.filter(QualityCheckTemplate.is_active == is_active)
if check_type:
query = query.filter(QualityCheckTemplate.check_type == check_type)
if stage:
# Filter by applicable stages (JSON array contains stage)
query = query.filter(
func.json_contains(
QualityCheckTemplate.applicable_stages,
f'"{stage.value}"'
)
)
# Get total count
total = query.count()
# Apply pagination and ordering
templates = query.order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.name
).offset(skip).limit(limit).all()
return templates, total
async def get_template(
self,
tenant_id: str,
template_id: UUID
) -> Optional[QualityCheckTemplate]:
"""Get a specific quality check template"""
return self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.id == template_id
)
).first()
async def update_template(
self,
tenant_id: str,
template_id: UUID,
template_data: QualityCheckTemplateUpdate
) -> Optional[QualityCheckTemplate]:
"""Update a quality check template"""
template = await self.get_template(tenant_id, template_id)
if not template:
return None
# Validate template code uniqueness if being updated
if template_data.template_code and template_data.template_code != template.template_code:
existing = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.template_code == template_data.template_code,
QualityCheckTemplate.id != template_id
)
).first()
if existing:
raise ValueError(f"Template code '{template_data.template_code}' already exists")
# Update fields
update_data = template_data.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(template, field, value)
template.updated_at = datetime.now(timezone.utc)
self.db.commit()
self.db.refresh(template)
return template
async def delete_template(
self,
tenant_id: str,
template_id: UUID
) -> bool:
"""Delete a quality check template"""
template = await self.get_template(tenant_id, template_id)
if not template:
return False
# Check if template is in use (you might want to add this check)
# For now, we'll allow deletion but in production you might want to:
# 1. Soft delete by setting is_active = False
# 2. Check for dependent quality checks
# 3. Prevent deletion if in use
self.db.delete(template)
self.db.commit()
return True
async def get_templates_for_stage(
self,
tenant_id: str,
stage: ProcessStage,
is_active: Optional[bool] = True
) -> List[QualityCheckTemplate]:
"""Get all quality check templates applicable to a specific process stage"""
query = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
or_(
# Templates that specify applicable stages
func.json_contains(
QualityCheckTemplate.applicable_stages,
f'"{stage.value}"'
),
# Templates that don't specify stages (applicable to all)
QualityCheckTemplate.applicable_stages.is_(None)
)
)
)
if is_active is not None:
query = query.filter(QualityCheckTemplate.is_active == is_active)
return query.order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.weight.desc(),
QualityCheckTemplate.name
).all()
async def duplicate_template(
self,
tenant_id: str,
template_id: UUID
) -> Optional[QualityCheckTemplate]:
"""Duplicate an existing quality check template"""
original = await self.get_template(tenant_id, template_id)
if not original:
return None
# Create duplicate with modified name and code
duplicate_data = {
'name': f"{original.name} (Copy)",
'template_code': f"{original.template_code}_copy" if original.template_code else None,
'check_type': original.check_type,
'category': original.category,
'description': original.description,
'instructions': original.instructions,
'parameters': original.parameters,
'thresholds': original.thresholds,
'scoring_criteria': original.scoring_criteria,
'is_active': original.is_active,
'is_required': original.is_required,
'is_critical': original.is_critical,
'weight': original.weight,
'min_value': original.min_value,
'max_value': original.max_value,
'target_value': original.target_value,
'unit': original.unit,
'tolerance_percentage': original.tolerance_percentage,
'applicable_stages': original.applicable_stages,
'created_by': original.created_by
}
create_data = QualityCheckTemplateCreate(**duplicate_data)
return await self.create_template(tenant_id, create_data)
async def get_templates_by_recipe_config(
self,
tenant_id: str,
stage: ProcessStage,
recipe_quality_config: dict
) -> List[QualityCheckTemplate]:
"""Get quality check templates based on recipe configuration"""
# Extract template IDs from recipe configuration for the specific stage
stage_config = recipe_quality_config.get('stages', {}).get(stage.value)
if not stage_config:
return []
template_ids = stage_config.get('template_ids', [])
if not template_ids:
return []
# Get templates by IDs
templates = self.db.query(QualityCheckTemplate).filter(
and_(
QualityCheckTemplate.tenant_id == tenant_id,
QualityCheckTemplate.id.in_([UUID(tid) for tid in template_ids]),
QualityCheckTemplate.is_active == True
)
).order_by(
QualityCheckTemplate.is_critical.desc(),
QualityCheckTemplate.is_required.desc(),
QualityCheckTemplate.weight.desc()
).all()
return templates
async def validate_template_configuration(
self,
tenant_id: str,
template_data: dict
) -> Tuple[bool, List[str]]:
"""Validate quality check template configuration"""
errors = []
# Validate check type specific requirements
check_type = template_data.get('check_type')
if check_type in ['measurement', 'temperature', 'weight']:
if not template_data.get('unit'):
errors.append(f"Unit is required for {check_type} checks")
min_val = template_data.get('min_value')
max_val = template_data.get('max_value')
if min_val is not None and max_val is not None and min_val >= max_val:
errors.append("Minimum value must be less than maximum value")
# Validate scoring criteria
scoring = template_data.get('scoring_criteria', {})
if check_type == 'visual' and not scoring:
errors.append("Visual checks require scoring criteria")
# Validate process stages
stages = template_data.get('applicable_stages', [])
if stages:
valid_stages = [stage.value for stage in ProcessStage]
invalid_stages = [s for s in stages if s not in valid_stages]
if invalid_stages:
errors.append(f"Invalid process stages: {invalid_stages}")
return len(errors) == 0, errors