| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427 |
- 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
|