|
|
@@ -1,12 +1,16 @@
|
|
|
# -*- coding: utf-8 -*-
|
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
|
|
+import logging
|
|
|
+import pytz
|
|
|
from datetime import datetime, date
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
from odoo import api, fields, models, _
|
|
|
from odoo.exceptions import UserError
|
|
|
from odoo.tools import float_round
|
|
|
|
|
|
+_logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
|
|
|
class HrEfficiency(models.Model):
|
|
|
_name = 'hr.efficiency'
|
|
|
@@ -15,17 +19,30 @@ class HrEfficiency(models.Model):
|
|
|
_rec_name = 'display_name'
|
|
|
_active_name = 'active'
|
|
|
|
|
|
- active = fields.Boolean('Active', default=True, help='Technical field to archive old records')
|
|
|
- month_year = fields.Char('Month Year', required=True, index=True, help="Format: YYYY-MM")
|
|
|
- employee_id = fields.Many2one('hr.employee', 'Employee', required=True, index=True)
|
|
|
- company_id = fields.Many2one('res.company', 'Company', required=True, default=lambda self: self.env.company)
|
|
|
+ # Basic fields
|
|
|
+ name = fields.Char('Name', compute='_compute_display_name', store=True)
|
|
|
+ employee_id = fields.Many2one('hr.employee', 'Employee', required=True, domain=[('employee_type', '=', 'employee')])
|
|
|
+ month_year = fields.Char('Month Year', required=True, help="Format: YYYY-MM (e.g., 2024-08)")
|
|
|
+ date = fields.Date('Date', compute='_compute_date', store=True, help="Date field for standard Odoo date filters")
|
|
|
+ company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
|
|
|
+ active = fields.Boolean('Active', default=True)
|
|
|
calculation_date = fields.Datetime('Calculation Date', default=fields.Datetime.now, help='When this calculation was performed')
|
|
|
|
|
|
- # Available hours (considering holidays and time off)
|
|
|
- available_hours = fields.Float('Available Hours', digits=(10, 2), help="Total available hours considering holidays and time off")
|
|
|
+ # 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")
|
|
|
|
|
|
- # Planned hours
|
|
|
- planned_hours = fields.Float('Planned Hours', digits=(10, 2), help="Total hours planned in planning module")
|
|
|
+ # 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")
|
|
|
+ expected_profitability = fields.Float('Expected Profitability (%)', digits=(5, 2), default=30.0, aggregator='avg', help="Company's expected profitability percentage at the time of calculation")
|
|
|
+ efficiency_factor = fields.Float('Efficiency Factor (%)', digits=(5, 2), default=85.0, aggregator='avg', help="Company's efficiency factor 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")
|
|
|
planned_non_billable_hours = fields.Float('Planned Non-Billable Hours', digits=(10, 2), help="Hours planned on non-billable projects")
|
|
|
|
|
|
@@ -33,27 +50,139 @@ 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)
|
|
|
+ # 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 (hourly_cost + overhead + rentabilidad_esperada)', 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 (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)
|
|
|
|
|
|
# Note: Removed unique constraint to allow historical tracking
|
|
|
# Multiple records can exist for the same employee and month
|
|
|
|
|
|
- @api.depends('actual_billable_hours', 'actual_non_billable_hours')
|
|
|
- def _compute_total_actual_hours(self):
|
|
|
+ @api.depends('month_year')
|
|
|
+ def _compute_date(self):
|
|
|
+ """
|
|
|
+ Compute date field from month_year for standard Odoo date filters
|
|
|
+ """
|
|
|
for record in self:
|
|
|
- record.total_actual_hours = record.actual_billable_hours + record.actual_non_billable_hours
|
|
|
+ if record.month_year:
|
|
|
+ try:
|
|
|
+ year, month = record.month_year.split('-')
|
|
|
+ record.date = date(int(year), int(month), 1)
|
|
|
+ except (ValueError, AttributeError):
|
|
|
+ record.date = False
|
|
|
+ else:
|
|
|
+ record.date = False
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ def _calculate_expected_hours_to_date(self, record):
|
|
|
+ """
|
|
|
+ Calculate expected hours to date based on planned hours and working days
|
|
|
+ """
|
|
|
+ if not record.month_year or not record.planned_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)
|
|
|
+
|
|
|
+ # 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 planned hours (what was planned to work)
|
|
|
+ expected_hours = (record.planned_hours / total_working_days) * working_days_until_date
|
|
|
+
|
|
|
+ # Ensure we don't exceed planned hours for the month
|
|
|
+ expected_hours = min(expected_hours, record.planned_hours)
|
|
|
+
|
|
|
+ return float_round(expected_hours, 2)
|
|
|
+ else:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ except (ValueError, AttributeError) as e:
|
|
|
+ return 0.0
|
|
|
|
|
|
- @api.depends('available_hours', 'planned_hours', 'total_actual_hours')
|
|
|
- def _compute_indicators(self):
|
|
|
+ def _calculate_precio_por_hora(self, record):
|
|
|
+ """
|
|
|
+ Calculate precio por hora: hourly_cost * (1 + overhead/100) * (1 + expected_profitability/100)
|
|
|
+ """
|
|
|
+ try:
|
|
|
+ # Get hourly_cost from employee
|
|
|
+ employee = getattr(record, 'employee_id', None)
|
|
|
+ if not employee:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ hourly_cost = getattr(employee, 'hourly_cost', 0.0) or 0.0
|
|
|
+ overhead = getattr(record, 'overhead', 40.0) or 40.0
|
|
|
+ expected_profitability = getattr(record, 'expected_profitability', 30.0) or 30.0
|
|
|
+
|
|
|
+ if hourly_cost > 0:
|
|
|
+ # Apply overhead and expected profitability margin
|
|
|
+ precio_por_hora = hourly_cost * (1 + (overhead / 100)) * (1 + (expected_profitability / 100))
|
|
|
+ return float_round(precio_por_hora, 2)
|
|
|
+ else:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+ except (ValueError, AttributeError, ZeroDivisionError) as e:
|
|
|
+ return 0.0
|
|
|
+
|
|
|
+
|
|
|
+ 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([
|
|
|
@@ -62,48 +191,163 @@ class HrEfficiency(models.Model):
|
|
|
('ttype', '=', 'float')
|
|
|
])
|
|
|
|
|
|
- # Prepare efficiency data
|
|
|
+ # Prepare efficiency data with safe field access
|
|
|
+ efficiency_data = {
|
|
|
+ '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,
|
|
|
+ }
|
|
|
+
|
|
|
+ # 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)
|
|
|
+
|
|
|
+ # 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)
|
|
|
+
|
|
|
+ # 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}"
|
|
|
+
|
|
|
+ # 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]
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+ def _update_stored_manual_fields(self):
|
|
|
+ """Update stored manual fields with computed values"""
|
|
|
+ for record in self:
|
|
|
+ # Get all manual fields for this model
|
|
|
+ manual_fields = self.env['ir.model.fields'].search([
|
|
|
+ ('model', '=', 'hr.efficiency'),
|
|
|
+ ('state', '=', 'manual'),
|
|
|
+ ('ttype', '=', 'float'),
|
|
|
+ ('store', '=', True),
|
|
|
+ ], order='id')
|
|
|
+
|
|
|
+ # 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,
|
|
|
+ '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 all indicators dynamically
|
|
|
+ # Calculate and update stored manual fields
|
|
|
for field in manual_fields:
|
|
|
# Find the corresponding indicator
|
|
|
indicator = self.env['hr.efficiency.indicator'].search([
|
|
|
('name', 'ilike', field.field_description)
|
|
|
], limit=1)
|
|
|
|
|
|
- if indicator:
|
|
|
+ if indicator and 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
|
|
|
- if hasattr(record, field.name):
|
|
|
- setattr(record, field.name, float_round(indicator_value, 2))
|
|
|
-
|
|
|
- # Overall efficiency based on configured indicators
|
|
|
- record.overall_efficiency = self._calculate_overall_efficiency(record)
|
|
|
+ # Update the stored field value
|
|
|
+ record[field.name] = float_round(indicator_value, 2)
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _recompute_all_indicators(self):
|
|
|
+ """Recompute all indicator fields for all records"""
|
|
|
+ records = self.search([])
|
|
|
+ if records:
|
|
|
+ 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
|
|
|
|
|
|
|
|
|
@@ -118,13 +362,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'),
|
|
|
@@ -156,28 +394,46 @@ class HrEfficiency(models.Model):
|
|
|
# Default calculation if no indicators configured
|
|
|
return 0.0
|
|
|
|
|
|
+ # Check if there's any data to calculate
|
|
|
+ if (record.available_hours == 0 and
|
|
|
+ record.planned_hours == 0 and
|
|
|
+ record.actual_billable_hours == 0 and
|
|
|
+ record.actual_non_billable_hours == 0):
|
|
|
+ return 0.0
|
|
|
+
|
|
|
total_weight = 0
|
|
|
weighted_sum = 0
|
|
|
+ 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,
|
|
|
+ '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:
|
|
|
if indicator.weight > 0:
|
|
|
indicator_value = indicator.evaluate_formula(efficiency_data)
|
|
|
- weighted_sum += indicator_value * indicator.weight
|
|
|
- total_weight += indicator.weight
|
|
|
+ # Count indicators with valid values (including 0 when it's a valid result)
|
|
|
+ if indicator_value is not None:
|
|
|
+ weighted_sum += indicator_value * indicator.weight
|
|
|
+ total_weight += indicator.weight
|
|
|
+ valid_indicators += 1
|
|
|
|
|
|
- if total_weight > 0:
|
|
|
- return float_round(weighted_sum / total_weight, 2)
|
|
|
- else:
|
|
|
+ # If no valid indicators or no total weight, return 0
|
|
|
+ if total_weight <= 0 or valid_indicators == 0:
|
|
|
return 0.0
|
|
|
+
|
|
|
+ # Multiply by 100 to show as percentage
|
|
|
+ return float_round((weighted_sum / total_weight) * 100, 2)
|
|
|
|
|
|
@api.depends('employee_id', 'month_year')
|
|
|
def _compute_display_name(self):
|
|
|
@@ -209,6 +465,20 @@ 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
|
|
|
+ expected_profitability = employee.company_id.expected_profitability or 30.0
|
|
|
+ efficiency_factor = employee.company_id.efficiency_factor or 85.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,
|
|
|
@@ -217,10 +487,56 @@ 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,
|
|
|
+ 'expected_profitability': expected_profitability,
|
|
|
+ 'efficiency_factor': efficiency_factor,
|
|
|
}
|
|
|
|
|
|
+ @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
|
|
|
@@ -272,20 +588,43 @@ class HrEfficiency(models.Model):
|
|
|
"""
|
|
|
Calculate planned hours from planning module
|
|
|
"""
|
|
|
- # Get planning slots for the employee in the date range
|
|
|
+ # Get planning slots for the employee that overlap with the date range
|
|
|
+ # This is the same logic as Odoo Planning's Gantt chart
|
|
|
+ start_datetime = datetime.combine(start_date, datetime.min.time())
|
|
|
+ end_datetime = datetime.combine(end_date, datetime.max.time())
|
|
|
+
|
|
|
planning_slots = self.env['planning.slot'].search([
|
|
|
('employee_id', '=', employee.id),
|
|
|
- ('start_datetime', '>=', datetime.combine(start_date, datetime.min.time())),
|
|
|
- ('end_datetime', '<=', datetime.combine(end_date, datetime.max.time())),
|
|
|
- ('state', 'in', ['draft', 'published']),
|
|
|
+ ('start_datetime', '<=', end_datetime),
|
|
|
+ ('end_datetime', '>=', start_datetime),
|
|
|
+ # Removed state restriction to include all planning slots regardless of state
|
|
|
])
|
|
|
|
|
|
total_planned = 0.0
|
|
|
total_billable = 0.0
|
|
|
total_non_billable = 0.0
|
|
|
|
|
|
+ # Get working intervals for the resource and company calendar
|
|
|
+ # This is the same approach as Odoo Planning's Gantt chart
|
|
|
+ start_utc = pytz.utc.localize(start_datetime)
|
|
|
+ end_utc = pytz.utc.localize(end_datetime)
|
|
|
+
|
|
|
+ if employee.resource_id:
|
|
|
+ resource_work_intervals, calendar_work_intervals = employee.resource_id._get_valid_work_intervals(
|
|
|
+ start_utc, end_utc, calendars=employee.company_id.resource_calendar_id
|
|
|
+ )
|
|
|
+ else:
|
|
|
+ # Fallback to company calendar if no resource
|
|
|
+ calendar_work_intervals = {employee.company_id.resource_calendar_id.id: []}
|
|
|
+ resource_work_intervals = {}
|
|
|
+
|
|
|
for slot in planning_slots:
|
|
|
- hours = slot.allocated_hours or 0.0
|
|
|
+ # Use the same logic as Odoo Planning's Gantt chart
|
|
|
+ # Calculate duration only within the specified period
|
|
|
+ hours = slot._get_duration_over_period(
|
|
|
+ start_utc, end_utc,
|
|
|
+ resource_work_intervals, calendar_work_intervals, has_allocated_hours=False
|
|
|
+ )
|
|
|
|
|
|
# Check if the slot is linked to a billable project
|
|
|
if slot.project_id and slot.project_id.allow_billable:
|
|
|
@@ -346,8 +685,11 @@ class HrEfficiency(models.Model):
|
|
|
# Generate list of months
|
|
|
months = self._generate_month_list(start_month, end_month)
|
|
|
|
|
|
- # Get all active employees
|
|
|
- employees = self.env['hr.employee'].search([('active', '=', True)])
|
|
|
+ # Get all active employees of type 'employee'
|
|
|
+ employees = self.env['hr.employee'].search([
|
|
|
+ ('active', '=', True),
|
|
|
+ ('employee_type', '=', 'employee')
|
|
|
+ ])
|
|
|
|
|
|
created_records = []
|
|
|
|
|
|
@@ -372,10 +714,34 @@ class HrEfficiency(models.Model):
|
|
|
'actual_non_billable_hours'
|
|
|
]
|
|
|
|
|
|
+ # Check basic fields
|
|
|
for field in fields_to_compare:
|
|
|
if abs(efficiency_data[field] - latest_record[field]) > 0.01: # Tolerance for floating point
|
|
|
has_changes = True
|
|
|
break
|
|
|
+
|
|
|
+ # 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)], order='sequence')
|
|
|
+
|
|
|
+ for indicator in active_indicators:
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
+
|
|
|
+ # Calculate current indicator value
|
|
|
+ current_value = indicator.evaluate_formula(efficiency_data)
|
|
|
+
|
|
|
+ # Get previous indicator value from record
|
|
|
+ previous_value = getattr(latest_record, field_name, None)
|
|
|
+
|
|
|
+ # Compare values with tolerance
|
|
|
+ if (current_value is not None and previous_value is not None and
|
|
|
+ abs(current_value - previous_value) > 0.01):
|
|
|
+ has_changes = True
|
|
|
+ break
|
|
|
+ elif current_value != previous_value: # Handle None vs value cases
|
|
|
+ has_changes = True
|
|
|
+ break
|
|
|
else:
|
|
|
# No previous record exists, so this is a change
|
|
|
has_changes = True
|
|
|
@@ -395,6 +761,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
|
|
|
@@ -435,6 +807,78 @@ class HrEfficiency(models.Model):
|
|
|
_logger.error(f"Error during dynamic system initialization: {str(e)}")
|
|
|
raise
|
|
|
|
|
|
+ @api.model
|
|
|
+ def _post_init_hook(self):
|
|
|
+ """
|
|
|
+ Post-install hook to ensure dynamic fields are created for existing indicators
|
|
|
+ """
|
|
|
+ import logging
|
|
|
+ _logger = logging.getLogger(__name__)
|
|
|
+
|
|
|
+ try:
|
|
|
+ _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)], 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:
|
|
|
+
|
|
|
+ # 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 _update_views_with_dynamic_fields(self):
|
|
|
"""
|
|
|
@@ -444,64 +888,161 @@ class HrEfficiency(models.Model):
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
try:
|
|
|
- # Get all manual fields for this model (like Studio does)
|
|
|
- manual_fields = self.env['ir.model.fields'].search([
|
|
|
- ('model', '=', 'hr.efficiency'),
|
|
|
- ('state', '=', 'manual'),
|
|
|
- ('ttype', '=', 'float')
|
|
|
- ], order='id')
|
|
|
+ # Get active indicators ordered by sequence (consistent with other parts)
|
|
|
+ active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
|
|
|
|
|
|
- _logger.info(f"Found {len(manual_fields)} manual fields to add to views")
|
|
|
+ fields_to_display = []
|
|
|
+
|
|
|
+ for indicator in active_indicators:
|
|
|
+ field_name = self._get_indicator_field_name(indicator.name)
|
|
|
+
|
|
|
+ # Check if this indicator has a manual field
|
|
|
+ manual_field = self.env['ir.model.fields'].search([
|
|
|
+ ('model', '=', 'hr.efficiency'),
|
|
|
+ ('state', '=', 'manual'),
|
|
|
+ ('ttype', '=', 'float'),
|
|
|
+ ('name', '=', field_name),
|
|
|
+ ], limit=1)
|
|
|
+
|
|
|
+ if manual_field:
|
|
|
+ # Indicator with manual field
|
|
|
+ fields_to_display.append({
|
|
|
+ 'name': field_name,
|
|
|
+ 'field_description': indicator.name,
|
|
|
+ 'indicator': indicator
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ # Create manual field for this indicator
|
|
|
+ _logger.info(f"Creating manual field for indicator '{indicator.name}'")
|
|
|
+ self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
|
|
|
+ # Add to display list after creation
|
|
|
+ fields_to_display.append({
|
|
|
+ 'name': field_name,
|
|
|
+ 'field_description': indicator.name,
|
|
|
+ 'indicator': indicator
|
|
|
+ })
|
|
|
+
|
|
|
+ _logger.info(f"Found {len(fields_to_display)} fields to add to views")
|
|
|
|
|
|
# Build dynamic fields XML for list view
|
|
|
dynamic_fields_xml = ''
|
|
|
- for field in manual_fields:
|
|
|
- field_xml = f'<field name="{field.name}" widget="percentage" optional="hide"'
|
|
|
+ for field_info in fields_to_display:
|
|
|
+ # 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'
|
|
|
|
|
|
- # Add decorations based on indicator thresholds
|
|
|
- indicator = self.env['hr.efficiency.indicator'].search([
|
|
|
- ('name', 'ilike', field.field_description)
|
|
|
- ], limit=1)
|
|
|
+ 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:
|
|
|
- if indicator.color_threshold_green:
|
|
|
- field_xml += f' decoration-success="{field.name} >= {indicator.color_threshold_green}"'
|
|
|
- if indicator.color_threshold_yellow:
|
|
|
- field_xml += f' decoration-warning="{field.name} >= {indicator.color_threshold_yellow} and {field.name} < {indicator.color_threshold_green}"'
|
|
|
- field_xml += f' decoration-danger="{field.name} < {indicator.color_threshold_yellow}"'
|
|
|
+ field_xml += f' string="{indicator.name}"'
|
|
|
+
|
|
|
+ # Add decorations based on indicator thresholds
|
|
|
+ if indicator:
|
|
|
+ # Use dynamic thresholds from indicator configuration
|
|
|
+ green_threshold = indicator.color_threshold_green / 100.0
|
|
|
+ yellow_threshold = indicator.color_threshold_yellow / 100.0
|
|
|
+
|
|
|
+ field_xml += f' decoration-success="{field_info["name"]} >= {green_threshold}"'
|
|
|
+ field_xml += f' decoration-warning="{field_info["name"]} >= {yellow_threshold} and {field_info["name"]} < {green_threshold}"'
|
|
|
+ field_xml += f' decoration-danger="{field_info["name"]} < {yellow_threshold}"'
|
|
|
+
|
|
|
+ # Add priority background color using CSS classes
|
|
|
+ if hasattr(indicator, 'priority') and indicator.priority != 'none':
|
|
|
+ if indicator.priority == 'low':
|
|
|
+ field_xml += f' class="priority-low-bg"'
|
|
|
+ elif indicator.priority == 'medium':
|
|
|
+ field_xml += f' class="priority-medium-bg"'
|
|
|
+ elif indicator.priority == 'high':
|
|
|
+ field_xml += f' class="priority-high-bg"'
|
|
|
|
|
|
field_xml += '/>'
|
|
|
dynamic_fields_xml += 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:
|
|
|
- arch = inherited_list_view.arch
|
|
|
- comment = '<!-- Dynamic indicator fields will be added here -->'
|
|
|
- if comment in arch:
|
|
|
- arch = arch.replace(comment, dynamic_fields_xml)
|
|
|
- inherited_list_view.write({'arch': arch})
|
|
|
- _logger.info(f"Updated inherited list view with {len(manual_fields)} dynamic fields")
|
|
|
+ if dynamic_fields_xml:
|
|
|
+ new_arch = f"""
|
|
|
+<xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">{dynamic_fields_xml}</xpath>
|
|
|
+"""
|
|
|
else:
|
|
|
- _logger.warning("Comment not found in inherited list view")
|
|
|
+ # 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")
|
|
|
|
|
|
# Build dynamic fields XML for form view
|
|
|
form_dynamic_fields_xml = ''
|
|
|
- for field in manual_fields:
|
|
|
- field_xml = f'<field name="{field.name}" widget="percentage"/>'
|
|
|
+ for field_info in fields_to_display:
|
|
|
+ # 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
|
|
|
+ if indicator:
|
|
|
+ # Use dynamic thresholds from indicator configuration
|
|
|
+ green_threshold = indicator.color_threshold_green / 100.0
|
|
|
+ yellow_threshold = indicator.color_threshold_yellow / 100.0
|
|
|
+
|
|
|
+ field_xml += f' decoration-success="{field_info["name"]} >= {green_threshold}"'
|
|
|
+ field_xml += f' decoration-warning="{field_info["name"]} >= {yellow_threshold} and {field_info["name"]} < {green_threshold}"'
|
|
|
+ field_xml += f' decoration-danger="{field_info["name"]} < {yellow_threshold}"'
|
|
|
+
|
|
|
+ # Add priority background color using CSS classes
|
|
|
+ if hasattr(indicator, 'priority') and indicator.priority != 'none':
|
|
|
+ if indicator.priority == 'low':
|
|
|
+ field_xml += f' class="priority-low-bg"'
|
|
|
+ elif indicator.priority == 'medium':
|
|
|
+ field_xml += f' class="priority-medium-bg"'
|
|
|
+ elif indicator.priority == 'high':
|
|
|
+ field_xml += f' class="priority-high-bg"'
|
|
|
+
|
|
|
+ field_xml += '/>'
|
|
|
form_dynamic_fields_xml += field_xml
|
|
|
|
|
|
# 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:
|
|
|
- arch = inherited_form_view.arch
|
|
|
- comment = '<!-- Dynamic indicator fields will be added here -->'
|
|
|
- if comment in arch:
|
|
|
- arch = arch.replace(comment, form_dynamic_fields_xml)
|
|
|
- inherited_form_view.write({'arch': arch})
|
|
|
- _logger.info(f"Updated inherited form view with dynamic fields")
|
|
|
+ if form_dynamic_fields_xml:
|
|
|
+ new_form_arch = f"""
|
|
|
+<xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">{form_dynamic_fields_xml}</xpath>
|
|
|
+"""
|
|
|
else:
|
|
|
- _logger.warning("Comment not found in inherited form view")
|
|
|
+ # 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")
|
|
|
|
|
|
except Exception as e:
|
|
|
_logger.error(f"Error updating views with dynamic fields: {str(e)}")
|
|
|
@@ -512,6 +1053,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')
|
|
|
|
|
|
@@ -527,6 +1075,31 @@ class HrEfficiency(models.Model):
|
|
|
Cron job to automatically calculate efficiency
|
|
|
"""
|
|
|
self.calculate_efficiency_for_period()
|
|
|
+ self._update_dynamic_filter_labels()
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _update_dynamic_filter_labels(self):
|
|
|
+ """
|
|
|
+ Update dynamic filter labels based on current date
|
|
|
+ """
|
|
|
+ labels = self._get_dynamic_month_labels()
|
|
|
+
|
|
|
+ # Update filter labels
|
|
|
+ filter_mapping = {
|
|
|
+ 'filter_two_months_ago': labels['two_months_ago'],
|
|
|
+ 'filter_last_month': labels['last_month'],
|
|
|
+ 'filter_current_month': labels['current_month'],
|
|
|
+ 'filter_next_month': labels['next_month'],
|
|
|
+ 'filter_two_months_ahead': labels['two_months_ahead'],
|
|
|
+ }
|
|
|
+
|
|
|
+ for filter_name, label in filter_mapping.items():
|
|
|
+ try:
|
|
|
+ filter_record = self.env.ref(f'hr_efficiency.{filter_name}', raise_if_not_found=False)
|
|
|
+ if filter_record:
|
|
|
+ filter_record.write({'name': label})
|
|
|
+ except Exception as e:
|
|
|
+ _logger.warning(f"Could not update filter {filter_name}: {str(e)}")
|
|
|
|
|
|
@api.model
|
|
|
def _get_month_filter_options(self):
|
|
|
@@ -557,3 +1130,101 @@ class HrEfficiency(models.Model):
|
|
|
months.append((month_year, month_name))
|
|
|
|
|
|
return months
|
|
|
+
|
|
|
+ @api.model
|
|
|
+ def _get_dynamic_month_labels(self):
|
|
|
+ """
|
|
|
+ Get dynamic month labels for filters
|
|
|
+ Returns a dictionary with month labels like "Mes 1", "Mes 2", "Mes 3 << actual", etc.
|
|
|
+ """
|
|
|
+ current_date = date.today()
|
|
|
+ labels = {}
|
|
|
+
|
|
|
+ # 2 months ago
|
|
|
+ month_2_ago = current_date - relativedelta(months=2)
|
|
|
+ labels['two_months_ago'] = f"Mes {month_2_ago.strftime('%m')}"
|
|
|
+
|
|
|
+ # 1 month ago
|
|
|
+ month_1_ago = current_date - relativedelta(months=1)
|
|
|
+ labels['last_month'] = f"Mes {month_1_ago.strftime('%m')}"
|
|
|
+
|
|
|
+ # Current month
|
|
|
+ labels['current_month'] = f"Mes {current_date.strftime('%m')} << actual"
|
|
|
+
|
|
|
+ # Next month
|
|
|
+ month_1_ahead = current_date + relativedelta(months=1)
|
|
|
+ labels['next_month'] = f"Mes {month_1_ahead.strftime('%m')}"
|
|
|
+
|
|
|
+ # 2 months ahead
|
|
|
+ month_2_ahead = current_date + relativedelta(months=2)
|
|
|
+ labels['two_months_ahead'] = f"Mes {month_2_ahead.strftime('%m')}"
|
|
|
+
|
|
|
+ return labels
|
|
|
+
|
|
|
+ def _count_working_days(self, start_date, end_date, employee=None):
|
|
|
+ """
|
|
|
+ Count working days between two dates considering employee calendar
|
|
|
+ """
|
|
|
+ working_days = 0
|
|
|
+ current_date = start_date
|
|
|
+
|
|
|
+ while current_date <= end_date:
|
|
|
+ # Check if it's a working day according to employee calendar
|
|
|
+ if self._is_working_day(current_date, employee):
|
|
|
+ working_days += 1
|
|
|
+ current_date += relativedelta(days=1)
|
|
|
+
|
|
|
+ return working_days
|
|
|
+
|
|
|
+ def _is_working_day(self, check_date, employee=None):
|
|
|
+ """
|
|
|
+ Check if a date is a working day considering employee calendar
|
|
|
+ """
|
|
|
+ if not employee or not employee.resource_calendar_id:
|
|
|
+ # Fallback to basic weekday check
|
|
|
+ return check_date.weekday() < 5
|
|
|
+
|
|
|
+ try:
|
|
|
+ # Convert date to datetime for calendar check
|
|
|
+ check_datetime = datetime.combine(check_date, datetime.min.time())
|
|
|
+
|
|
|
+ # Check if the day is a working day according to employee calendar
|
|
|
+ working_hours = employee.resource_calendar_id._list_work_time_per_day(
|
|
|
+ check_datetime, check_datetime, compute_leaves=True
|
|
|
+ )
|
|
|
+
|
|
|
+ # If there are working hours, it's a working day
|
|
|
+ return bool(working_hours)
|
|
|
+
|
|
|
+ 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
|