|
|
@@ -31,6 +31,14 @@ class HrEfficiency(models.Model):
|
|
|
# Available hours (what employee should work)
|
|
|
available_hours = fields.Float('Available Hours', digits=(10, 2), help="Total hours employee should work in the month")
|
|
|
|
|
|
+ # Employee contract information
|
|
|
+ wage = fields.Float('Gross Salary', digits=(10, 2), aggregator='sum', help="Employee's gross salary from their contract at the time of calculation")
|
|
|
+ currency_id = fields.Many2one('res.currency', 'Currency', help="Currency for the wage field")
|
|
|
+
|
|
|
+ # Employee utilization and company overhead (stored at calculation time)
|
|
|
+ utilization_rate = fields.Float('Utilization Rate (%)', digits=(5, 2), default=100.0, aggregator='avg', help="Employee's utilization rate at the time of calculation")
|
|
|
+ overhead = fields.Float('Company Overhead (%)', digits=(5, 2), default=40.0, aggregator='avg', help="Company's overhead percentage at the time of calculation")
|
|
|
+
|
|
|
# Planned hours (what was planned)
|
|
|
planned_hours = fields.Float('Planned Hours', digits=(10, 2), help="Total hours planned for the month")
|
|
|
planned_billable_hours = fields.Float('Planned Billable Hours', digits=(10, 2), help="Hours planned on billable projects")
|
|
|
@@ -40,16 +48,19 @@ class HrEfficiency(models.Model):
|
|
|
actual_billable_hours = fields.Float('Actual Billable Hours', digits=(10, 2), help="Hours actually worked on billable projects")
|
|
|
actual_non_billable_hours = fields.Float('Actual Non-Billable Hours', digits=(10, 2), help="Hours actually worked on non-billable projects")
|
|
|
|
|
|
- # Computed fields
|
|
|
- total_actual_hours = fields.Float('Total Actual Hours', compute='_compute_total_actual_hours', store=True)
|
|
|
- expected_hours_to_date = fields.Float('Expected Hours to Date', compute='_compute_expected_hours_to_date', store=True, help='Hours that should be registered based on planning until current date')
|
|
|
+ # Calculated fields (stored for performance)
|
|
|
+ total_actual_hours = fields.Float('Total Actual Hours', digits=(10, 2), help="Total actual hours (billable + non-billable)")
|
|
|
+ expected_hours_to_date = fields.Float('Expected Hours to Date', digits=(10, 2), help='Hours that should be registered based on planning until current date')
|
|
|
+
|
|
|
+ # Precio por Hora (stored field)
|
|
|
+ precio_por_hora = fields.Float('Precio por Hora', digits=(10, 2), help='Precio que cobramos al cliente por hora (costo + overhead + 30% rentabilidad)', aggregator='avg')
|
|
|
|
|
|
# Dynamic indicator fields (will be created automatically)
|
|
|
# These fields are managed dynamically based on hr.efficiency.indicator records
|
|
|
|
|
|
- # Overall efficiency (always present)
|
|
|
- overall_efficiency = fields.Float('Overall Efficiency (%)', compute='_compute_indicators', store=True, help='Overall efficiency based on configured indicators')
|
|
|
- overall_efficiency_display = fields.Char('Overall Efficiency Display', compute='_compute_overall_efficiency_display', store=True, help='Overall efficiency formatted for display with badge widget')
|
|
|
+ # Overall efficiency (always present) - Now stored fields calculated on save
|
|
|
+ overall_efficiency = fields.Float('Overall Efficiency (%)', digits=(5, 2), help='Overall efficiency based on configured indicators')
|
|
|
+ overall_efficiency_display = fields.Char('Overall Efficiency Display', help='Overall efficiency formatted for display with badge widget')
|
|
|
|
|
|
display_name = fields.Char('Display Name', compute='_compute_display_name', store=True)
|
|
|
|
|
|
@@ -71,72 +82,102 @@ class HrEfficiency(models.Model):
|
|
|
else:
|
|
|
record.date = False
|
|
|
|
|
|
- @api.depends('actual_billable_hours', 'actual_non_billable_hours')
|
|
|
- def _compute_total_actual_hours(self):
|
|
|
- for record in self:
|
|
|
- record.total_actual_hours = record.actual_billable_hours + record.actual_non_billable_hours
|
|
|
|
|
|
- @api.depends('available_hours', 'month_year', 'employee_id', 'employee_id.contract_id')
|
|
|
- def _compute_expected_hours_to_date(self):
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ def _calculate_expected_hours_to_date(self, record):
|
|
|
"""
|
|
|
Calculate expected hours to date based on available hours and working days
|
|
|
"""
|
|
|
- for record in self:
|
|
|
- if not record.month_year or not record.available_hours or not record.employee_id.contract_id:
|
|
|
- record.expected_hours_to_date = 0.0
|
|
|
- continue
|
|
|
+ if not record.month_year or not record.available_hours or not record.employee_id.contract_id:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Parse month_year (format: YYYY-MM)
|
|
|
+ year, month = record.month_year.split('-')
|
|
|
+ start_date = date(int(year), int(month), 1)
|
|
|
+ end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
|
|
|
|
|
|
- try:
|
|
|
- # Parse month_year (format: YYYY-MM)
|
|
|
- year, month = record.month_year.split('-')
|
|
|
- start_date = date(int(year), int(month), 1)
|
|
|
- end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
|
|
|
-
|
|
|
- # Get current date
|
|
|
- current_date = date.today()
|
|
|
-
|
|
|
- # If current date is outside the month, use end of month
|
|
|
- if current_date > end_date:
|
|
|
- calculation_date = end_date
|
|
|
- elif current_date < start_date:
|
|
|
- calculation_date = start_date
|
|
|
- else:
|
|
|
- calculation_date = current_date
|
|
|
+ # Get current date
|
|
|
+ current_date = date.today()
|
|
|
+
|
|
|
+ # If current date is outside the month, use end of month
|
|
|
+ if current_date > end_date:
|
|
|
+ calculation_date = end_date
|
|
|
+ elif current_date < start_date:
|
|
|
+ calculation_date = start_date
|
|
|
+ else:
|
|
|
+ calculation_date = current_date
|
|
|
+
|
|
|
+ # Calculate working days in the month
|
|
|
+ total_working_days = self._count_working_days(start_date, end_date, record.employee_id)
|
|
|
+
|
|
|
+ # Calculate working days until current date
|
|
|
+ working_days_until_date = self._count_working_days(start_date, calculation_date, record.employee_id)
|
|
|
+
|
|
|
+ if total_working_days > 0:
|
|
|
+ # Calculate expected hours based on available hours (what employee should work)
|
|
|
+ expected_hours = (record.available_hours / total_working_days) * working_days_until_date
|
|
|
|
|
|
- # Calculate working days in the month
|
|
|
- total_working_days = self._count_working_days(start_date, end_date, record.employee_id)
|
|
|
+ # Ensure we don't exceed available hours for the month
|
|
|
+ expected_hours = min(expected_hours, record.available_hours)
|
|
|
|
|
|
- # Calculate working days until current date
|
|
|
- working_days_until_date = self._count_working_days(start_date, calculation_date, record.employee_id)
|
|
|
+ return float_round(expected_hours, 2)
|
|
|
+ else:
|
|
|
+ return 0.0
|
|
|
|
|
|
- if total_working_days > 0:
|
|
|
- # Calculate expected hours based on available hours (what employee should work)
|
|
|
- expected_hours = (record.available_hours / total_working_days) * working_days_until_date
|
|
|
-
|
|
|
- # Ensure we don't exceed available hours for the month
|
|
|
- expected_hours = min(expected_hours, record.available_hours)
|
|
|
-
|
|
|
- record.expected_hours_to_date = float_round(expected_hours, 2)
|
|
|
- else:
|
|
|
- record.expected_hours_to_date = 0.0
|
|
|
-
|
|
|
- except (ValueError, AttributeError) as e:
|
|
|
- record.expected_hours_to_date = 0.0
|
|
|
-
|
|
|
+ except (ValueError, AttributeError) as e:
|
|
|
+ return 0.0
|
|
|
|
|
|
- @api.depends('overall_efficiency')
|
|
|
- def _compute_overall_efficiency_display(self):
|
|
|
+ def _calculate_precio_por_hora(self, record):
|
|
|
"""
|
|
|
- Compute display value for overall efficiency that works with badge widget
|
|
|
+ Calculate precio por hora: (wage / available_hours) * (1 + overhead/100) * 1.30
|
|
|
"""
|
|
|
- for record in self:
|
|
|
- if record.overall_efficiency == 0:
|
|
|
- record.overall_efficiency_display = '0.00'
|
|
|
+ try:
|
|
|
+ wage = getattr(record, 'wage', 0.0) or 0.0
|
|
|
+ available_hours = getattr(record, 'available_hours', 0.0) or 0.0
|
|
|
+ overhead = getattr(record, 'overhead', 40.0) or 40.0
|
|
|
+
|
|
|
+ if available_hours > 0:
|
|
|
+ # Calculate cost per hour
|
|
|
+ cost_per_hour = wage / available_hours
|
|
|
+ # Apply overhead and 30% profit margin
|
|
|
+ precio_por_hora = cost_per_hour * (1 + (overhead / 100)) * 1.30
|
|
|
+ return float_round(precio_por_hora, 2)
|
|
|
else:
|
|
|
- record.overall_efficiency_display = f"{record.overall_efficiency:.2f}"
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ except (ValueError, AttributeError, ZeroDivisionError) as e:
|
|
|
+ return 0.0
|
|
|
|
|
|
- @api.depends('available_hours', 'planned_hours', 'total_actual_hours')
|
|
|
- def _compute_indicators(self):
|
|
|
+
|
|
|
+ def _calculate_all_indicators(self):
|
|
|
+ """
|
|
|
+ Calculate all indicators and overall efficiency for stored fields
|
|
|
+ This method is called on create/write instead of using computed fields
|
|
|
+ """
|
|
|
+ # Prepare values to update without triggering write recursively
|
|
|
+ values_to_update = {}
|
|
|
+
|
|
|
+ # Pre-fetch necessary fields to avoid CacheMiss during iteration
|
|
|
+ fields_to_load = [
|
|
|
+ 'available_hours', 'planned_hours', 'planned_billable_hours',
|
|
|
+ 'planned_non_billable_hours', 'actual_billable_hours',
|
|
|
+ 'actual_non_billable_hours', 'total_actual_hours',
|
|
|
+ 'expected_hours_to_date', 'wage', 'utilization_rate', 'overhead',
|
|
|
+ 'precio_por_hora', 'employee_id', 'company_id', 'month_year', 'active',
|
|
|
+ ]
|
|
|
+
|
|
|
+ # Load fields for all records in 'self' with error handling
|
|
|
+ try:
|
|
|
+ self.read(fields_to_load)
|
|
|
+ except Exception as e:
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.warning(f"Error loading fields: {e}")
|
|
|
+ # Continue with empty cache if needed
|
|
|
+
|
|
|
for record in self:
|
|
|
# Get all manual fields for this model
|
|
|
manual_fields = self.env['ir.model.fields'].search([
|
|
|
@@ -145,39 +186,117 @@ class HrEfficiency(models.Model):
|
|
|
('ttype', '=', 'float')
|
|
|
])
|
|
|
|
|
|
- # Prepare efficiency data
|
|
|
+ # Prepare efficiency data with safe field access
|
|
|
efficiency_data = {
|
|
|
- 'available_hours': record.available_hours,
|
|
|
- 'planned_hours': record.planned_hours,
|
|
|
- 'planned_billable_hours': record.planned_billable_hours,
|
|
|
- 'planned_non_billable_hours': record.planned_non_billable_hours,
|
|
|
- 'actual_billable_hours': record.actual_billable_hours,
|
|
|
- 'actual_non_billable_hours': record.actual_non_billable_hours,
|
|
|
- 'expected_hours_to_date': record.expected_hours_to_date,
|
|
|
+ 'available_hours': getattr(record, 'available_hours', 0.0) or 0.0,
|
|
|
+ 'planned_hours': getattr(record, 'planned_hours', 0.0) or 0.0,
|
|
|
+ 'planned_billable_hours': getattr(record, 'planned_billable_hours', 0.0) or 0.0,
|
|
|
+ 'planned_non_billable_hours': getattr(record, 'planned_non_billable_hours', 0.0) or 0.0,
|
|
|
+ 'actual_billable_hours': getattr(record, 'actual_billable_hours', 0.0) or 0.0,
|
|
|
+ 'actual_non_billable_hours': getattr(record, 'actual_non_billable_hours', 0.0) or 0.0,
|
|
|
+ 'total_actual_hours': getattr(record, 'total_actual_hours', 0.0) or 0.0,
|
|
|
+ 'expected_hours_to_date': getattr(record, 'expected_hours_to_date', 0.0) or 0.0,
|
|
|
+ 'wage': getattr(record, 'wage', 0.0) or 0.0,
|
|
|
+ 'utilization_rate': getattr(record, 'utilization_rate', 100.0) or 100.0,
|
|
|
+ 'overhead': getattr(record, 'overhead', 40.0) or 40.0,
|
|
|
+ 'precio_por_hora': getattr(record, 'precio_por_hora', 0.0) or 0.0,
|
|
|
}
|
|
|
|
|
|
- # Calculate all indicators dynamically
|
|
|
- for field in manual_fields:
|
|
|
- # Find the corresponding indicator
|
|
|
- indicator = self.env['hr.efficiency.indicator'].search([
|
|
|
- ('name', 'ilike', field.field_description)
|
|
|
- ], limit=1)
|
|
|
+ # STEP 1: Calculate total_actual_hours FIRST (needed for indicators)
|
|
|
+ try:
|
|
|
+ total_actual_hours = getattr(record, 'actual_billable_hours', 0.0) + getattr(record, 'actual_non_billable_hours', 0.0)
|
|
|
+ except Exception as e:
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.error(f"Error calculating total_actual_hours for record {record.id}: {e}")
|
|
|
+ total_actual_hours = 0.0
|
|
|
+ record_values = {'total_actual_hours': float_round(total_actual_hours, 2)}
|
|
|
+
|
|
|
+ # STEP 2: Calculate expected_hours_to_date
|
|
|
+ expected_hours = self._calculate_expected_hours_to_date(record)
|
|
|
+ record_values['expected_hours_to_date'] = expected_hours
|
|
|
+
|
|
|
+ # STEP 3: Calculate precio_por_hora (needed for profitability indicators)
|
|
|
+ precio_por_hora = self._calculate_precio_por_hora(record)
|
|
|
+ record_values['precio_por_hora'] = precio_por_hora
|
|
|
+
|
|
|
+ # STEP 4: Update efficiency_data with ALL calculated base fields
|
|
|
+ efficiency_data.update({
|
|
|
+ 'total_actual_hours': total_actual_hours,
|
|
|
+ 'expected_hours_to_date': expected_hours,
|
|
|
+ 'precio_por_hora': precio_por_hora,
|
|
|
+ })
|
|
|
+
|
|
|
+ # STEP 5: Calculate all indicators dynamically (in sequence order)
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
+
|
|
|
+ for indicator in active_indicators:
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
|
|
|
- if indicator:
|
|
|
+ # Check if the field exists in the record
|
|
|
+ if field_name in record._fields:
|
|
|
# Calculate indicator value using the indicator formula
|
|
|
indicator_value = indicator.evaluate_formula(efficiency_data)
|
|
|
|
|
|
- # Set the value on the record - handle both computed and stored fields
|
|
|
- if field.name in record._fields:
|
|
|
- record[field.name] = float_round(indicator_value, 2)
|
|
|
- elif hasattr(record, field.name):
|
|
|
- setattr(record, field.name, float_round(indicator_value, 2))
|
|
|
+ # Store the value to update later
|
|
|
+ record_values[field_name] = float_round(indicator_value, 2)
|
|
|
+
|
|
|
+ # Calculate overall efficiency
|
|
|
+ overall_efficiency = self._calculate_overall_efficiency(record)
|
|
|
+ record_values['overall_efficiency'] = overall_efficiency
|
|
|
+
|
|
|
+ # Calculate overall efficiency display
|
|
|
+ if overall_efficiency == 0:
|
|
|
+ record_values['overall_efficiency_display'] = '0.00'
|
|
|
+ else:
|
|
|
+ record_values['overall_efficiency_display'] = f"{overall_efficiency:.2f}"
|
|
|
|
|
|
- # Overall efficiency based on configured indicators
|
|
|
- record.overall_efficiency = self._calculate_overall_efficiency(record)
|
|
|
+ # Store values for this record
|
|
|
+ values_to_update[record.id] = record_values
|
|
|
+
|
|
|
+ # Update all records at once to avoid recursion
|
|
|
+ for record_id, values in values_to_update.items():
|
|
|
+ record = self.browse(record_id)
|
|
|
+ # Use direct SQL update to avoid triggering write method
|
|
|
+ if values:
|
|
|
+ record.env.cr.execute(
|
|
|
+ "UPDATE hr_efficiency SET " +
|
|
|
+ ", ".join([f"{key} = %s" for key in values.keys()]) +
|
|
|
+ " WHERE id = %s",
|
|
|
+ list(values.values()) + [record_id]
|
|
|
+ )
|
|
|
|
|
|
- # Update stored manual fields after computing
|
|
|
- self._update_stored_manual_fields()
|
|
|
+ # Update aggregation fields after calculating indicators
|
|
|
+ self._update_aggregation_fields()
|
|
|
+
|
|
|
+ def _update_aggregation_fields(self):
|
|
|
+ """
|
|
|
+ Actualiza los campos de agregación con los valores de los indicadores dinámicos
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ for record in self:
|
|
|
+ # Obtener todos los indicadores activos (en orden de secuencia)
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
+
|
|
|
+ for indicator in active_indicators:
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
+ agg_field_name = f"{field_name}_agg"
|
|
|
+
|
|
|
+ # Verificar si el campo de agregación existe
|
|
|
+ if hasattr(record, agg_field_name):
|
|
|
+ # Obtener el valor del indicador dinámico
|
|
|
+ indicator_value = getattr(record, field_name, 0.0) or 0.0
|
|
|
+
|
|
|
+ # Actualizar el campo de agregación usando SQL directo
|
|
|
+ record.env.cr.execute(
|
|
|
+ "UPDATE hr_efficiency SET %s = %%s WHERE id = %%s" % agg_field_name,
|
|
|
+ [indicator_value, record.id]
|
|
|
+ )
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.warning(f"Error updating aggregation fields: {str(e)}")
|
|
|
|
|
|
def _update_stored_manual_fields(self):
|
|
|
"""Update stored manual fields with computed values"""
|
|
|
@@ -190,15 +309,19 @@ class HrEfficiency(models.Model):
|
|
|
('store', '=', True),
|
|
|
], order='id')
|
|
|
|
|
|
- # Prepare efficiency data
|
|
|
+ # Prepare efficiency data with safe field access
|
|
|
efficiency_data = {
|
|
|
- 'available_hours': record.available_hours,
|
|
|
- 'planned_hours': record.planned_hours,
|
|
|
- 'planned_billable_hours': record.planned_billable_hours,
|
|
|
- 'planned_non_billable_hours': record.planned_non_billable_hours,
|
|
|
- 'actual_billable_hours': record.actual_billable_hours,
|
|
|
- 'actual_non_billable_hours': record.actual_non_billable_hours,
|
|
|
- 'expected_hours_to_date': record.expected_hours_to_date,
|
|
|
+ 'available_hours': record.available_hours or 0.0,
|
|
|
+ 'planned_hours': record.planned_hours or 0.0,
|
|
|
+ 'planned_billable_hours': record.planned_billable_hours or 0.0,
|
|
|
+ 'planned_non_billable_hours': record.planned_non_billable_hours or 0.0,
|
|
|
+ 'actual_billable_hours': record.actual_billable_hours or 0.0,
|
|
|
+ 'actual_non_billable_hours': record.actual_non_billable_hours or 0.0,
|
|
|
+ 'total_actual_hours': record.total_actual_hours or 0.0,
|
|
|
+ 'expected_hours_to_date': record.expected_hours_to_date or 0.0,
|
|
|
+ 'wage': record.wage or 0.0,
|
|
|
+ 'utilization_rate': record.utilization_rate or 100.0,
|
|
|
+ 'overhead': record.overhead or 40.0,
|
|
|
}
|
|
|
|
|
|
# Calculate and update stored manual fields
|
|
|
@@ -220,22 +343,34 @@ class HrEfficiency(models.Model):
|
|
|
"""Recompute all indicator fields for all records"""
|
|
|
records = self.search([])
|
|
|
if records:
|
|
|
- records._compute_indicators()
|
|
|
+ records._calculate_all_indicators()
|
|
|
|
|
|
def _get_indicator_field_name(self, indicator_name):
|
|
|
"""
|
|
|
Convert indicator name to valid field name
|
|
|
"""
|
|
|
+ import re
|
|
|
+
|
|
|
# Remove special characters and convert to lowercase
|
|
|
field_name = indicator_name.lower()
|
|
|
- field_name = field_name.replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '')
|
|
|
- field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
|
|
|
- field_name = field_name.replace('ñ', 'n')
|
|
|
+
|
|
|
+ # Replace spaces, hyphens, and other special characters with underscores
|
|
|
+ field_name = re.sub(r'[^a-z0-9_]', '_', field_name)
|
|
|
+
|
|
|
+ # Remove multiple consecutive underscores
|
|
|
+ field_name = re.sub(r'_+', '_', field_name)
|
|
|
+
|
|
|
+ # Remove leading and trailing underscores
|
|
|
+ field_name = field_name.strip('_')
|
|
|
|
|
|
# Ensure it starts with x_ for manual fields
|
|
|
if not field_name.startswith('x_'):
|
|
|
field_name = 'x_' + field_name
|
|
|
|
|
|
+ # Ensure it doesn't exceed 63 characters (PostgreSQL limit)
|
|
|
+ if len(field_name) > 63:
|
|
|
+ field_name = field_name[:63]
|
|
|
+
|
|
|
return field_name
|
|
|
|
|
|
|
|
|
@@ -250,13 +385,7 @@ class HrEfficiency(models.Model):
|
|
|
|
|
|
for indicator in indicators:
|
|
|
# Check if field already exists in ir.model.fields
|
|
|
- field_name = indicator.name.lower().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 x_ for manual fields
|
|
|
- if not field_name.startswith('x_'):
|
|
|
- field_name = 'x_' + field_name
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
|
|
|
existing_field = self.env['ir.model.fields'].search([
|
|
|
('model', '=', 'hr.efficiency'),
|
|
|
@@ -300,13 +429,17 @@ class HrEfficiency(models.Model):
|
|
|
valid_indicators = 0
|
|
|
|
|
|
efficiency_data = {
|
|
|
- 'available_hours': record.available_hours,
|
|
|
- 'planned_hours': record.planned_hours,
|
|
|
- 'planned_billable_hours': record.planned_billable_hours,
|
|
|
- 'planned_non_billable_hours': record.planned_non_billable_hours,
|
|
|
- 'actual_billable_hours': record.actual_billable_hours,
|
|
|
- 'actual_non_billable_hours': record.actual_non_billable_hours,
|
|
|
- 'expected_hours_to_date': record.expected_hours_to_date,
|
|
|
+ 'available_hours': getattr(record, 'available_hours', 0.0) or 0.0,
|
|
|
+ 'planned_hours': getattr(record, 'planned_hours', 0.0) or 0.0,
|
|
|
+ 'planned_billable_hours': getattr(record, 'planned_billable_hours', 0.0) or 0.0,
|
|
|
+ 'planned_non_billable_hours': getattr(record, 'planned_non_billable_hours', 0.0) or 0.0,
|
|
|
+ 'actual_billable_hours': getattr(record, 'actual_billable_hours', 0.0) or 0.0,
|
|
|
+ 'actual_non_billable_hours': getattr(record, 'actual_non_billable_hours', 0.0) or 0.0,
|
|
|
+ 'total_actual_hours': getattr(record, 'total_actual_hours', 0.0) or 0.0,
|
|
|
+ 'expected_hours_to_date': getattr(record, 'expected_hours_to_date', 0.0) or 0.0,
|
|
|
+ 'wage': getattr(record, 'wage', 0.0) or 0.0,
|
|
|
+ 'utilization_rate': getattr(record, 'utilization_rate', 100.0) or 100.0,
|
|
|
+ 'overhead': getattr(record, 'overhead', 40.0) or 40.0,
|
|
|
}
|
|
|
|
|
|
for indicator in indicators:
|
|
|
@@ -355,6 +488,18 @@ class HrEfficiency(models.Model):
|
|
|
# Calculate actual hours
|
|
|
actual_billable_hours, actual_non_billable_hours = self._calculate_actual_hours(employee, start_date, end_date)
|
|
|
|
|
|
+ # Calculate wage and currency from employee's contract
|
|
|
+ # Use current date for wage calculation (when the record is being created/calculated)
|
|
|
+ wage_date = date.today()
|
|
|
+ wage, currency_id = self._get_employee_wage_and_currency(employee, wage_date)
|
|
|
+
|
|
|
+ # Get employee's utilization rate and company's overhead at calculation time
|
|
|
+ utilization_rate = employee.utilization_rate or 100.0
|
|
|
+ overhead = employee.company_id.overhead or 40.0
|
|
|
+
|
|
|
+ # Apply utilization_rate to actual_billable_hours to reflect real billable capacity
|
|
|
+ adjusted_actual_billable_hours = actual_billable_hours * (utilization_rate / 100)
|
|
|
+
|
|
|
return {
|
|
|
'month_year': month_year,
|
|
|
'employee_id': employee.id,
|
|
|
@@ -363,10 +508,54 @@ class HrEfficiency(models.Model):
|
|
|
'planned_hours': planned_hours,
|
|
|
'planned_billable_hours': planned_billable_hours,
|
|
|
'planned_non_billable_hours': planned_non_billable_hours,
|
|
|
- 'actual_billable_hours': actual_billable_hours,
|
|
|
+ 'actual_billable_hours': adjusted_actual_billable_hours,
|
|
|
'actual_non_billable_hours': actual_non_billable_hours,
|
|
|
+ 'wage': wage,
|
|
|
+ 'currency_id': currency_id,
|
|
|
+ 'utilization_rate': utilization_rate,
|
|
|
+ 'overhead': overhead,
|
|
|
}
|
|
|
|
|
|
+ @api.model
|
|
|
+ def _get_employee_wage_and_currency(self, employee, target_date):
|
|
|
+ """
|
|
|
+ Get employee's wage and currency from their contract for a specific date
|
|
|
+ Always take the contract that is active on the target date
|
|
|
+ If no active contract on that date, return 0 with company currency
|
|
|
+ """
|
|
|
+ if not employee:
|
|
|
+ return 0.0, self.env.company.currency_id.id
|
|
|
+
|
|
|
+ # Get all contracts for the employee
|
|
|
+ contracts = self.env['hr.contract'].search([
|
|
|
+ ('employee_id', '=', employee.id),
|
|
|
+ ('state', '=', 'open')
|
|
|
+ ], order='date_start desc')
|
|
|
+
|
|
|
+ if not contracts:
|
|
|
+ return 0.0, self.env.company.currency_id.id
|
|
|
+
|
|
|
+ # Find the contract that is active on the target date
|
|
|
+ for contract in contracts:
|
|
|
+ contract_start = contract.date_start
|
|
|
+
|
|
|
+ # Check if contract is active on target date
|
|
|
+ if contract.date_end:
|
|
|
+ # Contract has end date
|
|
|
+ if contract_start <= target_date <= contract.date_end:
|
|
|
+ wage = contract.wage or 0.0
|
|
|
+ currency_id = contract.currency_id.id if contract.currency_id else self.env.company.currency_id.id
|
|
|
+ return wage, currency_id
|
|
|
+ else:
|
|
|
+ # Contract has no end date, it's active for any date after start
|
|
|
+ if contract_start <= target_date:
|
|
|
+ wage = contract.wage or 0.0
|
|
|
+ currency_id = contract.currency_id.id if contract.currency_id else self.env.company.currency_id.id
|
|
|
+ return wage, currency_id
|
|
|
+
|
|
|
+ # If no contract is active on the target date, return defaults
|
|
|
+ return 0.0, self.env.company.currency_id.id
|
|
|
+
|
|
|
def _calculate_available_hours(self, employee, start_date, end_date):
|
|
|
"""
|
|
|
Calculate available hours considering holidays and time off
|
|
|
@@ -553,7 +742,7 @@ class HrEfficiency(models.Model):
|
|
|
# If no changes in basic fields, check dynamic indicators
|
|
|
if not has_changes:
|
|
|
# Get all active indicators
|
|
|
- active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)])
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
|
|
|
for indicator in active_indicators:
|
|
|
field_name = self._get_indicator_field_name(indicator.name)
|
|
|
@@ -591,6 +780,12 @@ class HrEfficiency(models.Model):
|
|
|
new_record = self.create(efficiency_data)
|
|
|
created_records.append(new_record)
|
|
|
|
|
|
+ # Calculate indicators for all newly created records
|
|
|
+ if created_records:
|
|
|
+ # Convert list to recordset
|
|
|
+ created_recordset = self.browse([record.id for record in created_records])
|
|
|
+ created_recordset._calculate_all_indicators()
|
|
|
+
|
|
|
return {
|
|
|
'created': len(created_records),
|
|
|
'updated': 0, # No longer updating existing records
|
|
|
@@ -643,19 +838,119 @@ class HrEfficiency(models.Model):
|
|
|
_logger.info("Running post-install hook for hr_efficiency module")
|
|
|
|
|
|
# Ensure all active indicators have manual fields
|
|
|
- active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)])
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
for indicator in active_indicators:
|
|
|
self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
|
|
|
|
|
|
# Update views with dynamic fields
|
|
|
self._update_views_with_dynamic_fields()
|
|
|
|
|
|
+ # Apply default values to existing records
|
|
|
+ self._apply_default_values_to_existing_records()
|
|
|
+
|
|
|
_logger.info(f"Post-install hook completed. Processed {len(active_indicators)} indicators")
|
|
|
|
|
|
except Exception as e:
|
|
|
_logger.error(f"Error in post-install hook: {str(e)}")
|
|
|
raise
|
|
|
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def create(self, vals_list):
|
|
|
+ """
|
|
|
+ Override create to calculate indicators when records are created
|
|
|
+ """
|
|
|
+ records = super().create(vals_list)
|
|
|
+
|
|
|
+ # Calculate indicators for newly created records
|
|
|
+ if records:
|
|
|
+ records._calculate_all_indicators()
|
|
|
+
|
|
|
+ return records
|
|
|
+
|
|
|
+ def write(self, vals):
|
|
|
+ """
|
|
|
+ Override write to recalculate indicators when records are updated
|
|
|
+ """
|
|
|
+ result = super().write(vals)
|
|
|
+
|
|
|
+ # Recalculate indicators for updated records
|
|
|
+ self._calculate_all_indicators()
|
|
|
+
|
|
|
+ return result
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _register_hook(self):
|
|
|
+ """
|
|
|
+ Called when the registry is loaded.
|
|
|
+ Update views with dynamic fields on every module load/restart.
|
|
|
+ """
|
|
|
+ super()._register_hook()
|
|
|
+ try:
|
|
|
+ # Ensure aggregation fields exist for dynamic indicators
|
|
|
+ self._ensure_aggregation_fields_exist()
|
|
|
+ # Update views with current dynamic fields on every module load
|
|
|
+ self._update_views_with_dynamic_fields()
|
|
|
+ except Exception as e:
|
|
|
+ # Log error but don't prevent module loading
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.warning(f"Could not update dynamic views on module load: {str(e)}")
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _ensure_aggregation_fields_exist(self):
|
|
|
+ """
|
|
|
+ Asegura que existan campos de agregación para todos los indicadores activos
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
+
|
|
|
+ for indicator in active_indicators:
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
+ agg_field_name = f"{field_name}_agg"
|
|
|
+
|
|
|
+ # Verificar si el campo de agregación ya existe en ir.model.fields
|
|
|
+ agg_field_record = self.env['ir.model.fields'].search([
|
|
|
+ ('model', '=', 'hr.efficiency'),
|
|
|
+ ('name', '=', agg_field_name)
|
|
|
+ ], limit=1)
|
|
|
+
|
|
|
+ if not agg_field_record:
|
|
|
+ # Determinar tipo de agregación basado en el tipo de indicador
|
|
|
+ agg_type = 'avg' if indicator.indicator_type == 'percentage' else 'sum'
|
|
|
+
|
|
|
+ # Obtener el model_id para hr.efficiency
|
|
|
+ model_record = self.env['ir.model'].search([('model', '=', 'hr.efficiency')], limit=1)
|
|
|
+
|
|
|
+ if model_record:
|
|
|
+ # Crear el campo de agregación
|
|
|
+ self.env['ir.model.fields'].create({
|
|
|
+ 'name': agg_field_name,
|
|
|
+ 'model_id': model_record.id,
|
|
|
+ 'field_description': f'{indicator.name} (Agregación)',
|
|
|
+ 'ttype': 'float',
|
|
|
+ 'state': 'manual',
|
|
|
+ 'store': True,
|
|
|
+ 'help': f'Campo de agregación para {indicator.name}'
|
|
|
+ })
|
|
|
+
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.info(f"Created aggregation field: {agg_field_name} for indicator: {indicator.name}")
|
|
|
+ else:
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.warning(f"Model hr.efficiency not found for aggregation field: {agg_field_name}")
|
|
|
+
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.info(f"Created aggregation field: {agg_field_name} for indicator: {indicator.name}")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+ _logger.warning(f"Error creating aggregation fields: {str(e)}")
|
|
|
+
|
|
|
@api.model
|
|
|
def _update_views_with_dynamic_fields(self):
|
|
|
"""
|
|
|
@@ -665,8 +960,8 @@ class HrEfficiency(models.Model):
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
try:
|
|
|
- # Get active indicators ordered by weight (descending)
|
|
|
- active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='weight desc')
|
|
|
+ # Get active indicators ordered by sequence (consistent with other parts)
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
|
|
|
fields_to_display = []
|
|
|
|
|
|
@@ -704,10 +999,23 @@ class HrEfficiency(models.Model):
|
|
|
# Build dynamic fields XML for list view
|
|
|
dynamic_fields_xml = ''
|
|
|
for field_info in fields_to_display:
|
|
|
- field_xml = f'<field name="{field_info["name"]}" widget="percentage" optional="hide"'
|
|
|
+ # Determine widget based on indicator type
|
|
|
+ indicator = field_info['indicator']
|
|
|
+ widget_map = {
|
|
|
+ 'percentage': 'percentage', # Changed back to 'percentage' to show % symbol
|
|
|
+ 'hours': 'float_time',
|
|
|
+ 'currency': 'monetary',
|
|
|
+ 'number': 'float'
|
|
|
+ }
|
|
|
+ widget = widget_map.get(indicator.indicator_type, 'float') if indicator else 'float'
|
|
|
+
|
|
|
+ field_xml = f'<field name="{field_info["name"]}" widget="{widget}" optional="show"'
|
|
|
+
|
|
|
+ # Add field name as string (tooltip will be shown automatically from field description)
|
|
|
+ if indicator:
|
|
|
+ field_xml += f' string="{indicator.name}"'
|
|
|
|
|
|
# Add decorations based on indicator thresholds
|
|
|
- indicator = field_info['indicator']
|
|
|
if indicator:
|
|
|
# Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
|
|
|
field_xml += f' decoration-success="{field_info["name"]} >= 0.9"'
|
|
|
@@ -717,12 +1025,28 @@ class HrEfficiency(models.Model):
|
|
|
|
|
|
field_xml += '/>'
|
|
|
dynamic_fields_xml += field_xml
|
|
|
+
|
|
|
+ # Add aggregation field for this indicator
|
|
|
+ agg_field_name = f"{field_info['name']}_agg"
|
|
|
+ agg_type = 'avg' if indicator.indicator_type == 'percentage' else 'sum'
|
|
|
+ agg_label = f"Total {indicator.name}" if agg_type == 'sum' else f"Average {indicator.name}"
|
|
|
+
|
|
|
+ agg_field_xml = f'<field name="{agg_field_name}" {agg_type}="{agg_label}" optional="hide" invisible="1"/>'
|
|
|
+ dynamic_fields_xml += agg_field_xml
|
|
|
|
|
|
# Update inherited list view
|
|
|
inherited_list_view = self.env.ref('hr_efficiency.view_hr_efficiency_list_inherited', raise_if_not_found=False)
|
|
|
if inherited_list_view:
|
|
|
- new_arch = f"""
|
|
|
+ if dynamic_fields_xml:
|
|
|
+ new_arch = f"""
|
|
|
<xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">{dynamic_fields_xml}</xpath>
|
|
|
+"""
|
|
|
+ else:
|
|
|
+ # If no dynamic fields, remove any existing dynamic fields from the view
|
|
|
+ new_arch = """
|
|
|
+<xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">
|
|
|
+ <!-- No dynamic fields to display -->
|
|
|
+</xpath>
|
|
|
"""
|
|
|
inherited_list_view.write({'arch': new_arch})
|
|
|
_logger.info(f"Updated inherited list view with {len(fields_to_display)} dynamic fields")
|
|
|
@@ -730,10 +1054,25 @@ class HrEfficiency(models.Model):
|
|
|
# Build dynamic fields XML for form view
|
|
|
form_dynamic_fields_xml = ''
|
|
|
for field_info in fields_to_display:
|
|
|
- field_xml = f'<field name="{field_info["name"]}" widget="badge"'
|
|
|
+ # Determine widget based on indicator type
|
|
|
+ indicator = field_info['indicator']
|
|
|
+ widget_map = {
|
|
|
+ 'percentage': 'badge',
|
|
|
+ 'hours': 'float_time',
|
|
|
+ 'currency': 'monetary',
|
|
|
+ 'number': 'float'
|
|
|
+ }
|
|
|
+ widget = widget_map.get(indicator.indicator_type, 'badge') if indicator else 'badge'
|
|
|
+
|
|
|
+ field_xml = f'<field name="{field_info["name"]}" widget="{widget}"'
|
|
|
+
|
|
|
+ # Add help text with indicator description (valid in form views)
|
|
|
+ if indicator and indicator.description:
|
|
|
+ # Escape quotes in description for XML
|
|
|
+ help_text = indicator.description.replace('"', '"')
|
|
|
+ field_xml += f' help="{help_text}"'
|
|
|
|
|
|
# Add decorations based on indicator thresholds
|
|
|
- indicator = field_info['indicator']
|
|
|
if indicator:
|
|
|
# Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
|
|
|
field_xml += f' decoration-success="{field_info["name"]} >= 0.9"'
|
|
|
@@ -747,8 +1086,16 @@ class HrEfficiency(models.Model):
|
|
|
# Update inherited form view
|
|
|
inherited_form_view = self.env.ref('hr_efficiency.view_hr_efficiency_form_inherited', raise_if_not_found=False)
|
|
|
if inherited_form_view:
|
|
|
- new_form_arch = f"""
|
|
|
+ if form_dynamic_fields_xml:
|
|
|
+ new_form_arch = f"""
|
|
|
<xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">{form_dynamic_fields_xml}</xpath>
|
|
|
+"""
|
|
|
+ else:
|
|
|
+ # If no dynamic fields, remove any existing dynamic fields from the view
|
|
|
+ new_form_arch = """
|
|
|
+<xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">
|
|
|
+ <!-- No dynamic fields to display -->
|
|
|
+</xpath>
|
|
|
"""
|
|
|
inherited_form_view.write({'arch': new_form_arch})
|
|
|
_logger.info("Updated inherited form view with dynamic fields")
|
|
|
@@ -762,6 +1109,13 @@ class HrEfficiency(models.Model):
|
|
|
Generate list of months between start_month and end_month (inclusive)
|
|
|
"""
|
|
|
months = []
|
|
|
+
|
|
|
+ # Convert date objects to datetime if needed
|
|
|
+ if isinstance(start_month, date):
|
|
|
+ start_month = start_month.strftime('%Y-%m')
|
|
|
+ if isinstance(end_month, date):
|
|
|
+ end_month = end_month.strftime('%Y-%m')
|
|
|
+
|
|
|
current = datetime.strptime(start_month, '%Y-%m')
|
|
|
end = datetime.strptime(end_month, '%Y-%m')
|
|
|
|
|
|
@@ -901,3 +1255,32 @@ class HrEfficiency(models.Model):
|
|
|
except Exception:
|
|
|
# Fallback to basic weekday check if there's any error
|
|
|
return check_date.weekday() < 5
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _apply_default_values_to_existing_records(self):
|
|
|
+ """Apply default values to existing employee and company records"""
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Update employees with utilization_rate = 100.0 if not set
|
|
|
+ employees_to_update = self.env['hr.employee'].search([
|
|
|
+ ('utilization_rate', '=', 0.0)
|
|
|
+ ])
|
|
|
+ if employees_to_update:
|
|
|
+ employees_to_update.write({'utilization_rate': 100.0})
|
|
|
+ _logger.info(f"Updated {len(employees_to_update)} employees with default utilization_rate = 100.0%")
|
|
|
+
|
|
|
+ # Update companies with overhead = 40.0 if not set
|
|
|
+ companies_to_update = self.env['res.company'].search([
|
|
|
+ ('overhead', '=', 0.0)
|
|
|
+ ])
|
|
|
+ if companies_to_update:
|
|
|
+ companies_to_update.write({'overhead': 40.0})
|
|
|
+ _logger.info(f"Updated {len(companies_to_update)} companies with default overhead = 40.0%")
|
|
|
+
|
|
|
+ _logger.info("Default values applied successfully to existing records")
|
|
|
+
|
|
|
+ except Exception as e:
|
|
|
+ _logger.error(f"Error applying default values to existing records: {str(e)}")
|
|
|
+ # Don't raise the exception to avoid breaking the installation
|