hr_efficiency_indicator.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. import logging
  2. from odoo import models, fields, api, _
  3. from odoo.exceptions import ValidationError
  4. _logger = logging.getLogger(__name__)
  5. class HrEfficiencyIndicator(models.Model):
  6. _name = 'hr.efficiency.indicator'
  7. _description = 'Efficiency Indicator Configuration'
  8. _order = 'sequence, name'
  9. name = fields.Char('Indicator Name', required=True)
  10. sequence = fields.Integer('Sequence', default=10)
  11. active = fields.Boolean('Active', default=True)
  12. # Formula configuration
  13. formula = fields.Text('Formula', required=True, help="""
  14. Use the following variables in your formula:
  15. - available_hours: Available hours for the employee
  16. - planned_hours: Total planned hours
  17. - planned_billable_hours: Planned hours on billable projects
  18. - planned_non_billable_hours: Planned hours on non-billable projects
  19. - actual_billable_hours: Actual hours worked on billable projects
  20. - actual_non_billable_hours: Actual hours worked on non-billable projects
  21. Examples:
  22. - (planned_hours / available_hours) * 100 # Planning efficiency
  23. - ((actual_billable_hours + actual_non_billable_hours) / planned_hours) * 100 # Time tracking efficiency
  24. """)
  25. # Target and thresholds
  26. target_percentage = fields.Float('Target %', default=90.0, help='Target percentage for this indicator')
  27. weight = fields.Float('Weight %', default=50.0, help='Weight of this indicator in the overall efficiency calculation')
  28. # Display settings
  29. description = fields.Text('Description', help='Description of what this indicator measures')
  30. color_threshold_green = fields.Float('Green Threshold', default=90.0, help='Percentage above which to show green')
  31. color_threshold_yellow = fields.Float('Yellow Threshold', default=70.0, help='Percentage above which to show yellow')
  32. @api.constrains('weight')
  33. def _check_weight(self):
  34. for record in self:
  35. if record.weight < 0 or record.weight > 100:
  36. raise ValidationError(_('Weight must be between 0 and 100'))
  37. @api.constrains('target_percentage')
  38. def _check_target_percentage(self):
  39. for record in self:
  40. if record.target_percentage < 0 or record.target_percentage > 100:
  41. raise ValidationError(_('Target percentage must be between 0 and 100'))
  42. @api.model_create_multi
  43. def create(self, vals_list):
  44. """Create indicators and create dynamic fields"""
  45. records = super().create(vals_list)
  46. # Create dynamic fields for all indicators
  47. for record in records:
  48. self._create_dynamic_field(record)
  49. return records
  50. def write(self, vals):
  51. """Update indicator and update dynamic field"""
  52. result = super().write(vals)
  53. # Update dynamic field for this indicator
  54. for record in self:
  55. self._update_dynamic_field(record)
  56. return result
  57. def unlink(self):
  58. """Delete indicator and delete dynamic field"""
  59. # Delete associated dynamic fields
  60. for record in self:
  61. dynamic_field = self.env['hr.efficiency.dynamic.field'].search([
  62. ('indicator_id', '=', record.id)
  63. ])
  64. if dynamic_field:
  65. dynamic_field.unlink()
  66. # Also remove the corresponding manual field in ir.model.fields
  67. efficiency_model = 'hr.efficiency'
  68. technical_name = self.env[efficiency_model]._get_indicator_field_name(record.name)
  69. imf = self.env['ir.model.fields'].search([
  70. ('model', '=', efficiency_model),
  71. ('name', '=', technical_name),
  72. ('state', '=', 'manual'),
  73. ], limit=1)
  74. if imf:
  75. imf.unlink()
  76. result = super().unlink()
  77. # Refresh views after removal
  78. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  79. return result
  80. def _create_dynamic_field(self, indicator):
  81. """Create a dynamic field for the indicator"""
  82. # Create dynamic field record for all indicators
  83. existing_field = self.env['hr.efficiency.dynamic.field'].search([
  84. ('indicator_id', '=', indicator.id)
  85. ])
  86. if not existing_field:
  87. # Create dynamic field
  88. field_name = indicator.name.lower().replace(' ', '_').replace('-', '_')
  89. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  90. field_name = field_name.replace('ñ', 'n')
  91. # Ensure it starts with a letter
  92. if not field_name[0].isalpha():
  93. field_name = 'indicator_' + field_name
  94. self.env['hr.efficiency.dynamic.field'].create({
  95. 'name': field_name,
  96. 'label': indicator.name,
  97. 'indicator_id': indicator.id,
  98. 'sequence': indicator.sequence,
  99. 'active': indicator.active,
  100. 'show_in_list': True,
  101. 'show_in_form': True,
  102. 'show_in_search': False,
  103. 'widget': 'percentage',
  104. 'decoration_success': indicator.color_threshold_green,
  105. 'decoration_warning': indicator.color_threshold_yellow,
  106. 'decoration_danger': 0.0,
  107. })
  108. # Ensure an ir.model.fields manual field exists (Studio-like) for ALL indicators
  109. efficiency_model = 'hr.efficiency'
  110. model_rec = self.env['ir.model']._get(efficiency_model)
  111. # Compute the technical field name like Studio (x_ prefix)
  112. technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name)
  113. imf = self.env['ir.model.fields'].search([
  114. ('model', '=', efficiency_model),
  115. ('name', '=', technical_name),
  116. ], limit=1)
  117. if not imf:
  118. self.env['ir.model.fields'].with_context(studio=True).create({
  119. 'name': technical_name,
  120. 'model': efficiency_model,
  121. 'model_id': model_rec.id,
  122. 'ttype': 'float',
  123. 'field_description': indicator.name,
  124. 'state': 'manual',
  125. 'store': True,
  126. 'compute': False,
  127. })
  128. else:
  129. # Keep label in sync
  130. if imf.field_description != indicator.name:
  131. imf.write({'field_description': indicator.name})
  132. # Update views to include the new field
  133. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  134. # Recompute all indicators to populate the new stored field
  135. records = self.env['hr.efficiency'].search([])
  136. if records:
  137. records._compute_indicators()
  138. def _update_dynamic_field(self, indicator):
  139. """Update the dynamic field for the indicator"""
  140. dynamic_field = self.env['hr.efficiency.dynamic.field'].search([
  141. ('indicator_id', '=', indicator.id)
  142. ])
  143. if dynamic_field:
  144. dynamic_field.write({
  145. 'label': indicator.name,
  146. 'active': indicator.active,
  147. 'decoration_success': indicator.color_threshold_green,
  148. 'decoration_warning': indicator.color_threshold_yellow,
  149. })
  150. # Sync corresponding ir.model.fields label and refresh views
  151. efficiency_model = 'hr.efficiency'
  152. technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name)
  153. imf = self.env['ir.model.fields'].search([
  154. ('model', '=', efficiency_model),
  155. ('name', '=', technical_name),
  156. ], limit=1)
  157. if imf and imf.field_description != indicator.name:
  158. imf.write({'field_description': indicator.name})
  159. # Rebuild views so only active indicators are shown
  160. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  161. def evaluate_formula(self, efficiency_data):
  162. """
  163. Evaluate the formula using the provided efficiency data
  164. """
  165. try:
  166. # Create a safe environment for formula evaluation
  167. safe_dict = {
  168. 'available_hours': efficiency_data.get('available_hours', 0),
  169. 'planned_hours': efficiency_data.get('planned_hours', 0),
  170. 'planned_billable_hours': efficiency_data.get('planned_billable_hours', 0),
  171. 'planned_non_billable_hours': efficiency_data.get('planned_non_billable_hours', 0),
  172. 'actual_billable_hours': efficiency_data.get('actual_billable_hours', 0),
  173. 'actual_non_billable_hours': efficiency_data.get('actual_non_billable_hours', 0),
  174. 'expected_hours_to_date': efficiency_data.get('expected_hours_to_date', 0),
  175. }
  176. # Add math functions for safety
  177. import math
  178. safe_dict.update({
  179. 'abs': abs,
  180. 'min': min,
  181. 'max': max,
  182. 'round': round,
  183. })
  184. # Evaluate the formula
  185. result = eval(self.formula, {"__builtins__": {}}, safe_dict)
  186. return result
  187. except Exception as e:
  188. _logger.error(f"Error evaluating formula for indicator {self.name}: {e}")
  189. return 0.0
  190. def get_color_class(self, percentage):
  191. """
  192. Return the CSS color class based on the percentage
  193. """
  194. if percentage >= self.color_threshold_green:
  195. return 'text-success'
  196. elif percentage >= self.color_threshold_yellow:
  197. return 'text-warning'
  198. else:
  199. return 'text-danger'