hr_efficiency.py 37 KB

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