hr_efficiency.py 38 KB

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