hr_efficiency.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from datetime import datetime, date
  4. from dateutil.relativedelta import relativedelta
  5. from odoo import api, fields, models, _
  6. from odoo.exceptions import UserError
  7. from odoo.tools import float_round
  8. class HrEfficiency(models.Model):
  9. _name = 'hr.efficiency'
  10. _description = 'Employee Efficiency'
  11. _order = 'month_year desc, employee_id'
  12. _rec_name = 'display_name'
  13. _active_name = 'active'
  14. active = fields.Boolean('Active', default=True, help='Technical field to archive old records')
  15. month_year = fields.Char('Month Year', required=True, index=True, help="Format: YYYY-MM")
  16. employee_id = fields.Many2one('hr.employee', 'Employee', required=True, index=True)
  17. company_id = fields.Many2one('res.company', 'Company', required=True, default=lambda self: self.env.company)
  18. calculation_date = fields.Datetime('Calculation Date', default=fields.Datetime.now, help='When this calculation was performed')
  19. # Available hours (considering holidays and time off)
  20. available_hours = fields.Float('Available Hours', digits=(10, 2), help="Total available hours considering holidays and time off")
  21. # Planned hours
  22. planned_hours = fields.Float('Planned Hours', digits=(10, 2), help="Total hours planned in planning module")
  23. planned_billable_hours = fields.Float('Planned Billable Hours', digits=(10, 2), help="Hours planned on billable projects")
  24. planned_non_billable_hours = fields.Float('Planned Non-Billable Hours', digits=(10, 2), help="Hours planned on non-billable projects")
  25. # Actual hours
  26. actual_billable_hours = fields.Float('Actual Billable Hours', digits=(10, 2), help="Hours actually worked on billable projects")
  27. actual_non_billable_hours = fields.Float('Actual Non-Billable Hours', digits=(10, 2), help="Hours actually worked on non-billable projects")
  28. # Computed fields
  29. total_actual_hours = fields.Float('Total Actual Hours', compute='_compute_total_actual_hours', store=True)
  30. # Dynamic indicator fields (will be created automatically)
  31. # These fields are managed dynamically based on hr.efficiency.indicator records
  32. # Overall efficiency (always present)
  33. overall_efficiency = fields.Float('Overall Efficiency (%)', compute='_compute_indicators', store=True, help='Overall efficiency based on configured indicators')
  34. display_name = fields.Char('Display Name', compute='_compute_display_name', store=True)
  35. # Note: Removed unique constraint to allow historical tracking
  36. # Multiple records can exist for the same employee and month
  37. @api.depends('actual_billable_hours', 'actual_non_billable_hours')
  38. def _compute_total_actual_hours(self):
  39. for record in self:
  40. record.total_actual_hours = record.actual_billable_hours + record.actual_non_billable_hours
  41. @api.depends('available_hours', 'planned_hours', 'total_actual_hours')
  42. def _compute_indicators(self):
  43. for record in self:
  44. # Get all manual fields for this model
  45. manual_fields = self.env['ir.model.fields'].search([
  46. ('model', '=', 'hr.efficiency'),
  47. ('state', '=', 'manual'),
  48. ('ttype', '=', 'float')
  49. ])
  50. # Prepare efficiency data
  51. efficiency_data = {
  52. 'available_hours': record.available_hours,
  53. 'planned_hours': record.planned_hours,
  54. 'planned_billable_hours': record.planned_billable_hours,
  55. 'planned_non_billable_hours': record.planned_non_billable_hours,
  56. 'actual_billable_hours': record.actual_billable_hours,
  57. 'actual_non_billable_hours': record.actual_non_billable_hours,
  58. }
  59. # Calculate all indicators dynamically
  60. for field in manual_fields:
  61. # Find the corresponding indicator
  62. indicator = self.env['hr.efficiency.indicator'].search([
  63. ('name', 'ilike', field.field_description)
  64. ], limit=1)
  65. if indicator:
  66. # Calculate indicator value using the indicator formula
  67. indicator_value = indicator.evaluate_formula(efficiency_data)
  68. # Set the value on the record
  69. if hasattr(record, field.name):
  70. setattr(record, field.name, float_round(indicator_value, 2))
  71. # Overall efficiency based on configured indicators
  72. record.overall_efficiency = self._calculate_overall_efficiency(record)
  73. def _get_indicator_field_name(self, indicator_name):
  74. """
  75. Convert indicator name to valid field name
  76. """
  77. # Remove special characters and convert to lowercase
  78. field_name = indicator_name.lower()
  79. field_name = field_name.replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '')
  80. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  81. field_name = field_name.replace('ñ', 'n')
  82. # Ensure it starts with x_ for manual fields
  83. if not field_name.startswith('x_'):
  84. field_name = 'x_' + field_name
  85. return field_name
  86. @api.model
  87. def _create_default_dynamic_fields(self):
  88. """
  89. Create default dynamic field records for existing indicators
  90. """
  91. # Get all active indicators
  92. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  93. for indicator in indicators:
  94. # Check if field already exists in ir.model.fields
  95. field_name = indicator.name.lower().replace(' ', '_')
  96. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  97. field_name = field_name.replace('ñ', 'n')
  98. # Ensure it starts with x_ for manual fields
  99. if not field_name.startswith('x_'):
  100. field_name = 'x_' + field_name
  101. existing_field = self.env['ir.model.fields'].search([
  102. ('model', '=', 'hr.efficiency'),
  103. ('name', '=', field_name)
  104. ], limit=1)
  105. if not existing_field:
  106. # Create field in ir.model.fields (like Studio does)
  107. self.env['ir.model.fields'].create({
  108. 'name': field_name,
  109. 'model': 'hr.efficiency',
  110. 'model_id': self.env['ir.model'].search([('model', '=', 'hr.efficiency')], limit=1).id,
  111. 'ttype': 'float',
  112. 'field_description': indicator.name,
  113. 'state': 'manual', # This is the key - it makes it a custom field
  114. 'store': True,
  115. 'compute': '_compute_indicators',
  116. })
  117. def _calculate_overall_efficiency(self, record):
  118. """
  119. Calculate overall efficiency based on configured indicators
  120. """
  121. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  122. if not indicators:
  123. # Default calculation if no indicators configured
  124. return 0.0
  125. total_weight = 0
  126. weighted_sum = 0
  127. efficiency_data = {
  128. 'available_hours': record.available_hours,
  129. 'planned_hours': record.planned_hours,
  130. 'planned_billable_hours': record.planned_billable_hours,
  131. 'planned_non_billable_hours': record.planned_non_billable_hours,
  132. 'actual_billable_hours': record.actual_billable_hours,
  133. 'actual_non_billable_hours': record.actual_non_billable_hours,
  134. }
  135. for indicator in indicators:
  136. if indicator.weight > 0:
  137. indicator_value = indicator.evaluate_formula(efficiency_data)
  138. weighted_sum += indicator_value * indicator.weight
  139. total_weight += indicator.weight
  140. if total_weight > 0:
  141. return float_round(weighted_sum / total_weight, 2)
  142. else:
  143. return 0.0
  144. @api.depends('employee_id', 'month_year')
  145. def _compute_display_name(self):
  146. for record in self:
  147. if record.employee_id and record.month_year:
  148. record.display_name = f"{record.employee_id.name} - {record.month_year}"
  149. else:
  150. record.display_name = "New Efficiency Record"
  151. @api.model
  152. def _calculate_employee_efficiency(self, employee, month_year):
  153. """
  154. Calculate efficiency for a specific employee and month
  155. """
  156. # Parse month_year (format: YYYY-MM)
  157. try:
  158. year, month = month_year.split('-')
  159. start_date = date(int(year), int(month), 1)
  160. end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
  161. except ValueError:
  162. raise UserError(_("Invalid month_year format. Expected: YYYY-MM"))
  163. # Calculate available hours (considering holidays and time off)
  164. available_hours = self._calculate_available_hours(employee, start_date, end_date)
  165. # Calculate planned hours
  166. planned_hours, planned_billable_hours, planned_non_billable_hours = self._calculate_planned_hours(employee, start_date, end_date)
  167. # Calculate actual hours
  168. actual_billable_hours, actual_non_billable_hours = self._calculate_actual_hours(employee, start_date, end_date)
  169. return {
  170. 'month_year': month_year,
  171. 'employee_id': employee.id,
  172. 'company_id': employee.company_id.id,
  173. 'available_hours': available_hours,
  174. 'planned_hours': planned_hours,
  175. 'planned_billable_hours': planned_billable_hours,
  176. 'planned_non_billable_hours': planned_non_billable_hours,
  177. 'actual_billable_hours': actual_billable_hours,
  178. 'actual_non_billable_hours': actual_non_billable_hours,
  179. }
  180. def _calculate_available_hours(self, employee, start_date, end_date):
  181. """
  182. Calculate available hours considering holidays and time off
  183. """
  184. if not employee.resource_calendar_id:
  185. return 0.0
  186. # Convert dates to datetime for the method
  187. start_datetime = datetime.combine(start_date, datetime.min.time())
  188. end_datetime = datetime.combine(end_date, datetime.max.time())
  189. # Get working hours from calendar
  190. work_hours_data = employee._list_work_time_per_day(start_datetime, end_datetime)
  191. total_work_hours = sum(hours for _, hours in work_hours_data[employee.id])
  192. # Subtract hours from approved time off
  193. time_off_hours = self._get_time_off_hours(employee, start_date, end_date)
  194. return max(0.0, total_work_hours - time_off_hours)
  195. def _get_time_off_hours(self, employee, start_date, end_date):
  196. """
  197. Get hours from approved time off requests
  198. """
  199. # Get approved time off requests
  200. leaves = self.env['hr.leave'].search([
  201. ('employee_id', '=', employee.id),
  202. ('state', '=', 'validate'),
  203. ('date_from', '<=', end_date),
  204. ('date_to', '>=', start_date),
  205. ])
  206. total_hours = 0.0
  207. for leave in leaves:
  208. # Calculate overlap with the month
  209. overlap_start = max(leave.date_from.date(), start_date)
  210. overlap_end = min(leave.date_to.date(), end_date)
  211. if overlap_start <= overlap_end:
  212. # Get hours for the overlap period
  213. overlap_start_dt = datetime.combine(overlap_start, datetime.min.time())
  214. overlap_end_dt = datetime.combine(overlap_end, datetime.max.time())
  215. work_hours_data = employee._list_work_time_per_day(overlap_start_dt, overlap_end_dt)
  216. total_hours += sum(hours for _, hours in work_hours_data[employee.id])
  217. return total_hours
  218. def _calculate_planned_hours(self, employee, start_date, end_date):
  219. """
  220. Calculate planned hours from planning module
  221. """
  222. # Get planning slots for the employee in the date range
  223. planning_slots = self.env['planning.slot'].search([
  224. ('employee_id', '=', employee.id),
  225. ('start_datetime', '>=', datetime.combine(start_date, datetime.min.time())),
  226. ('end_datetime', '<=', datetime.combine(end_date, datetime.max.time())),
  227. ('state', 'in', ['draft', 'published']),
  228. ])
  229. total_planned = 0.0
  230. total_billable = 0.0
  231. total_non_billable = 0.0
  232. for slot in planning_slots:
  233. hours = slot.allocated_hours or 0.0
  234. # Check if the slot is linked to a billable project
  235. if slot.project_id and slot.project_id.allow_billable:
  236. total_billable += hours
  237. else:
  238. total_non_billable += hours
  239. total_planned += hours
  240. return total_planned, total_billable, total_non_billable
  241. def _calculate_actual_hours(self, employee, start_date, end_date):
  242. """
  243. Calculate actual hours from timesheets
  244. """
  245. # Get timesheets for the employee in the date range (excluding time off)
  246. timesheets = self.env['account.analytic.line'].search([
  247. ('employee_id', '=', employee.id),
  248. ('date', '>=', start_date),
  249. ('date', '<=', end_date),
  250. ('project_id', '!=', False), # Only project timesheets
  251. ('holiday_id', '=', False), # Exclude time off timesheets
  252. ])
  253. total_billable = 0.0
  254. total_non_billable = 0.0
  255. for timesheet in timesheets:
  256. hours = timesheet.unit_amount or 0.0
  257. # Additional filter: exclude time off tasks
  258. if timesheet.task_id and timesheet.task_id.name and 'time off' in timesheet.task_id.name.lower():
  259. continue # Skip time off tasks
  260. # Additional filter: exclude time off from name
  261. if timesheet.name and 'tiempo personal' in timesheet.name.lower():
  262. continue # Skip personal time entries
  263. # Check if the project is billable
  264. if timesheet.project_id and timesheet.project_id.allow_billable:
  265. total_billable += hours
  266. else:
  267. total_non_billable += hours
  268. return total_billable, total_non_billable
  269. @api.model
  270. def calculate_efficiency_for_period(self, start_month=None, end_month=None):
  271. """
  272. Calculate efficiency for all employees for a given period
  273. """
  274. if not start_month:
  275. # Default: last 3 months and next 6 months
  276. current_date = date.today()
  277. start_month = (current_date - relativedelta(months=3)).strftime('%Y-%m')
  278. end_month = (current_date + relativedelta(months=6)).strftime('%Y-%m')
  279. # Generate list of months
  280. months = self._generate_month_list(start_month, end_month)
  281. # Get all active employees
  282. employees = self.env['hr.employee'].search([('active', '=', True)])
  283. created_records = []
  284. for employee in employees:
  285. for month in months:
  286. # Calculate efficiency data
  287. efficiency_data = self._calculate_employee_efficiency(employee, month)
  288. # Check if there are changes compared to the latest record
  289. latest_record = self.search([
  290. ('employee_id', '=', employee.id),
  291. ('month_year', '=', month),
  292. ('company_id', '=', employee.company_id.id),
  293. ], order='calculation_date desc', limit=1)
  294. has_changes = False
  295. if latest_record:
  296. # Compare current data with latest record
  297. fields_to_compare = [
  298. 'available_hours', 'planned_hours', 'planned_billable_hours',
  299. 'planned_non_billable_hours', 'actual_billable_hours',
  300. 'actual_non_billable_hours'
  301. ]
  302. for field in fields_to_compare:
  303. if abs(efficiency_data[field] - latest_record[field]) > 0.01: # Tolerance for floating point
  304. has_changes = True
  305. break
  306. else:
  307. # No previous record exists, so this is a change
  308. has_changes = True
  309. # Only create new record if there are changes
  310. if has_changes:
  311. # Archive existing records for this employee and month
  312. existing_records = self.search([
  313. ('employee_id', '=', employee.id),
  314. ('month_year', '=', month),
  315. ('company_id', '=', employee.company_id.id),
  316. ])
  317. if existing_records:
  318. existing_records.write({'active': False})
  319. # Create new record
  320. new_record = self.create(efficiency_data)
  321. created_records.append(new_record)
  322. return {
  323. 'created': len(created_records),
  324. 'updated': 0, # No longer updating existing records
  325. 'total_processed': len(created_records),
  326. }
  327. @api.model
  328. def _init_dynamic_system(self):
  329. """
  330. Initialize dynamic fields and views when module is installed
  331. """
  332. import logging
  333. _logger = logging.getLogger(__name__)
  334. try:
  335. _logger.info("Starting dynamic system initialization...")
  336. # Step 1: Create dynamic field records for existing indicators
  337. self._create_default_dynamic_fields()
  338. _logger.info("Default dynamic fields created successfully")
  339. # Step 2: Dynamic fields are created via hr.efficiency.dynamic.field model
  340. _logger.info("Dynamic fields creation handled by hr.efficiency.dynamic.field model")
  341. # Step 3: Update views
  342. self._update_views_with_dynamic_fields()
  343. _logger.info("Views updated successfully")
  344. # Step 4: Force recompute of existing records
  345. records = self.search([])
  346. if records:
  347. records._invalidate_cache()
  348. _logger.info(f"Invalidated cache for {len(records)} records")
  349. _logger.info("Dynamic system initialization completed successfully")
  350. except Exception as e:
  351. _logger.error(f"Error during dynamic system initialization: {str(e)}")
  352. raise
  353. @api.model
  354. def _update_views_with_dynamic_fields(self):
  355. """
  356. Update inherited views to include dynamic fields after module is loaded
  357. """
  358. import logging
  359. _logger = logging.getLogger(__name__)
  360. try:
  361. # Get all manual fields for this model (like Studio does)
  362. manual_fields = self.env['ir.model.fields'].search([
  363. ('model', '=', 'hr.efficiency'),
  364. ('state', '=', 'manual'),
  365. ('ttype', '=', 'float')
  366. ], order='id')
  367. _logger.info(f"Found {len(manual_fields)} manual fields to add to views")
  368. # Build dynamic fields XML for list view
  369. dynamic_fields_xml = ''
  370. for field in manual_fields:
  371. field_xml = f'<field name="{field.name}" widget="percentage" optional="hide"'
  372. # Add decorations based on indicator thresholds
  373. indicator = self.env['hr.efficiency.indicator'].search([
  374. ('name', 'ilike', field.field_description)
  375. ], limit=1)
  376. if indicator:
  377. if indicator.color_threshold_green:
  378. field_xml += f' decoration-success="{field.name} &gt;= {indicator.color_threshold_green}"'
  379. if indicator.color_threshold_yellow:
  380. field_xml += f' decoration-warning="{field.name} &gt;= {indicator.color_threshold_yellow} and {field.name} &lt; {indicator.color_threshold_green}"'
  381. field_xml += f' decoration-danger="{field.name} &lt; {indicator.color_threshold_yellow}"'
  382. field_xml += '/>'
  383. dynamic_fields_xml += field_xml
  384. # Update inherited list view
  385. inherited_list_view = self.env.ref('hr_efficiency.view_hr_efficiency_list_inherited', raise_if_not_found=False)
  386. if inherited_list_view:
  387. arch = inherited_list_view.arch
  388. comment = '<!-- Dynamic indicator fields will be added here -->'
  389. if comment in arch:
  390. arch = arch.replace(comment, dynamic_fields_xml)
  391. inherited_list_view.write({'arch': arch})
  392. _logger.info(f"Updated inherited list view with {len(manual_fields)} dynamic fields")
  393. else:
  394. _logger.warning("Comment not found in inherited list view")
  395. # Build dynamic fields XML for form view
  396. form_dynamic_fields_xml = ''
  397. for field in manual_fields:
  398. field_xml = f'<field name="{field.name}" widget="percentage"/>'
  399. form_dynamic_fields_xml += field_xml
  400. # Update inherited form view
  401. inherited_form_view = self.env.ref('hr_efficiency.view_hr_efficiency_form_inherited', raise_if_not_found=False)
  402. if inherited_form_view:
  403. arch = inherited_form_view.arch
  404. comment = '<!-- Dynamic indicator fields will be added here -->'
  405. if comment in arch:
  406. arch = arch.replace(comment, form_dynamic_fields_xml)
  407. inherited_form_view.write({'arch': arch})
  408. _logger.info(f"Updated inherited form view with dynamic fields")
  409. else:
  410. _logger.warning("Comment not found in inherited form view")
  411. except Exception as e:
  412. _logger.error(f"Error updating views with dynamic fields: {str(e)}")
  413. raise
  414. def _generate_month_list(self, start_month, end_month):
  415. """
  416. Generate list of months between start_month and end_month (inclusive)
  417. """
  418. months = []
  419. current = datetime.strptime(start_month, '%Y-%m')
  420. end = datetime.strptime(end_month, '%Y-%m')
  421. while current <= end:
  422. months.append(current.strftime('%Y-%m'))
  423. current = current + relativedelta(months=1)
  424. return months
  425. @api.model
  426. def _cron_calculate_efficiency(self):
  427. """
  428. Cron job to automatically calculate efficiency
  429. """
  430. self.calculate_efficiency_for_period()
  431. @api.model
  432. def _get_month_filter_options(self):
  433. """
  434. Get dynamic month filter options for search view
  435. Returns a list of tuples (month_year, display_name) for the last 2, current, and next 2 months
  436. """
  437. current_date = date.today()
  438. months = []
  439. # Last 2 months
  440. for i in range(2, 0, -1):
  441. month_date = current_date - relativedelta(months=i)
  442. month_year = month_date.strftime('%Y-%m')
  443. month_name = month_date.strftime('%B %Y') # e.g., "August 2024"
  444. months.append((month_year, month_name))
  445. # Current month
  446. current_month_year = current_date.strftime('%Y-%m')
  447. current_month_name = current_date.strftime('%B %Y')
  448. months.append((current_month_year, current_month_name))
  449. # Next 2 months
  450. for i in range(1, 3):
  451. month_date = current_date + relativedelta(months=i)
  452. month_year = month_date.strftime('%Y-%m')
  453. month_name = month_date.strftime('%B %Y')
  454. months.append((month_year, month_name))
  455. return months