hr_efficiency.py 38 KB

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