hr_efficiency.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836
  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. # Count indicators with valid values (including 0 when it's a valid result)
  240. if indicator_value is not None:
  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. # Check basic fields
  411. for field in fields_to_compare:
  412. if abs(efficiency_data[field] - latest_record[field]) > 0.01: # Tolerance for floating point
  413. has_changes = True
  414. break
  415. # If no changes in basic fields, check dynamic indicators
  416. if not has_changes:
  417. # Get all active indicators
  418. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)])
  419. for indicator in active_indicators:
  420. field_name = self._get_indicator_field_name(indicator.name)
  421. # Calculate current indicator value
  422. current_value = indicator.evaluate_formula(efficiency_data)
  423. # Get previous indicator value from record
  424. previous_value = getattr(latest_record, field_name, None)
  425. # Compare values with tolerance
  426. if (current_value is not None and previous_value is not None and
  427. abs(current_value - previous_value) > 0.01):
  428. has_changes = True
  429. break
  430. elif current_value != previous_value: # Handle None vs value cases
  431. has_changes = True
  432. break
  433. else:
  434. # No previous record exists, so this is a change
  435. has_changes = True
  436. # Only create new record if there are changes
  437. if has_changes:
  438. # Archive existing records for this employee and month
  439. existing_records = self.search([
  440. ('employee_id', '=', employee.id),
  441. ('month_year', '=', month),
  442. ('company_id', '=', employee.company_id.id),
  443. ])
  444. if existing_records:
  445. existing_records.write({'active': False})
  446. # Create new record
  447. new_record = self.create(efficiency_data)
  448. created_records.append(new_record)
  449. return {
  450. 'created': len(created_records),
  451. 'updated': 0, # No longer updating existing records
  452. 'total_processed': len(created_records),
  453. }
  454. @api.model
  455. def _init_dynamic_system(self):
  456. """
  457. Initialize dynamic fields and views when module is installed
  458. """
  459. import logging
  460. _logger = logging.getLogger(__name__)
  461. try:
  462. _logger.info("Starting dynamic system initialization...")
  463. # Step 1: Create dynamic field records for existing indicators
  464. self._create_default_dynamic_fields()
  465. _logger.info("Default dynamic fields created successfully")
  466. # Step 2: Dynamic fields are created via hr.efficiency.dynamic.field model
  467. _logger.info("Dynamic fields creation handled by hr.efficiency.dynamic.field model")
  468. # Step 3: Update views
  469. self._update_views_with_dynamic_fields()
  470. _logger.info("Views updated successfully")
  471. # Step 4: Force recompute of existing records
  472. records = self.search([])
  473. if records:
  474. records._invalidate_cache()
  475. _logger.info(f"Invalidated cache for {len(records)} records")
  476. _logger.info("Dynamic system initialization completed successfully")
  477. except Exception as e:
  478. _logger.error(f"Error during dynamic system initialization: {str(e)}")
  479. raise
  480. @api.model
  481. def _update_views_with_dynamic_fields(self):
  482. """
  483. Update inherited views to include dynamic fields after module is loaded
  484. """
  485. import logging
  486. _logger = logging.getLogger(__name__)
  487. try:
  488. # Get active indicators ordered by weight (descending)
  489. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='weight desc')
  490. fields_to_display = []
  491. for indicator in active_indicators:
  492. field_name = self._get_indicator_field_name(indicator.name)
  493. # Check if this indicator has a manual field
  494. manual_field = self.env['ir.model.fields'].search([
  495. ('model', '=', 'hr.efficiency'),
  496. ('state', '=', 'manual'),
  497. ('ttype', '=', 'float'),
  498. ('name', '=', field_name),
  499. ], limit=1)
  500. if manual_field:
  501. # Indicator with manual field
  502. fields_to_display.append({
  503. 'name': field_name,
  504. 'field_description': indicator.name,
  505. 'indicator': indicator
  506. })
  507. else:
  508. # Create manual field for this indicator
  509. _logger.info(f"Creating manual field for indicator '{indicator.name}'")
  510. self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
  511. # Add to display list after creation
  512. fields_to_display.append({
  513. 'name': field_name,
  514. 'field_description': indicator.name,
  515. 'indicator': indicator
  516. })
  517. _logger.info(f"Found {len(fields_to_display)} fields to add to views")
  518. # Build dynamic fields XML for list view
  519. dynamic_fields_xml = ''
  520. for field_info in fields_to_display:
  521. field_xml = f'<field name="{field_info["name"]}" widget="percentage" optional="hide"'
  522. # Add decorations based on indicator thresholds
  523. indicator = field_info['indicator']
  524. if indicator:
  525. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  526. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  527. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  528. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  529. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  530. field_xml += '/>'
  531. dynamic_fields_xml += field_xml
  532. # Update inherited list view
  533. inherited_list_view = self.env.ref('hr_efficiency.view_hr_efficiency_list_inherited', raise_if_not_found=False)
  534. if inherited_list_view:
  535. new_arch = f"""
  536. <xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">{dynamic_fields_xml}</xpath>
  537. """
  538. inherited_list_view.write({'arch': new_arch})
  539. _logger.info(f"Updated inherited list view with {len(fields_to_display)} dynamic fields")
  540. # Build dynamic fields XML for form view
  541. form_dynamic_fields_xml = ''
  542. for field_info in fields_to_display:
  543. field_xml = f'<field name="{field_info["name"]}" widget="badge"'
  544. # Add decorations based on indicator thresholds
  545. indicator = field_info['indicator']
  546. if indicator:
  547. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  548. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  549. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  550. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  551. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  552. field_xml += '/>'
  553. form_dynamic_fields_xml += field_xml
  554. # Update inherited form view
  555. inherited_form_view = self.env.ref('hr_efficiency.view_hr_efficiency_form_inherited', raise_if_not_found=False)
  556. if inherited_form_view:
  557. new_form_arch = f"""
  558. <xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">{form_dynamic_fields_xml}</xpath>
  559. """
  560. inherited_form_view.write({'arch': new_form_arch})
  561. _logger.info("Updated inherited form view with dynamic fields")
  562. except Exception as e:
  563. _logger.error(f"Error updating views with dynamic fields: {str(e)}")
  564. raise
  565. def _generate_month_list(self, start_month, end_month):
  566. """
  567. Generate list of months between start_month and end_month (inclusive)
  568. """
  569. months = []
  570. current = datetime.strptime(start_month, '%Y-%m')
  571. end = datetime.strptime(end_month, '%Y-%m')
  572. while current <= end:
  573. months.append(current.strftime('%Y-%m'))
  574. current = current + relativedelta(months=1)
  575. return months
  576. @api.model
  577. def _cron_calculate_efficiency(self):
  578. """
  579. Cron job to automatically calculate efficiency
  580. """
  581. self.calculate_efficiency_for_period()
  582. self._update_dynamic_filter_labels()
  583. @api.model
  584. def _update_dynamic_filter_labels(self):
  585. """
  586. Update dynamic filter labels based on current date
  587. """
  588. labels = self._get_dynamic_month_labels()
  589. # Update filter labels
  590. filter_mapping = {
  591. 'filter_two_months_ago': labels['two_months_ago'],
  592. 'filter_last_month': labels['last_month'],
  593. 'filter_current_month': labels['current_month'],
  594. 'filter_next_month': labels['next_month'],
  595. 'filter_two_months_ahead': labels['two_months_ahead'],
  596. }
  597. for filter_name, label in filter_mapping.items():
  598. try:
  599. filter_record = self.env.ref(f'hr_efficiency.{filter_name}', raise_if_not_found=False)
  600. if filter_record:
  601. filter_record.write({'name': label})
  602. except Exception as e:
  603. _logger.warning(f"Could not update filter {filter_name}: {str(e)}")
  604. @api.model
  605. def _get_month_filter_options(self):
  606. """
  607. Get dynamic month filter options for search view
  608. Returns a list of tuples (month_year, display_name) for the last 2, current, and next 2 months
  609. """
  610. current_date = date.today()
  611. months = []
  612. # Last 2 months
  613. for i in range(2, 0, -1):
  614. month_date = current_date - relativedelta(months=i)
  615. month_year = month_date.strftime('%Y-%m')
  616. month_name = month_date.strftime('%B %Y') # e.g., "August 2024"
  617. months.append((month_year, month_name))
  618. # Current month
  619. current_month_year = current_date.strftime('%Y-%m')
  620. current_month_name = current_date.strftime('%B %Y')
  621. months.append((current_month_year, current_month_name))
  622. # Next 2 months
  623. for i in range(1, 3):
  624. month_date = current_date + relativedelta(months=i)
  625. month_year = month_date.strftime('%Y-%m')
  626. month_name = month_date.strftime('%B %Y')
  627. months.append((month_year, month_name))
  628. return months
  629. @api.model
  630. def _get_dynamic_month_labels(self):
  631. """
  632. Get dynamic month labels for filters
  633. Returns a dictionary with month labels like "Mes 1", "Mes 2", "Mes 3 << actual", etc.
  634. """
  635. current_date = date.today()
  636. labels = {}
  637. # 2 months ago
  638. month_2_ago = current_date - relativedelta(months=2)
  639. labels['two_months_ago'] = f"Mes {month_2_ago.strftime('%m')}"
  640. # 1 month ago
  641. month_1_ago = current_date - relativedelta(months=1)
  642. labels['last_month'] = f"Mes {month_1_ago.strftime('%m')}"
  643. # Current month
  644. labels['current_month'] = f"Mes {current_date.strftime('%m')} << actual"
  645. # Next month
  646. month_1_ahead = current_date + relativedelta(months=1)
  647. labels['next_month'] = f"Mes {month_1_ahead.strftime('%m')}"
  648. # 2 months ahead
  649. month_2_ahead = current_date + relativedelta(months=2)
  650. labels['two_months_ahead'] = f"Mes {month_2_ahead.strftime('%m')}"
  651. return labels
  652. def _count_working_days(self, start_date, end_date, employee=None):
  653. """
  654. Count working days between two dates considering employee calendar
  655. """
  656. working_days = 0
  657. current_date = start_date
  658. while current_date <= end_date:
  659. # Check if it's a working day according to employee calendar
  660. if self._is_working_day(current_date, employee):
  661. working_days += 1
  662. current_date += relativedelta(days=1)
  663. return working_days
  664. def _is_working_day(self, check_date, employee=None):
  665. """
  666. Check if a date is a working day considering employee calendar
  667. """
  668. if not employee or not employee.resource_calendar_id:
  669. # Fallback to basic weekday check
  670. return check_date.weekday() < 5
  671. try:
  672. # Convert date to datetime for calendar check
  673. check_datetime = datetime.combine(check_date, datetime.min.time())
  674. # Check if the day is a working day according to employee calendar
  675. working_hours = employee.resource_calendar_id._list_work_time_per_day(
  676. check_datetime, check_datetime, compute_leaves=True
  677. )
  678. # If there are working hours, it's a working day
  679. return bool(working_hours)
  680. except Exception:
  681. # Fallback to basic weekday check if there's any error
  682. return check_date.weekday() < 5