hr_efficiency.py 39 KB

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