hr_efficiency.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import logging
  4. from datetime import datetime, date
  5. from dateutil.relativedelta import relativedelta
  6. from odoo import api, fields, models, _
  7. from odoo.exceptions import UserError
  8. from odoo.tools import float_round
  9. _logger = logging.getLogger(__name__)
  10. class HrEfficiency(models.Model):
  11. _name = 'hr.efficiency'
  12. _description = 'Employee Efficiency'
  13. _order = 'month_year desc, employee_id'
  14. _rec_name = 'display_name'
  15. _active_name = 'active'
  16. active = fields.Boolean('Active', default=True, help='Technical field to archive old records')
  17. month_year = fields.Char('Month Year', required=True, index=True, help="Format: YYYY-MM")
  18. employee_id = fields.Many2one('hr.employee', 'Employee', required=True, index=True, domain=[('employee_type', '=', 'employee')])
  19. company_id = fields.Many2one('res.company', 'Company', required=True, default=lambda self: self.env.company)
  20. calculation_date = fields.Datetime('Calculation Date', default=fields.Datetime.now, help='When this calculation was performed')
  21. # Available hours (considering holidays and time off)
  22. available_hours = fields.Float('Available Hours', digits=(10, 2), help="Total available hours considering holidays and time off")
  23. # Planned hours
  24. planned_hours = fields.Float('Planned Hours', digits=(10, 2), help="Total hours planned in planning module")
  25. planned_billable_hours = fields.Float('Planned Billable Hours', digits=(10, 2), help="Hours planned on billable projects")
  26. planned_non_billable_hours = fields.Float('Planned Non-Billable Hours', digits=(10, 2), help="Hours planned on non-billable projects")
  27. # Actual hours
  28. actual_billable_hours = fields.Float('Actual Billable Hours', digits=(10, 2), help="Hours actually worked on billable projects")
  29. actual_non_billable_hours = fields.Float('Actual Non-Billable Hours', digits=(10, 2), help="Hours actually worked on non-billable projects")
  30. # Computed fields
  31. total_actual_hours = fields.Float('Total Actual Hours', compute='_compute_total_actual_hours', store=True)
  32. expected_hours_to_date = fields.Float('Expected Hours to Date', compute='_compute_expected_hours_to_date', store=True, help='Hours that should be registered based on planning until current date')
  33. # Dynamic indicator fields (will be created automatically)
  34. # These fields are managed dynamically based on hr.efficiency.indicator records
  35. # Overall efficiency (always present)
  36. overall_efficiency = fields.Float('Overall Efficiency (%)', compute='_compute_indicators', store=True, help='Overall efficiency based on configured indicators')
  37. overall_efficiency_display = fields.Char('Overall Efficiency Display', compute='_compute_overall_efficiency_display', store=True, help='Overall efficiency formatted for display with badge widget')
  38. display_name = fields.Char('Display Name', compute='_compute_display_name', store=True)
  39. # Note: Removed unique constraint to allow historical tracking
  40. # Multiple records can exist for the same employee and month
  41. @api.depends('actual_billable_hours', 'actual_non_billable_hours')
  42. def _compute_total_actual_hours(self):
  43. for record in self:
  44. record.total_actual_hours = record.actual_billable_hours + record.actual_non_billable_hours
  45. @api.depends('available_hours', 'month_year', 'employee_id', 'employee_id.contract_id')
  46. def _compute_expected_hours_to_date(self):
  47. """
  48. Calculate expected hours to date based on available hours and working days
  49. """
  50. for record in self:
  51. if not record.month_year or not record.available_hours or not record.employee_id.contract_id:
  52. record.expected_hours_to_date = 0.0
  53. continue
  54. try:
  55. # Parse month_year (format: YYYY-MM)
  56. year, month = record.month_year.split('-')
  57. start_date = date(int(year), int(month), 1)
  58. end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
  59. # Get current date
  60. current_date = date.today()
  61. # If current date is outside the month, use end of month
  62. if current_date > end_date:
  63. calculation_date = end_date
  64. elif current_date < start_date:
  65. calculation_date = start_date
  66. else:
  67. calculation_date = current_date
  68. # Calculate working days in the month
  69. total_working_days = self._count_working_days(start_date, end_date, record.employee_id)
  70. # Calculate working days until current date
  71. working_days_until_date = self._count_working_days(start_date, calculation_date, record.employee_id)
  72. if total_working_days > 0:
  73. # Calculate expected hours based on available hours (what employee should work)
  74. expected_hours = (record.available_hours / total_working_days) * working_days_until_date
  75. # Ensure we don't exceed available hours for the month
  76. expected_hours = min(expected_hours, record.available_hours)
  77. record.expected_hours_to_date = float_round(expected_hours, 2)
  78. else:
  79. record.expected_hours_to_date = 0.0
  80. except (ValueError, AttributeError) as e:
  81. record.expected_hours_to_date = 0.0
  82. @api.depends('overall_efficiency')
  83. def _compute_overall_efficiency_display(self):
  84. """
  85. Compute display value for overall efficiency that works with badge widget
  86. """
  87. for record in self:
  88. if record.overall_efficiency == 0:
  89. record.overall_efficiency_display = '0.00'
  90. else:
  91. record.overall_efficiency_display = f"{record.overall_efficiency:.2f}"
  92. @api.depends('available_hours', 'planned_hours', 'total_actual_hours')
  93. def _compute_indicators(self):
  94. for record in self:
  95. # Get all manual fields for this model
  96. manual_fields = self.env['ir.model.fields'].search([
  97. ('model', '=', 'hr.efficiency'),
  98. ('state', '=', 'manual'),
  99. ('ttype', '=', 'float')
  100. ])
  101. # Prepare efficiency data
  102. efficiency_data = {
  103. 'available_hours': record.available_hours,
  104. 'planned_hours': record.planned_hours,
  105. 'planned_billable_hours': record.planned_billable_hours,
  106. 'planned_non_billable_hours': record.planned_non_billable_hours,
  107. 'actual_billable_hours': record.actual_billable_hours,
  108. 'actual_non_billable_hours': record.actual_non_billable_hours,
  109. 'expected_hours_to_date': record.expected_hours_to_date,
  110. }
  111. # Calculate all indicators dynamically
  112. for field in manual_fields:
  113. # Find the corresponding indicator
  114. indicator = self.env['hr.efficiency.indicator'].search([
  115. ('name', 'ilike', field.field_description)
  116. ], limit=1)
  117. if indicator:
  118. # Calculate indicator value using the indicator formula
  119. indicator_value = indicator.evaluate_formula(efficiency_data)
  120. # Set the value on the record - handle both computed and stored fields
  121. if field.name in record._fields:
  122. record[field.name] = float_round(indicator_value, 2)
  123. elif hasattr(record, field.name):
  124. setattr(record, field.name, float_round(indicator_value, 2))
  125. # Overall efficiency based on configured indicators
  126. record.overall_efficiency = self._calculate_overall_efficiency(record)
  127. # Update stored manual fields after computing
  128. self._update_stored_manual_fields()
  129. def _update_stored_manual_fields(self):
  130. """Update stored manual fields with computed values"""
  131. for record in self:
  132. # Get all manual fields for this model
  133. manual_fields = self.env['ir.model.fields'].search([
  134. ('model', '=', 'hr.efficiency'),
  135. ('state', '=', 'manual'),
  136. ('ttype', '=', 'float'),
  137. ('store', '=', True),
  138. ], order='id')
  139. # Prepare efficiency data
  140. efficiency_data = {
  141. 'available_hours': record.available_hours,
  142. 'planned_hours': record.planned_hours,
  143. 'planned_billable_hours': record.planned_billable_hours,
  144. 'planned_non_billable_hours': record.planned_non_billable_hours,
  145. 'actual_billable_hours': record.actual_billable_hours,
  146. 'actual_non_billable_hours': record.actual_non_billable_hours,
  147. 'expected_hours_to_date': record.expected_hours_to_date,
  148. }
  149. # Calculate and update stored manual fields
  150. for field in manual_fields:
  151. # Find the corresponding indicator
  152. indicator = self.env['hr.efficiency.indicator'].search([
  153. ('name', 'ilike', field.field_description)
  154. ], limit=1)
  155. if indicator and field.name in record._fields:
  156. # Calculate indicator value using the indicator formula
  157. indicator_value = indicator.evaluate_formula(efficiency_data)
  158. # Update the stored field value
  159. record[field.name] = float_round(indicator_value, 2)
  160. @api.model
  161. def _recompute_all_indicators(self):
  162. """Recompute all indicator fields for all records"""
  163. records = self.search([])
  164. if records:
  165. records._compute_indicators()
  166. def _get_indicator_field_name(self, indicator_name):
  167. """
  168. Convert indicator name to valid field name
  169. """
  170. # Remove special characters and convert to lowercase
  171. field_name = indicator_name.lower()
  172. field_name = field_name.replace(' ', '_').replace('-', '_').replace('(', '').replace(')', '')
  173. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  174. field_name = field_name.replace('ñ', 'n')
  175. # Ensure it starts with x_ for manual fields
  176. if not field_name.startswith('x_'):
  177. field_name = 'x_' + field_name
  178. return field_name
  179. @api.model
  180. def _create_default_dynamic_fields(self):
  181. """
  182. Create default dynamic field records for existing indicators
  183. """
  184. # Get all active indicators
  185. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  186. for indicator in indicators:
  187. # Check if field already exists in ir.model.fields
  188. field_name = indicator.name.lower().replace(' ', '_')
  189. field_name = field_name.replace('í', 'i').replace('á', 'a').replace('é', 'e').replace('ó', 'o').replace('ú', 'u')
  190. field_name = field_name.replace('ñ', 'n')
  191. # Ensure it starts with x_ for manual fields
  192. if not field_name.startswith('x_'):
  193. field_name = 'x_' + field_name
  194. existing_field = self.env['ir.model.fields'].search([
  195. ('model', '=', 'hr.efficiency'),
  196. ('name', '=', field_name)
  197. ], limit=1)
  198. if not existing_field:
  199. # Create field in ir.model.fields (like Studio does)
  200. self.env['ir.model.fields'].create({
  201. 'name': field_name,
  202. 'model': 'hr.efficiency',
  203. 'model_id': self.env['ir.model'].search([('model', '=', 'hr.efficiency')], limit=1).id,
  204. 'ttype': 'float',
  205. 'field_description': indicator.name,
  206. 'state': 'manual', # This is the key - it makes it a custom field
  207. 'store': True,
  208. 'compute': '_compute_indicators',
  209. })
  210. def _calculate_overall_efficiency(self, record):
  211. """
  212. Calculate overall efficiency based on configured indicators
  213. """
  214. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  215. if not indicators:
  216. # Default calculation if no indicators configured
  217. return 0.0
  218. # Check if there's any data to calculate
  219. if (record.available_hours == 0 and
  220. record.planned_hours == 0 and
  221. record.actual_billable_hours == 0 and
  222. record.actual_non_billable_hours == 0):
  223. return 0.0
  224. total_weight = 0
  225. weighted_sum = 0
  226. valid_indicators = 0
  227. efficiency_data = {
  228. 'available_hours': record.available_hours,
  229. 'planned_hours': record.planned_hours,
  230. 'planned_billable_hours': record.planned_billable_hours,
  231. 'planned_non_billable_hours': record.planned_non_billable_hours,
  232. 'actual_billable_hours': record.actual_billable_hours,
  233. 'actual_non_billable_hours': record.actual_non_billable_hours,
  234. 'expected_hours_to_date': record.expected_hours_to_date,
  235. }
  236. for indicator in indicators:
  237. if indicator.weight > 0:
  238. indicator_value = indicator.evaluate_formula(efficiency_data)
  239. # Only count indicators with valid values (not None or 0 when there's data)
  240. if indicator_value is not None and indicator_value > 0:
  241. weighted_sum += indicator_value * indicator.weight
  242. total_weight += indicator.weight
  243. valid_indicators += 1
  244. # If no valid indicators or no total weight, return 0
  245. if total_weight <= 0 or valid_indicators == 0:
  246. return 0.0
  247. # Multiply by 100 to show as percentage
  248. return float_round((weighted_sum / total_weight) * 100, 2)
  249. @api.depends('employee_id', 'month_year')
  250. def _compute_display_name(self):
  251. for record in self:
  252. if record.employee_id and record.month_year:
  253. record.display_name = f"{record.employee_id.name} - {record.month_year}"
  254. else:
  255. record.display_name = "New Efficiency Record"
  256. @api.model
  257. def _calculate_employee_efficiency(self, employee, month_year):
  258. """
  259. Calculate efficiency for a specific employee and month
  260. """
  261. # Parse month_year (format: YYYY-MM)
  262. try:
  263. year, month = month_year.split('-')
  264. start_date = date(int(year), int(month), 1)
  265. end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
  266. except ValueError:
  267. raise UserError(_("Invalid month_year format. Expected: YYYY-MM"))
  268. # Calculate available hours (considering holidays and time off)
  269. available_hours = self._calculate_available_hours(employee, start_date, end_date)
  270. # Calculate planned hours
  271. planned_hours, planned_billable_hours, planned_non_billable_hours = self._calculate_planned_hours(employee, start_date, end_date)
  272. # Calculate actual hours
  273. actual_billable_hours, actual_non_billable_hours = self._calculate_actual_hours(employee, start_date, end_date)
  274. return {
  275. 'month_year': month_year,
  276. 'employee_id': employee.id,
  277. 'company_id': employee.company_id.id,
  278. 'available_hours': available_hours,
  279. 'planned_hours': planned_hours,
  280. 'planned_billable_hours': planned_billable_hours,
  281. 'planned_non_billable_hours': planned_non_billable_hours,
  282. 'actual_billable_hours': actual_billable_hours,
  283. 'actual_non_billable_hours': actual_non_billable_hours,
  284. }
  285. def _calculate_available_hours(self, employee, start_date, end_date):
  286. """
  287. Calculate available hours considering holidays and time off
  288. """
  289. if not employee.resource_calendar_id:
  290. return 0.0
  291. # Convert dates to datetime for the method
  292. start_datetime = datetime.combine(start_date, datetime.min.time())
  293. end_datetime = datetime.combine(end_date, datetime.max.time())
  294. # Get working hours from calendar
  295. work_hours_data = employee._list_work_time_per_day(start_datetime, end_datetime)
  296. total_work_hours = sum(hours for _, hours in work_hours_data[employee.id])
  297. # Subtract hours from approved time off
  298. time_off_hours = self._get_time_off_hours(employee, start_date, end_date)
  299. return max(0.0, total_work_hours - time_off_hours)
  300. def _get_time_off_hours(self, employee, start_date, end_date):
  301. """
  302. Get hours from approved time off requests
  303. """
  304. # Get approved time off requests
  305. leaves = self.env['hr.leave'].search([
  306. ('employee_id', '=', employee.id),
  307. ('state', '=', 'validate'),
  308. ('date_from', '<=', end_date),
  309. ('date_to', '>=', start_date),
  310. ])
  311. total_hours = 0.0
  312. for leave in leaves:
  313. # Calculate overlap with the month
  314. overlap_start = max(leave.date_from.date(), start_date)
  315. overlap_end = min(leave.date_to.date(), end_date)
  316. if overlap_start <= overlap_end:
  317. # Get hours for the overlap period
  318. overlap_start_dt = datetime.combine(overlap_start, datetime.min.time())
  319. overlap_end_dt = datetime.combine(overlap_end, datetime.max.time())
  320. work_hours_data = employee._list_work_time_per_day(overlap_start_dt, overlap_end_dt)
  321. total_hours += sum(hours for _, hours in work_hours_data[employee.id])
  322. return total_hours
  323. def _calculate_planned_hours(self, employee, start_date, end_date):
  324. """
  325. Calculate planned hours from planning module
  326. """
  327. # Get planning slots for the employee in the date range
  328. planning_slots = self.env['planning.slot'].search([
  329. ('employee_id', '=', employee.id),
  330. ('start_datetime', '>=', datetime.combine(start_date, datetime.min.time())),
  331. ('end_datetime', '<=', datetime.combine(end_date, datetime.max.time())),
  332. ('state', 'in', ['draft', 'published']),
  333. ])
  334. total_planned = 0.0
  335. total_billable = 0.0
  336. total_non_billable = 0.0
  337. for slot in planning_slots:
  338. hours = slot.allocated_hours or 0.0
  339. # Check if the slot is linked to a billable project
  340. if slot.project_id and slot.project_id.allow_billable:
  341. total_billable += hours
  342. else:
  343. total_non_billable += hours
  344. total_planned += hours
  345. return total_planned, total_billable, total_non_billable
  346. def _calculate_actual_hours(self, employee, start_date, end_date):
  347. """
  348. Calculate actual hours from timesheets
  349. """
  350. # Get timesheets for the employee in the date range (excluding time off)
  351. timesheets = self.env['account.analytic.line'].search([
  352. ('employee_id', '=', employee.id),
  353. ('date', '>=', start_date),
  354. ('date', '<=', end_date),
  355. ('project_id', '!=', False), # Only project timesheets
  356. ('holiday_id', '=', False), # Exclude time off timesheets
  357. ])
  358. total_billable = 0.0
  359. total_non_billable = 0.0
  360. for timesheet in timesheets:
  361. hours = timesheet.unit_amount or 0.0
  362. # Additional filter: exclude time off tasks
  363. if timesheet.task_id and timesheet.task_id.name and 'time off' in timesheet.task_id.name.lower():
  364. continue # Skip time off tasks
  365. # Additional filter: exclude time off from name
  366. if timesheet.name and 'tiempo personal' in timesheet.name.lower():
  367. continue # Skip personal time entries
  368. # Check if the project is billable
  369. if timesheet.project_id and timesheet.project_id.allow_billable:
  370. total_billable += hours
  371. else:
  372. total_non_billable += hours
  373. return total_billable, total_non_billable
  374. @api.model
  375. def calculate_efficiency_for_period(self, start_month=None, end_month=None):
  376. """
  377. Calculate efficiency for all employees for a given period
  378. """
  379. if not start_month:
  380. # Default: last 3 months and next 6 months
  381. current_date = date.today()
  382. start_month = (current_date - relativedelta(months=3)).strftime('%Y-%m')
  383. end_month = (current_date + relativedelta(months=6)).strftime('%Y-%m')
  384. # Generate list of months
  385. months = self._generate_month_list(start_month, end_month)
  386. # Get all active employees of type 'employee'
  387. employees = self.env['hr.employee'].search([
  388. ('active', '=', True),
  389. ('employee_type', '=', 'employee')
  390. ])
  391. created_records = []
  392. for employee in employees:
  393. for month in months:
  394. # Calculate efficiency data
  395. efficiency_data = self._calculate_employee_efficiency(employee, month)
  396. # Check if there are changes compared to the latest record
  397. latest_record = self.search([
  398. ('employee_id', '=', employee.id),
  399. ('month_year', '=', month),
  400. ('company_id', '=', employee.company_id.id),
  401. ], order='calculation_date desc', limit=1)
  402. has_changes = False
  403. if latest_record:
  404. # Compare current data with latest record
  405. fields_to_compare = [
  406. 'available_hours', 'planned_hours', 'planned_billable_hours',
  407. 'planned_non_billable_hours', 'actual_billable_hours',
  408. 'actual_non_billable_hours'
  409. ]
  410. for field in fields_to_compare:
  411. if abs(efficiency_data[field] - latest_record[field]) > 0.01: # Tolerance for floating point
  412. has_changes = True
  413. break
  414. else:
  415. # No previous record exists, so this is a change
  416. has_changes = True
  417. # Only create new record if there are changes
  418. if has_changes:
  419. # Archive existing records for this employee and month
  420. existing_records = self.search([
  421. ('employee_id', '=', employee.id),
  422. ('month_year', '=', month),
  423. ('company_id', '=', employee.company_id.id),
  424. ])
  425. if existing_records:
  426. existing_records.write({'active': False})
  427. # Create new record
  428. new_record = self.create(efficiency_data)
  429. created_records.append(new_record)
  430. return {
  431. 'created': len(created_records),
  432. 'updated': 0, # No longer updating existing records
  433. 'total_processed': len(created_records),
  434. }
  435. @api.model
  436. def _init_dynamic_system(self):
  437. """
  438. Initialize dynamic fields and views when module is installed
  439. """
  440. import logging
  441. _logger = logging.getLogger(__name__)
  442. try:
  443. _logger.info("Starting dynamic system initialization...")
  444. # Step 1: Create dynamic field records for existing indicators
  445. self._create_default_dynamic_fields()
  446. _logger.info("Default dynamic fields created successfully")
  447. # Step 2: Dynamic fields are created via hr.efficiency.dynamic.field model
  448. _logger.info("Dynamic fields creation handled by hr.efficiency.dynamic.field model")
  449. # Step 3: Update views
  450. self._update_views_with_dynamic_fields()
  451. _logger.info("Views updated successfully")
  452. # Step 4: Force recompute of existing records
  453. records = self.search([])
  454. if records:
  455. records._invalidate_cache()
  456. _logger.info(f"Invalidated cache for {len(records)} records")
  457. _logger.info("Dynamic system initialization completed successfully")
  458. except Exception as e:
  459. _logger.error(f"Error during dynamic system initialization: {str(e)}")
  460. raise
  461. @api.model
  462. def _update_views_with_dynamic_fields(self):
  463. """
  464. Update inherited views to include dynamic fields after module is loaded
  465. """
  466. import logging
  467. _logger = logging.getLogger(__name__)
  468. try:
  469. # Get active indicators ordered by weight (descending)
  470. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='weight desc')
  471. fields_to_display = []
  472. for indicator in active_indicators:
  473. field_name = self._get_indicator_field_name(indicator.name)
  474. # Check if this indicator has a manual field
  475. manual_field = self.env['ir.model.fields'].search([
  476. ('model', '=', 'hr.efficiency'),
  477. ('state', '=', 'manual'),
  478. ('ttype', '=', 'float'),
  479. ('name', '=', field_name),
  480. ], limit=1)
  481. if manual_field:
  482. # Indicator with manual field
  483. fields_to_display.append({
  484. 'name': field_name,
  485. 'field_description': indicator.name,
  486. 'indicator': indicator
  487. })
  488. else:
  489. # Create manual field for this indicator
  490. _logger.info(f"Creating manual field for indicator '{indicator.name}'")
  491. self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
  492. # Add to display list after creation
  493. fields_to_display.append({
  494. 'name': field_name,
  495. 'field_description': indicator.name,
  496. 'indicator': indicator
  497. })
  498. _logger.info(f"Found {len(fields_to_display)} fields to add to views")
  499. # Build dynamic fields XML for list view
  500. dynamic_fields_xml = ''
  501. for field_info in fields_to_display:
  502. field_xml = f'<field name="{field_info["name"]}" widget="percentage" optional="hide"'
  503. # Add decorations based on indicator thresholds
  504. indicator = field_info['indicator']
  505. if indicator:
  506. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  507. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  508. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  509. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  510. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  511. field_xml += '/>'
  512. dynamic_fields_xml += field_xml
  513. # Update inherited list view
  514. inherited_list_view = self.env.ref('hr_efficiency.view_hr_efficiency_list_inherited', raise_if_not_found=False)
  515. if inherited_list_view:
  516. new_arch = f"""
  517. <xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">{dynamic_fields_xml}</xpath>
  518. """
  519. inherited_list_view.write({'arch': new_arch})
  520. _logger.info(f"Updated inherited list view with {len(fields_to_display)} dynamic fields")
  521. # Build dynamic fields XML for form view
  522. form_dynamic_fields_xml = ''
  523. for field_info in fields_to_display:
  524. field_xml = f'<field name="{field_info["name"]}" widget="badge"'
  525. # Add decorations based on indicator thresholds
  526. indicator = field_info['indicator']
  527. if indicator:
  528. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  529. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  530. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  531. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  532. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  533. field_xml += '/>'
  534. form_dynamic_fields_xml += field_xml
  535. # Update inherited form view
  536. inherited_form_view = self.env.ref('hr_efficiency.view_hr_efficiency_form_inherited', raise_if_not_found=False)
  537. if inherited_form_view:
  538. new_form_arch = f"""
  539. <xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">{form_dynamic_fields_xml}</xpath>
  540. """
  541. inherited_form_view.write({'arch': new_form_arch})
  542. _logger.info("Updated inherited form view with dynamic fields")
  543. except Exception as e:
  544. _logger.error(f"Error updating views with dynamic fields: {str(e)}")
  545. raise
  546. def _generate_month_list(self, start_month, end_month):
  547. """
  548. Generate list of months between start_month and end_month (inclusive)
  549. """
  550. months = []
  551. current = datetime.strptime(start_month, '%Y-%m')
  552. end = datetime.strptime(end_month, '%Y-%m')
  553. while current <= end:
  554. months.append(current.strftime('%Y-%m'))
  555. current = current + relativedelta(months=1)
  556. return months
  557. @api.model
  558. def _cron_calculate_efficiency(self):
  559. """
  560. Cron job to automatically calculate efficiency
  561. """
  562. self.calculate_efficiency_for_period()
  563. self._update_dynamic_filter_labels()
  564. @api.model
  565. def _update_dynamic_filter_labels(self):
  566. """
  567. Update dynamic filter labels based on current date
  568. """
  569. labels = self._get_dynamic_month_labels()
  570. # Update filter labels
  571. filter_mapping = {
  572. 'filter_two_months_ago': labels['two_months_ago'],
  573. 'filter_last_month': labels['last_month'],
  574. 'filter_current_month': labels['current_month'],
  575. 'filter_next_month': labels['next_month'],
  576. 'filter_two_months_ahead': labels['two_months_ahead'],
  577. }
  578. for filter_name, label in filter_mapping.items():
  579. try:
  580. filter_record = self.env.ref(f'hr_efficiency.{filter_name}', raise_if_not_found=False)
  581. if filter_record:
  582. filter_record.write({'name': label})
  583. except Exception as e:
  584. _logger.warning(f"Could not update filter {filter_name}: {str(e)}")
  585. @api.model
  586. def _get_month_filter_options(self):
  587. """
  588. Get dynamic month filter options for search view
  589. Returns a list of tuples (month_year, display_name) for the last 2, current, and next 2 months
  590. """
  591. current_date = date.today()
  592. months = []
  593. # Last 2 months
  594. for i in range(2, 0, -1):
  595. month_date = current_date - relativedelta(months=i)
  596. month_year = month_date.strftime('%Y-%m')
  597. month_name = month_date.strftime('%B %Y') # e.g., "August 2024"
  598. months.append((month_year, month_name))
  599. # Current month
  600. current_month_year = current_date.strftime('%Y-%m')
  601. current_month_name = current_date.strftime('%B %Y')
  602. months.append((current_month_year, current_month_name))
  603. # Next 2 months
  604. for i in range(1, 3):
  605. month_date = current_date + relativedelta(months=i)
  606. month_year = month_date.strftime('%Y-%m')
  607. month_name = month_date.strftime('%B %Y')
  608. months.append((month_year, month_name))
  609. return months
  610. @api.model
  611. def _get_dynamic_month_labels(self):
  612. """
  613. Get dynamic month labels for filters
  614. Returns a dictionary with month labels like "Mes 1", "Mes 2", "Mes 3 << actual", etc.
  615. """
  616. current_date = date.today()
  617. labels = {}
  618. # 2 months ago
  619. month_2_ago = current_date - relativedelta(months=2)
  620. labels['two_months_ago'] = f"Mes {month_2_ago.strftime('%m')}"
  621. # 1 month ago
  622. month_1_ago = current_date - relativedelta(months=1)
  623. labels['last_month'] = f"Mes {month_1_ago.strftime('%m')}"
  624. # Current month
  625. labels['current_month'] = f"Mes {current_date.strftime('%m')} << actual"
  626. # Next month
  627. month_1_ahead = current_date + relativedelta(months=1)
  628. labels['next_month'] = f"Mes {month_1_ahead.strftime('%m')}"
  629. # 2 months ahead
  630. month_2_ahead = current_date + relativedelta(months=2)
  631. labels['two_months_ahead'] = f"Mes {month_2_ahead.strftime('%m')}"
  632. return labels
  633. def _count_working_days(self, start_date, end_date, employee=None):
  634. """
  635. Count working days between two dates considering employee calendar
  636. """
  637. working_days = 0
  638. current_date = start_date
  639. while current_date <= end_date:
  640. # Check if it's a working day according to employee calendar
  641. if self._is_working_day(current_date, employee):
  642. working_days += 1
  643. current_date += relativedelta(days=1)
  644. return working_days
  645. def _is_working_day(self, check_date, employee=None):
  646. """
  647. Check if a date is a working day considering employee calendar
  648. """
  649. if not employee or not employee.resource_calendar_id:
  650. # Fallback to basic weekday check
  651. return check_date.weekday() < 5
  652. try:
  653. # Convert date to datetime for calendar check
  654. check_datetime = datetime.combine(check_date, datetime.min.time())
  655. # Check if the day is a working day according to employee calendar
  656. working_hours = employee.resource_calendar_id._list_work_time_per_day(
  657. check_datetime, check_datetime, compute_leaves=True
  658. )
  659. # If there are working hours, it's a working day
  660. return bool(working_hours)
  661. except Exception:
  662. # Fallback to basic weekday check if there's any error
  663. return check_date.weekday() < 5