hr_efficiency_indicator.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  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. # Indicator type for widget selection
  13. indicator_type = fields.Selection([
  14. ('percentage', 'Percentage'),
  15. ('hours', 'Hours'),
  16. ('currency', 'Currency'),
  17. ('number', 'Number')
  18. ], string='Indicator Type', default='percentage', required=True,
  19. help='Type of indicator that determines how it will be displayed in views')
  20. # Formula configuration
  21. formula = fields.Text('Formula', required=True, help="""
  22. Use the following variables in your formula:
  23. - available_hours: Available hours for the employee
  24. - planned_hours: Total planned hours
  25. - planned_billable_hours: Planned hours on billable projects
  26. - planned_non_billable_hours: Planned hours on non-billable projects
  27. - actual_billable_hours: Actual hours worked on billable projects
  28. - actual_non_billable_hours: Actual hours worked on non-billable projects
  29. Examples:
  30. - (planned_hours / available_hours) * 100 # Planning efficiency
  31. - ((actual_billable_hours + actual_non_billable_hours) / planned_hours) * 100 # Time tracking efficiency
  32. """)
  33. # Target and thresholds
  34. target_percentage = fields.Float('Target %', default=90.0, help='Target percentage for this indicator')
  35. weight = fields.Float('Weight %', default=50.0, help='Weight of this indicator in the overall efficiency calculation')
  36. # Display settings
  37. description = fields.Text('Description', help='Description of what this indicator measures')
  38. color_threshold_green = fields.Float('Green Threshold', default=90.0, help='Percentage above which to show green')
  39. color_threshold_yellow = fields.Float('Yellow Threshold', default=70.0, help='Percentage above which to show yellow')
  40. @api.constrains('weight')
  41. def _check_weight(self):
  42. for record in self:
  43. if record.weight < 0 or record.weight > 100:
  44. raise ValidationError(_('Weight must be between 0 and 100'))
  45. @api.constrains('target_percentage')
  46. def _check_target_percentage(self):
  47. for record in self:
  48. if record.target_percentage < 0 or record.target_percentage > 100:
  49. raise ValidationError(_('Target percentage must be between 0 and 100'))
  50. @api.model_create_multi
  51. def create(self, vals_list):
  52. """Create indicators and create dynamic fields"""
  53. records = super().create(vals_list)
  54. # Create dynamic fields for all indicators
  55. for record in records:
  56. self._create_dynamic_field(record)
  57. return records
  58. def write(self, vals):
  59. """Update indicator and update dynamic field"""
  60. result = super().write(vals)
  61. # Update dynamic field for this indicator
  62. for record in self:
  63. self._update_dynamic_field(record, vals)
  64. return result
  65. def unlink(self):
  66. """Delete indicator and delete dynamic field"""
  67. # FIRST: Mark indicators as inactive to exclude them from views
  68. self.write({'active': False})
  69. # SECOND: Update views to remove field references BEFORE deleting fields
  70. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  71. # THEN: Delete associated dynamic fields and manual fields
  72. for record in self:
  73. dynamic_field = self.env['hr.efficiency.dynamic.field'].search([
  74. ('indicator_id', '=', record.id)
  75. ])
  76. if dynamic_field:
  77. dynamic_field.unlink()
  78. # Also remove the corresponding manual field in ir.model.fields
  79. efficiency_model = 'hr.efficiency'
  80. technical_name = self.env[efficiency_model]._get_indicator_field_name(record.name)
  81. imf = self.env['ir.model.fields'].search([
  82. ('model', '=', efficiency_model),
  83. ('name', '=', technical_name),
  84. ('state', '=', 'manual'),
  85. ], limit=1)
  86. if imf:
  87. imf.unlink()
  88. result = super().unlink()
  89. return result
  90. def _create_dynamic_field(self, indicator):
  91. """Create a dynamic field for the indicator"""
  92. # Create dynamic field record for all indicators
  93. existing_field = self.env['hr.efficiency.dynamic.field'].search([
  94. ('indicator_id', '=', indicator.id)
  95. ])
  96. if not existing_field:
  97. # Create dynamic field
  98. field_name = indicator.name.lower().replace(' ', '_').replace('-', '_')
  99. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  100. field_name = field_name.replace('ñ', 'n')
  101. # Ensure it starts with a letter
  102. if not field_name[0].isalpha():
  103. field_name = 'indicator_' + field_name
  104. # Determine widget based on indicator type
  105. widget_map = {
  106. 'percentage': 'percentage',
  107. 'hours': 'float_time',
  108. 'currency': 'monetary',
  109. 'number': 'float'
  110. }
  111. widget = widget_map.get(indicator.indicator_type, 'percentage')
  112. self.env['hr.efficiency.dynamic.field'].create({
  113. 'name': field_name,
  114. 'label': indicator.name,
  115. 'indicator_id': indicator.id,
  116. 'sequence': indicator.sequence,
  117. 'active': indicator.active,
  118. 'show_in_list': True,
  119. 'show_in_form': True,
  120. 'show_in_search': False,
  121. 'widget': widget,
  122. 'decoration_success': indicator.color_threshold_green,
  123. 'decoration_warning': indicator.color_threshold_yellow,
  124. 'decoration_danger': 0.0,
  125. })
  126. # Ensure an ir.model.fields manual field exists (Studio-like) for ALL indicators
  127. efficiency_model = 'hr.efficiency'
  128. model_rec = self.env['ir.model']._get(efficiency_model)
  129. # Compute the technical field name like Studio (x_ prefix)
  130. technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name)
  131. imf = self.env['ir.model.fields'].search([
  132. ('model', '=', efficiency_model),
  133. ('name', '=', technical_name),
  134. ], limit=1)
  135. if not imf:
  136. self.env['ir.model.fields'].with_context(studio=True).create({
  137. 'name': technical_name,
  138. 'model': efficiency_model,
  139. 'model_id': model_rec.id,
  140. 'ttype': 'float',
  141. 'field_description': indicator.name,
  142. 'help': indicator.description or indicator.name,
  143. 'state': 'manual',
  144. 'store': True,
  145. 'compute': False,
  146. })
  147. else:
  148. # Keep label and help in sync
  149. update_vals = {}
  150. if imf.field_description != indicator.name:
  151. update_vals['field_description'] = indicator.name
  152. if imf.help != (indicator.description or indicator.name):
  153. update_vals['help'] = indicator.description or indicator.name
  154. if update_vals:
  155. imf.write(update_vals)
  156. # Update views to include the new field
  157. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  158. # Recompute all indicators to populate the new stored field
  159. records = self.env['hr.efficiency'].search([])
  160. if records:
  161. records._compute_indicators()
  162. def _update_dynamic_field(self, indicator, vals=None):
  163. """Update the dynamic field for the indicator"""
  164. dynamic_field = self.env['hr.efficiency.dynamic.field'].search([
  165. ('indicator_id', '=', indicator.id)
  166. ])
  167. if dynamic_field:
  168. # Determine widget based on indicator type
  169. widget_map = {
  170. 'percentage': 'percentage',
  171. 'hours': 'float_time',
  172. 'currency': 'monetary',
  173. 'number': 'float'
  174. }
  175. widget = widget_map.get(indicator.indicator_type, 'percentage')
  176. dynamic_field.write({
  177. 'label': indicator.name,
  178. 'active': indicator.active,
  179. 'widget': widget,
  180. 'decoration_success': indicator.color_threshold_green,
  181. 'decoration_warning': indicator.color_threshold_yellow,
  182. })
  183. # Sync corresponding ir.model.fields label and refresh views
  184. efficiency_model = 'hr.efficiency'
  185. technical_name = self.env[efficiency_model]._get_indicator_field_name(indicator.name)
  186. imf = self.env['ir.model.fields'].search([
  187. ('model', '=', efficiency_model),
  188. ('name', '=', technical_name),
  189. ], limit=1)
  190. if imf:
  191. update_vals = {}
  192. if imf.field_description != indicator.name:
  193. update_vals['field_description'] = indicator.name
  194. if imf.help != (indicator.description or indicator.name):
  195. update_vals['help'] = indicator.description or indicator.name
  196. if update_vals:
  197. imf.write(update_vals)
  198. # Update views when active status, sequence, or bulk operations change
  199. if 'active' in vals or 'sequence' in vals or len(self) > 1:
  200. try:
  201. self.env['hr.efficiency']._update_views_with_dynamic_fields()
  202. except Exception as e:
  203. # Log the error but don't let it rollback the transaction
  204. import logging
  205. _logger = logging.getLogger(__name__)
  206. _logger.error(f"Error updating views after indicator change: {e}")
  207. # Continue with the transaction even if view update fails
  208. def evaluate_formula(self, efficiency_data):
  209. """
  210. Evaluate the formula using the provided efficiency data
  211. """
  212. try:
  213. # Create a safe environment for formula evaluation
  214. safe_dict = {
  215. 'available_hours': efficiency_data.get('available_hours', 0),
  216. 'planned_hours': efficiency_data.get('planned_hours', 0),
  217. 'planned_billable_hours': efficiency_data.get('planned_billable_hours', 0),
  218. 'planned_non_billable_hours': efficiency_data.get('planned_non_billable_hours', 0),
  219. 'actual_billable_hours': efficiency_data.get('actual_billable_hours', 0),
  220. 'actual_non_billable_hours': efficiency_data.get('actual_non_billable_hours', 0),
  221. 'total_actual_hours': efficiency_data.get('total_actual_hours', 0),
  222. 'expected_hours_to_date': efficiency_data.get('expected_hours_to_date', 0),
  223. 'wage': efficiency_data.get('wage', 0),
  224. 'utilization_rate': efficiency_data.get('utilization_rate', 100.0),
  225. 'overhead': efficiency_data.get('overhead', 40.0),
  226. 'precio_por_hora': efficiency_data.get('precio_por_hora', 0),
  227. }
  228. # Add math functions for safety
  229. import math
  230. safe_dict.update({
  231. 'abs': abs,
  232. 'min': min,
  233. 'max': max,
  234. 'round': round,
  235. })
  236. # Evaluate the formula
  237. result = eval(self.formula, {"__builtins__": {}}, safe_dict)
  238. return result
  239. except Exception as e:
  240. _logger.error(f"Error evaluating formula for indicator {self.name}: {e}")
  241. return 0.0
  242. def get_color_class(self, percentage):
  243. """
  244. Return the CSS color class based on the percentage
  245. """
  246. if percentage >= self.color_threshold_green:
  247. return 'text-success'
  248. elif percentage >= self.color_threshold_yellow:
  249. return 'text-warning'
  250. else:
  251. return 'text-danger'