import logging from odoo import models, fields, api, _ from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) class HrEfficiencyIndicator(models.Model): _name = 'hr.efficiency.indicator' _description = 'Efficiency Indicator Configuration' _order = 'sequence, name' name = fields.Char('Indicator Name', required=True) sequence = fields.Integer('Sequence', default=10) active = fields.Boolean('Active', default=True) # Indicator type for widget selection indicator_type = fields.Selection([ ('percentage', 'Percentage'), ('hours', 'Hours'), ('currency', 'Currency'), ('number', 'Number') ], string='Indicator Type', default='percentage', required=True, help='Type of indicator that determines how it will be displayed in views') # Formula configuration formula = fields.Text('Formula', required=True, help=""" Use the following variables in your formula: - available_hours: Available hours for the employee - planned_hours: Total planned hours - planned_billable_hours: Planned hours on billable projects - planned_non_billable_hours: Planned hours on non-billable projects - actual_billable_hours: Actual hours worked on billable projects - actual_non_billable_hours: Actual hours worked on non-billable projects Examples: - (planned_hours / available_hours) * 100 # Planning efficiency - ((actual_billable_hours + actual_non_billable_hours) / planned_hours) * 100 # Time tracking efficiency """) # Target and thresholds target_percentage = fields.Float('Target %', default=90.0, help='Target percentage for this indicator') weight = fields.Float('Weight %', default=50.0, help='Weight of this indicator in the overall efficiency calculation') # Priority for column background color priority = fields.Selection([ ('none', 'Sin Prioridad'), ('low', 'Baja (Verde)'), ('medium', 'Media (Amarillo)'), ('high', 'Alta (Rojo)') ], string='Prioridad', default='none', help='Prioridad del indicador que determina el color de fondo de la columna') # Display settings description = fields.Text('Description', help='Description of what this indicator measures') color_threshold_green = fields.Float('Green Threshold', default=90.0, help='Percentage above which to show green') color_threshold_yellow = fields.Float('Yellow Threshold', default=70.0, help='Percentage above which to show yellow') color_threshold_red = fields.Float('Red Threshold', default=50.0, help='Percentage above which to show red (below this shows danger)') # Computed field for priority background color priority_color = fields.Char('Priority Color', compute='_compute_priority_color', store=True, help='Color de fondo basado en la prioridad') @api.depends('priority') def _compute_priority_color(self): """Compute background color based on priority""" for record in self: if record.priority == 'low': record.priority_color = '#d4edda' # Verde claro elif record.priority == 'medium': record.priority_color = '#fff3cd' # Amarillo claro elif record.priority == 'high': record.priority_color = '#f8d7da' # Rojo claro else: record.priority_color = '' # Sin color (transparente) @api.constrains('weight') def _check_weight(self): for record in self: if record.weight < 0 or record.weight > 100: raise ValidationError(_('Weight must be between 0 and 100')) @api.constrains('target_percentage') def _check_target_percentage(self): for record in self: if record.target_percentage < 0 or record.target_percentage > 100: raise ValidationError(_('Target percentage must be between 0 and 100')) @api.model_create_multi def create(self, vals_list): """Create indicators and create dynamic fields""" records = super().create(vals_list) # Create dynamic fields for all indicators for record in records: self._create_dynamic_field(record) return records def write(self, vals): """Update indicator and update dynamic field""" result = super().write(vals) # Update dynamic field for this indicator for record in self: self._update_dynamic_field(record, vals) return result def unlink(self): """Delete indicator and delete dynamic field""" # FIRST: Mark indicators as inactive to exclude them from views self.write({'active': False}) # SECOND: Update views to remove field references BEFORE deleting fields self.env['hr.efficiency']._update_views_with_dynamic_fields() # THEN: Delete associated dynamic fields and manual fields for record in self: dynamic_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', record.id) ]) if dynamic_field: dynamic_field.unlink() # Also remove the corresponding manual field in ir.model.fields efficiency_model = 'hr.efficiency' technical_name = self.env[efficiency_model]._get_indicator_field_name(record.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ('state', '=', 'manual'), ], limit=1) if imf: imf.unlink() result = super().unlink() return result def _create_dynamic_field(self, indicator): """Create a dynamic field for the indicator""" # Create dynamic field record for all indicators existing_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', indicator.id) ]) if not existing_field: # Create dynamic field field_name = indicator.name.lower().replace(' ', '_').replace('-', '_') field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u') field_name = field_name.replace('ñ', 'n') # Ensure it starts with a letter if not field_name[0].isalpha(): field_name = 'indicator_' + field_name # Determine widget based on indicator type widget_map = { 'percentage': 'percentage', 'hours': 'float_time', 'currency': 'monetary', 'number': 'float' } widget = widget_map.get(indicator.indicator_type, 'percentage') self.env['hr.efficiency.dynamic.field'].create({ 'name': field_name, 'label': indicator.name, 'indicator_id': indicator.id, 'sequence': indicator.sequence, 'active': indicator.active, 'show_in_list': True, 'show_in_form': True, 'show_in_search': False, 'widget': widget, 'decoration_success': indicator.color_threshold_green, 'decoration_warning': indicator.color_threshold_yellow, 'decoration_danger': indicator.color_threshold_red, 'priority_background_color': indicator.priority_color, }) # Ensure an ir.model.fields manual field exists (Studio-like) for ALL indicators efficiency_model = 'hr.efficiency' model_rec = self.env['ir.model']._get(efficiency_model) # Compute the technical field name like Studio (x_ prefix) technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ], limit=1) if not imf: self.env['ir.model.fields'].with_context(studio=True).create({ 'name': technical_name, 'model': efficiency_model, 'model_id': model_rec.id, 'ttype': 'float', 'field_description': indicator.name, 'help': indicator.description or indicator.name, 'state': 'manual', 'store': True, 'compute': False, }) else: # Keep label and help in sync update_vals = {} if imf.field_description != indicator.name: update_vals['field_description'] = indicator.name if imf.help != (indicator.description or indicator.name): update_vals['help'] = indicator.description or indicator.name if update_vals: imf.write(update_vals) # Update views to include the new field self.env['hr.efficiency']._update_views_with_dynamic_fields() # Recompute all indicators to populate the new stored field records = self.env['hr.efficiency'].search([]) if records: records._calculate_all_indicators() def _update_dynamic_field(self, indicator, vals=None): """Update the dynamic field for the indicator""" dynamic_field = self.env['hr.efficiency.dynamic.field'].search([ ('indicator_id', '=', indicator.id) ]) if dynamic_field: # Determine widget based on indicator type widget_map = { 'percentage': 'percentage', 'hours': 'float_time', 'currency': 'monetary', 'number': 'float' } widget = widget_map.get(indicator.indicator_type, 'percentage') dynamic_field.write({ 'label': indicator.name, 'active': indicator.active, 'widget': widget, 'decoration_success': indicator.color_threshold_green, 'decoration_warning': indicator.color_threshold_yellow, 'decoration_danger': indicator.color_threshold_red, 'priority_background_color': indicator.priority_color, }) # Sync corresponding ir.model.fields label and refresh views efficiency_model = 'hr.efficiency' technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name) imf = self.env['ir.model.fields'].search([ ('model', '=', efficiency_model), ('name', '=', technical_name), ], limit=1) if imf: update_vals = {} if imf.field_description != indicator.name: update_vals['field_description'] = indicator.name if imf.help != (indicator.description or indicator.name): update_vals['help'] = indicator.description or indicator.name if update_vals: imf.write(update_vals) # Update views when active status, sequence, or bulk operations change if 'active' in vals or 'sequence' in vals or len(self) > 1: try: self.env['hr.efficiency']._update_views_with_dynamic_fields() except Exception as e: # Log the error but don't let it rollback the transaction import logging _logger = logging.getLogger(__name__) _logger.error(f"Error updating views after indicator change: {e}") # Continue with the transaction even if view update fails def evaluate_formula(self, efficiency_data): """ Evaluate the formula using the provided efficiency data """ try: # Create a safe environment for formula evaluation safe_dict = { 'available_hours': efficiency_data.get('available_hours', 0), 'planned_hours': efficiency_data.get('planned_hours', 0), 'planned_billable_hours': efficiency_data.get('planned_billable_hours', 0), 'planned_non_billable_hours': efficiency_data.get('planned_non_billable_hours', 0), 'actual_billable_hours': efficiency_data.get('actual_billable_hours', 0), 'actual_non_billable_hours': efficiency_data.get('actual_non_billable_hours', 0), 'total_actual_hours': efficiency_data.get('total_actual_hours', 0), 'expected_hours_to_date': efficiency_data.get('expected_hours_to_date', 0), 'wage': efficiency_data.get('wage', 0), 'wage_overhead': efficiency_data.get('wage_overhead', 0), 'utilization_rate': efficiency_data.get('utilization_rate', 100.0), 'precio_por_hora': efficiency_data.get('precio_por_hora', 0), } # Add math functions for safety import math safe_dict.update({ 'abs': abs, 'min': min, 'max': max, 'round': round, }) # Evaluate the formula result = eval(self.formula, {"__builtins__": {}}, safe_dict) return result except Exception as e: _logger.error(f"Error evaluating formula for indicator {self.name}: {e}") return 0.0 def get_color_class(self, percentage): """ Return the CSS color class based on the percentage """ if percentage >= self.color_threshold_green: return 'text-success' elif percentage >= self.color_threshold_yellow: return 'text-warning' elif percentage >= self.color_threshold_red: return 'text-danger' else: return 'text-danger' # Below red threshold - critical def action_sync_from_xml(self): """ Acción manual para forzar sincronización desde XML Útil para debugging y actualizaciones manuales """ result = self.env['hr.efficiency.indicator'].sync_indicators_from_xml() # Mostrar mensaje al usuario message = f""" 🔄 Sincronización completada: 📊 Indicadores procesados: {result['indicators_processed']} 🆕 Campos creados: {result['fields_created']} ✅ Campos actualizados: {result['fields_updated']} 🔄 Registros recalculados: {result['records_recalculated']} """ return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Sincronización Completada', 'message': message, 'type': 'success', 'sticky': True, } } @api.model def sync_indicators_from_xml(self): """ Sincroniza los indicadores desde el XML con la base de datos Compara definiciones y actualiza campos dinámicos automáticamente """ import logging _logger = logging.getLogger(__name__) try: _logger.info("🔄 Iniciando sincronización de indicadores desde XML...") # Obtener todos los indicadores activos ordenados por secuencia active_indicators = self.search([('active', '=', True)], order='sequence') _logger.info(f"📊 Encontrados {len(active_indicators)} indicadores activos") # Contadores para el reporte fields_created = 0 fields_updated = 0 indicators_processed = 0 for indicator in active_indicators: try: # Verificar si el campo dinámico existe field_name = self.env['hr.efficiency']._get_indicator_field_name(indicator.name) # Buscar el campo manual existente existing_field = self.env['ir.model.fields'].search([ ('model', '=', 'hr.efficiency'), ('name', '=', field_name), ('state', '=', 'manual'), ], limit=1) if existing_field: # Actualizar campo existente si hay cambios update_vals = {} if existing_field.field_description != indicator.name: update_vals['field_description'] = indicator.name if existing_field.help != (indicator.description or indicator.name): update_vals['help'] = indicator.description or indicator.name if update_vals: existing_field.write(update_vals) fields_updated += 1 _logger.info(f"✅ Actualizado campo: {field_name}") else: # Crear nuevo campo dinámico self._create_dynamic_field(indicator) fields_created += 1 _logger.info(f"🆕 Creado campo: {field_name}") indicators_processed += 1 except Exception as e: _logger.error(f"❌ Error procesando indicador {indicator.name}: {e}") continue # Actualizar vistas con los campos dinámicos self.env['hr.efficiency']._update_views_with_dynamic_fields() # Recalcular todos los registros existentes efficiency_records = self.env['hr.efficiency'].search([('active', '=', True)]) if efficiency_records: efficiency_records._calculate_all_indicators() _logger.info(f"🔄 Recalculados {len(efficiency_records)} registros de eficiencia") # Reporte final _logger.info("=" * 60) _logger.info("📋 REPORTE DE SINCRONIZACIÓN COMPLETADO") _logger.info("=" * 60) _logger.info(f"📊 Indicadores procesados: {indicators_processed}") _logger.info(f"🆕 Campos creados: {fields_created}") _logger.info(f"✅ Campos actualizados: {fields_updated}") _logger.info(f"🔄 Registros recalculados: {len(efficiency_records)}") _logger.info("=" * 60) return { 'indicators_processed': indicators_processed, 'fields_created': fields_created, 'fields_updated': fields_updated, 'records_recalculated': len(efficiency_records), } except Exception as e: _logger.error(f"❌ Error crítico en sincronización: {e}") raise