hr_efficiency.py 57 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  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. # Employee contract information
  28. wage = fields.Float('Gross Salary', digits=(10, 2), aggregator='sum', help="Employee's gross salary from their contract at the time of calculation")
  29. currency_id = fields.Many2one('res.currency', 'Currency', help="Currency for the wage field")
  30. # Employee utilization and company overhead (stored at calculation time)
  31. utilization_rate = fields.Float('Utilization Rate (%)', digits=(5, 2), default=100.0, aggregator='avg', help="Employee's utilization rate at the time of calculation")
  32. overhead = fields.Float('Company Overhead (%)', digits=(5, 2), default=40.0, aggregator='avg', help="Company's overhead percentage at the time of calculation")
  33. # Planned hours (what was planned)
  34. planned_hours = fields.Float('Planned Hours', digits=(10, 2), help="Total hours planned for the month")
  35. planned_billable_hours = fields.Float('Planned Billable Hours', digits=(10, 2), help="Hours planned on billable projects")
  36. planned_non_billable_hours = fields.Float('Planned Non-Billable Hours', digits=(10, 2), help="Hours planned on non-billable projects")
  37. # Actual hours
  38. actual_billable_hours = fields.Float('Actual Billable Hours', digits=(10, 2), help="Hours actually worked on billable projects")
  39. actual_non_billable_hours = fields.Float('Actual Non-Billable Hours', digits=(10, 2), help="Hours actually worked on non-billable projects")
  40. # Calculated fields (stored for performance)
  41. total_actual_hours = fields.Float('Total Actual Hours', digits=(10, 2), help="Total actual hours (billable + non-billable)")
  42. expected_hours_to_date = fields.Float('Expected Hours to Date', digits=(10, 2), help='Hours that should be registered based on planning until current date')
  43. # Precio por Hora (stored field)
  44. precio_por_hora = fields.Float('Precio por Hora', digits=(10, 2), help='Precio que cobramos al cliente por hora (costo + overhead + 30% rentabilidad)', aggregator='avg')
  45. # Dynamic indicator fields (will be created automatically)
  46. # These fields are managed dynamically based on hr.efficiency.indicator records
  47. # Overall efficiency (always present) - Now stored fields calculated on save
  48. overall_efficiency = fields.Float('Overall Efficiency (%)', digits=(5, 2), help='Overall efficiency based on configured indicators')
  49. overall_efficiency_display = fields.Char('Overall Efficiency Display', help='Overall efficiency formatted for display with badge widget')
  50. display_name = fields.Char('Display Name', compute='_compute_display_name', store=True)
  51. # Note: Removed unique constraint to allow historical tracking
  52. # Multiple records can exist for the same employee and month
  53. @api.depends('month_year')
  54. def _compute_date(self):
  55. """
  56. Compute date field from month_year for standard Odoo date filters
  57. """
  58. for record in self:
  59. if record.month_year:
  60. try:
  61. year, month = record.month_year.split('-')
  62. record.date = date(int(year), int(month), 1)
  63. except (ValueError, AttributeError):
  64. record.date = False
  65. else:
  66. record.date = False
  67. def _calculate_expected_hours_to_date(self, record):
  68. """
  69. Calculate expected hours to date based on available hours and working days
  70. """
  71. if not record.month_year or not record.available_hours or not record.employee_id.contract_id:
  72. return 0.0
  73. try:
  74. # Parse month_year (format: YYYY-MM)
  75. year, month = record.month_year.split('-')
  76. start_date = date(int(year), int(month), 1)
  77. end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
  78. # Get current date
  79. current_date = date.today()
  80. # If current date is outside the month, use end of month
  81. if current_date > end_date:
  82. calculation_date = end_date
  83. elif current_date < start_date:
  84. calculation_date = start_date
  85. else:
  86. calculation_date = current_date
  87. # Calculate working days in the month
  88. total_working_days = self._count_working_days(start_date, end_date, record.employee_id)
  89. # Calculate working days until current date
  90. working_days_until_date = self._count_working_days(start_date, calculation_date, record.employee_id)
  91. if total_working_days > 0:
  92. # Calculate expected hours based on available hours (what employee should work)
  93. expected_hours = (record.available_hours / total_working_days) * working_days_until_date
  94. # Ensure we don't exceed available hours for the month
  95. expected_hours = min(expected_hours, record.available_hours)
  96. return float_round(expected_hours, 2)
  97. else:
  98. return 0.0
  99. except (ValueError, AttributeError) as e:
  100. return 0.0
  101. def _calculate_precio_por_hora(self, record):
  102. """
  103. Calculate precio por hora: (wage / available_hours) * (1 + overhead/100) * 1.30
  104. """
  105. try:
  106. wage = getattr(record, 'wage', 0.0) or 0.0
  107. available_hours = getattr(record, 'available_hours', 0.0) or 0.0
  108. overhead = getattr(record, 'overhead', 40.0) or 40.0
  109. if available_hours > 0:
  110. # Calculate cost per hour
  111. cost_per_hour = wage / available_hours
  112. # Apply overhead and 30% profit margin
  113. precio_por_hora = cost_per_hour * (1 + (overhead / 100)) * 1.30
  114. return float_round(precio_por_hora, 2)
  115. else:
  116. return 0.0
  117. except (ValueError, AttributeError, ZeroDivisionError) as e:
  118. return 0.0
  119. def _calculate_all_indicators(self):
  120. """
  121. Calculate all indicators and overall efficiency for stored fields
  122. This method is called on create/write instead of using computed fields
  123. """
  124. # Prepare values to update without triggering write recursively
  125. values_to_update = {}
  126. # Pre-fetch necessary fields to avoid CacheMiss during iteration
  127. fields_to_load = [
  128. 'available_hours', 'planned_hours', 'planned_billable_hours',
  129. 'planned_non_billable_hours', 'actual_billable_hours',
  130. 'actual_non_billable_hours', 'total_actual_hours',
  131. 'expected_hours_to_date', 'wage', 'utilization_rate', 'overhead',
  132. 'precio_por_hora', 'employee_id', 'company_id', 'month_year', 'active',
  133. ]
  134. # Load fields for all records in 'self' with error handling
  135. try:
  136. self.read(fields_to_load)
  137. except Exception as e:
  138. import logging
  139. _logger = logging.getLogger(__name__)
  140. _logger.warning(f"Error loading fields: {e}")
  141. # Continue with empty cache if needed
  142. for record in self:
  143. # Get all manual fields for this model
  144. manual_fields = self.env['ir.model.fields'].search([
  145. ('model', '=', 'hr.efficiency'),
  146. ('state', '=', 'manual'),
  147. ('ttype', '=', 'float')
  148. ])
  149. # Prepare efficiency data with safe field access
  150. efficiency_data = {
  151. 'available_hours': getattr(record, 'available_hours', 0.0) or 0.0,
  152. 'planned_hours': getattr(record, 'planned_hours', 0.0) or 0.0,
  153. 'planned_billable_hours': getattr(record, 'planned_billable_hours', 0.0) or 0.0,
  154. 'planned_non_billable_hours': getattr(record, 'planned_non_billable_hours', 0.0) or 0.0,
  155. 'actual_billable_hours': getattr(record, 'actual_billable_hours', 0.0) or 0.0,
  156. 'actual_non_billable_hours': getattr(record, 'actual_non_billable_hours', 0.0) or 0.0,
  157. 'total_actual_hours': getattr(record, 'total_actual_hours', 0.0) or 0.0,
  158. 'expected_hours_to_date': getattr(record, 'expected_hours_to_date', 0.0) or 0.0,
  159. 'wage': getattr(record, 'wage', 0.0) or 0.0,
  160. 'utilization_rate': getattr(record, 'utilization_rate', 100.0) or 100.0,
  161. 'overhead': getattr(record, 'overhead', 40.0) or 40.0,
  162. 'precio_por_hora': getattr(record, 'precio_por_hora', 0.0) or 0.0,
  163. }
  164. # STEP 1: Calculate total_actual_hours FIRST (needed for indicators)
  165. try:
  166. total_actual_hours = getattr(record, 'actual_billable_hours', 0.0) + getattr(record, 'actual_non_billable_hours', 0.0)
  167. except Exception as e:
  168. import logging
  169. _logger = logging.getLogger(__name__)
  170. _logger.error(f"Error calculating total_actual_hours for record {record.id}: {e}")
  171. total_actual_hours = 0.0
  172. record_values = {'total_actual_hours': float_round(total_actual_hours, 2)}
  173. # STEP 2: Calculate expected_hours_to_date
  174. expected_hours = self._calculate_expected_hours_to_date(record)
  175. record_values['expected_hours_to_date'] = expected_hours
  176. # STEP 3: Calculate precio_por_hora (needed for profitability indicators)
  177. precio_por_hora = self._calculate_precio_por_hora(record)
  178. record_values['precio_por_hora'] = precio_por_hora
  179. # STEP 4: Update efficiency_data with ALL calculated base fields
  180. efficiency_data.update({
  181. 'total_actual_hours': total_actual_hours,
  182. 'expected_hours_to_date': expected_hours,
  183. 'precio_por_hora': precio_por_hora,
  184. })
  185. # STEP 5: Calculate all indicators dynamically (in sequence order)
  186. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  187. for indicator in active_indicators:
  188. field_name = self._get_indicator_field_name(indicator.name)
  189. # Check if the field exists in the record
  190. if field_name in record._fields:
  191. # Calculate indicator value using the indicator formula
  192. indicator_value = indicator.evaluate_formula(efficiency_data)
  193. # Store the value to update later
  194. record_values[field_name] = float_round(indicator_value, 2)
  195. # Calculate overall efficiency
  196. overall_efficiency = self._calculate_overall_efficiency(record)
  197. record_values['overall_efficiency'] = overall_efficiency
  198. # Calculate overall efficiency display
  199. if overall_efficiency == 0:
  200. record_values['overall_efficiency_display'] = '0.00'
  201. else:
  202. record_values['overall_efficiency_display'] = f"{overall_efficiency:.2f}"
  203. # Store values for this record
  204. values_to_update[record.id] = record_values
  205. # Update all records at once to avoid recursion
  206. for record_id, values in values_to_update.items():
  207. record = self.browse(record_id)
  208. # Use direct SQL update to avoid triggering write method
  209. if values:
  210. record.env.cr.execute(
  211. "UPDATE hr_efficiency SET " +
  212. ", ".join([f"{key} = %s" for key in values.keys()]) +
  213. " WHERE id = %s",
  214. list(values.values()) + [record_id]
  215. )
  216. # Update aggregation fields after calculating indicators
  217. self._update_aggregation_fields()
  218. def _update_aggregation_fields(self):
  219. """
  220. Actualiza los campos de agregación con los valores de los indicadores dinámicos
  221. """
  222. try:
  223. for record in self:
  224. # Obtener todos los indicadores activos (en orden de secuencia)
  225. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  226. for indicator in active_indicators:
  227. field_name = self._get_indicator_field_name(indicator.name)
  228. agg_field_name = f"{field_name}_agg"
  229. # Verificar si el campo de agregación existe
  230. if hasattr(record, agg_field_name):
  231. # Obtener el valor del indicador dinámico
  232. indicator_value = getattr(record, field_name, 0.0) or 0.0
  233. # Actualizar el campo de agregación usando SQL directo
  234. record.env.cr.execute(
  235. "UPDATE hr_efficiency SET %s = %%s WHERE id = %%s" % agg_field_name,
  236. [indicator_value, record.id]
  237. )
  238. except Exception as e:
  239. import logging
  240. _logger = logging.getLogger(__name__)
  241. _logger.warning(f"Error updating aggregation fields: {str(e)}")
  242. def _update_stored_manual_fields(self):
  243. """Update stored manual fields with computed values"""
  244. for record in self:
  245. # Get all manual fields for this model
  246. manual_fields = self.env['ir.model.fields'].search([
  247. ('model', '=', 'hr.efficiency'),
  248. ('state', '=', 'manual'),
  249. ('ttype', '=', 'float'),
  250. ('store', '=', True),
  251. ], order='id')
  252. # Prepare efficiency data with safe field access
  253. efficiency_data = {
  254. 'available_hours': record.available_hours or 0.0,
  255. 'planned_hours': record.planned_hours or 0.0,
  256. 'planned_billable_hours': record.planned_billable_hours or 0.0,
  257. 'planned_non_billable_hours': record.planned_non_billable_hours or 0.0,
  258. 'actual_billable_hours': record.actual_billable_hours or 0.0,
  259. 'actual_non_billable_hours': record.actual_non_billable_hours or 0.0,
  260. 'total_actual_hours': record.total_actual_hours or 0.0,
  261. 'expected_hours_to_date': record.expected_hours_to_date or 0.0,
  262. 'wage': record.wage or 0.0,
  263. 'utilization_rate': record.utilization_rate or 100.0,
  264. 'overhead': record.overhead or 40.0,
  265. }
  266. # Calculate and update stored manual fields
  267. for field in manual_fields:
  268. # Find the corresponding indicator
  269. indicator = self.env['hr.efficiency.indicator'].search([
  270. ('name', 'ilike', field.field_description)
  271. ], limit=1)
  272. if indicator and field.name in record._fields:
  273. # Calculate indicator value using the indicator formula
  274. indicator_value = indicator.evaluate_formula(efficiency_data)
  275. # Update the stored field value
  276. record[field.name] = float_round(indicator_value, 2)
  277. @api.model
  278. def _recompute_all_indicators(self):
  279. """Recompute all indicator fields for all records"""
  280. records = self.search([])
  281. if records:
  282. records._calculate_all_indicators()
  283. def _get_indicator_field_name(self, indicator_name):
  284. """
  285. Convert indicator name to valid field name
  286. """
  287. import re
  288. # Remove special characters and convert to lowercase
  289. field_name = indicator_name.lower()
  290. # Replace spaces, hyphens, and other special characters with underscores
  291. field_name = re.sub(r'[^a-z0-9_]', '_', field_name)
  292. # Remove multiple consecutive underscores
  293. field_name = re.sub(r'_+', '_', field_name)
  294. # Remove leading and trailing underscores
  295. field_name = field_name.strip('_')
  296. # Ensure it starts with x_ for manual fields
  297. if not field_name.startswith('x_'):
  298. field_name = 'x_' + field_name
  299. # Ensure it doesn't exceed 63 characters (PostgreSQL limit)
  300. if len(field_name) > 63:
  301. field_name = field_name[:63]
  302. return field_name
  303. @api.model
  304. def _create_default_dynamic_fields(self):
  305. """
  306. Create default dynamic field records for existing indicators
  307. """
  308. # Get all active indicators
  309. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  310. for indicator in indicators:
  311. # Check if field already exists in ir.model.fields
  312. field_name = self._get_indicator_field_name(indicator.name)
  313. existing_field = self.env['ir.model.fields'].search([
  314. ('model', '=', 'hr.efficiency'),
  315. ('name', '=', field_name)
  316. ], limit=1)
  317. if not existing_field:
  318. # Create field in ir.model.fields (like Studio does)
  319. self.env['ir.model.fields'].create({
  320. 'name': field_name,
  321. 'model': 'hr.efficiency',
  322. 'model_id': self.env['ir.model'].search([('model', '=', 'hr.efficiency')], limit=1).id,
  323. 'ttype': 'float',
  324. 'field_description': indicator.name,
  325. 'state': 'manual', # This is the key - it makes it a custom field
  326. 'store': True,
  327. 'compute': '_compute_indicators',
  328. })
  329. def _calculate_overall_efficiency(self, record):
  330. """
  331. Calculate overall efficiency based on configured indicators
  332. """
  333. indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  334. if not indicators:
  335. # Default calculation if no indicators configured
  336. return 0.0
  337. # Check if there's any data to calculate
  338. if (record.available_hours == 0 and
  339. record.planned_hours == 0 and
  340. record.actual_billable_hours == 0 and
  341. record.actual_non_billable_hours == 0):
  342. return 0.0
  343. total_weight = 0
  344. weighted_sum = 0
  345. valid_indicators = 0
  346. efficiency_data = {
  347. 'available_hours': getattr(record, 'available_hours', 0.0) or 0.0,
  348. 'planned_hours': getattr(record, 'planned_hours', 0.0) or 0.0,
  349. 'planned_billable_hours': getattr(record, 'planned_billable_hours', 0.0) or 0.0,
  350. 'planned_non_billable_hours': getattr(record, 'planned_non_billable_hours', 0.0) or 0.0,
  351. 'actual_billable_hours': getattr(record, 'actual_billable_hours', 0.0) or 0.0,
  352. 'actual_non_billable_hours': getattr(record, 'actual_non_billable_hours', 0.0) or 0.0,
  353. 'total_actual_hours': getattr(record, 'total_actual_hours', 0.0) or 0.0,
  354. 'expected_hours_to_date': getattr(record, 'expected_hours_to_date', 0.0) or 0.0,
  355. 'wage': getattr(record, 'wage', 0.0) or 0.0,
  356. 'utilization_rate': getattr(record, 'utilization_rate', 100.0) or 100.0,
  357. 'overhead': getattr(record, 'overhead', 40.0) or 40.0,
  358. }
  359. for indicator in indicators:
  360. if indicator.weight > 0:
  361. indicator_value = indicator.evaluate_formula(efficiency_data)
  362. # Count indicators with valid values (including 0 when it's a valid result)
  363. if indicator_value is not None:
  364. weighted_sum += indicator_value * indicator.weight
  365. total_weight += indicator.weight
  366. valid_indicators += 1
  367. # If no valid indicators or no total weight, return 0
  368. if total_weight <= 0 or valid_indicators == 0:
  369. return 0.0
  370. # Multiply by 100 to show as percentage
  371. return float_round((weighted_sum / total_weight) * 100, 2)
  372. @api.depends('employee_id', 'month_year')
  373. def _compute_display_name(self):
  374. for record in self:
  375. if record.employee_id and record.month_year:
  376. record.display_name = f"{record.employee_id.name} - {record.month_year}"
  377. else:
  378. record.display_name = "New Efficiency Record"
  379. @api.model
  380. def _calculate_employee_efficiency(self, employee, month_year):
  381. """
  382. Calculate efficiency for a specific employee and month
  383. """
  384. # Parse month_year (format: YYYY-MM)
  385. try:
  386. year, month = month_year.split('-')
  387. start_date = date(int(year), int(month), 1)
  388. end_date = (start_date + relativedelta(months=1)) - relativedelta(days=1)
  389. except ValueError:
  390. raise UserError(_("Invalid month_year format. Expected: YYYY-MM"))
  391. # Calculate available hours (considering holidays and time off)
  392. available_hours = self._calculate_available_hours(employee, start_date, end_date)
  393. # Calculate planned hours
  394. planned_hours, planned_billable_hours, planned_non_billable_hours = self._calculate_planned_hours(employee, start_date, end_date)
  395. # Calculate actual hours
  396. actual_billable_hours, actual_non_billable_hours = self._calculate_actual_hours(employee, start_date, end_date)
  397. # Calculate wage and currency from employee's contract
  398. # Use current date for wage calculation (when the record is being created/calculated)
  399. wage_date = date.today()
  400. wage, currency_id = self._get_employee_wage_and_currency(employee, wage_date)
  401. # Get employee's utilization rate and company's overhead at calculation time
  402. utilization_rate = employee.utilization_rate or 100.0
  403. overhead = employee.company_id.overhead or 40.0
  404. # Apply utilization_rate to actual_billable_hours to reflect real billable capacity
  405. adjusted_actual_billable_hours = actual_billable_hours * (utilization_rate / 100)
  406. return {
  407. 'month_year': month_year,
  408. 'employee_id': employee.id,
  409. 'company_id': employee.company_id.id,
  410. 'available_hours': available_hours,
  411. 'planned_hours': planned_hours,
  412. 'planned_billable_hours': planned_billable_hours,
  413. 'planned_non_billable_hours': planned_non_billable_hours,
  414. 'actual_billable_hours': adjusted_actual_billable_hours,
  415. 'actual_non_billable_hours': actual_non_billable_hours,
  416. 'wage': wage,
  417. 'currency_id': currency_id,
  418. 'utilization_rate': utilization_rate,
  419. 'overhead': overhead,
  420. }
  421. @api.model
  422. def _get_employee_wage_and_currency(self, employee, target_date):
  423. """
  424. Get employee's wage and currency from their contract for a specific date
  425. Always take the contract that is active on the target date
  426. If no active contract on that date, return 0 with company currency
  427. """
  428. if not employee:
  429. return 0.0, self.env.company.currency_id.id
  430. # Get all contracts for the employee
  431. contracts = self.env['hr.contract'].search([
  432. ('employee_id', '=', employee.id),
  433. ('state', '=', 'open')
  434. ], order='date_start desc')
  435. if not contracts:
  436. return 0.0, self.env.company.currency_id.id
  437. # Find the contract that is active on the target date
  438. for contract in contracts:
  439. contract_start = contract.date_start
  440. # Check if contract is active on target date
  441. if contract.date_end:
  442. # Contract has end date
  443. if contract_start <= target_date <= contract.date_end:
  444. wage = contract.wage or 0.0
  445. currency_id = contract.currency_id.id if contract.currency_id else self.env.company.currency_id.id
  446. return wage, currency_id
  447. else:
  448. # Contract has no end date, it's active for any date after start
  449. if contract_start <= target_date:
  450. wage = contract.wage or 0.0
  451. currency_id = contract.currency_id.id if contract.currency_id else self.env.company.currency_id.id
  452. return wage, currency_id
  453. # If no contract is active on the target date, return defaults
  454. return 0.0, self.env.company.currency_id.id
  455. def _calculate_available_hours(self, employee, start_date, end_date):
  456. """
  457. Calculate available hours considering holidays and time off
  458. """
  459. if not employee.resource_calendar_id:
  460. return 0.0
  461. # Convert dates to datetime for the method
  462. start_datetime = datetime.combine(start_date, datetime.min.time())
  463. end_datetime = datetime.combine(end_date, datetime.max.time())
  464. # Get working hours from calendar
  465. work_hours_data = employee._list_work_time_per_day(start_datetime, end_datetime)
  466. total_work_hours = sum(hours for _, hours in work_hours_data[employee.id])
  467. # Subtract hours from approved time off
  468. time_off_hours = self._get_time_off_hours(employee, start_date, end_date)
  469. return max(0.0, total_work_hours - time_off_hours)
  470. def _get_time_off_hours(self, employee, start_date, end_date):
  471. """
  472. Get hours from approved time off requests
  473. """
  474. # Get approved time off requests
  475. leaves = self.env['hr.leave'].search([
  476. ('employee_id', '=', employee.id),
  477. ('state', '=', 'validate'),
  478. ('date_from', '<=', end_date),
  479. ('date_to', '>=', start_date),
  480. ])
  481. total_hours = 0.0
  482. for leave in leaves:
  483. # Calculate overlap with the month
  484. overlap_start = max(leave.date_from.date(), start_date)
  485. overlap_end = min(leave.date_to.date(), end_date)
  486. if overlap_start <= overlap_end:
  487. # Get hours for the overlap period
  488. overlap_start_dt = datetime.combine(overlap_start, datetime.min.time())
  489. overlap_end_dt = datetime.combine(overlap_end, datetime.max.time())
  490. work_hours_data = employee._list_work_time_per_day(overlap_start_dt, overlap_end_dt)
  491. total_hours += sum(hours for _, hours in work_hours_data[employee.id])
  492. return total_hours
  493. def _calculate_planned_hours(self, employee, start_date, end_date):
  494. """
  495. Calculate planned hours from planning module
  496. """
  497. # Get planning slots for the employee that overlap with the date range
  498. # This is the same logic as Odoo Planning's Gantt chart
  499. start_datetime = datetime.combine(start_date, datetime.min.time())
  500. end_datetime = datetime.combine(end_date, datetime.max.time())
  501. planning_slots = self.env['planning.slot'].search([
  502. ('employee_id', '=', employee.id),
  503. ('start_datetime', '<=', end_datetime),
  504. ('end_datetime', '>=', start_datetime),
  505. # Removed state restriction to include all planning slots regardless of state
  506. ])
  507. total_planned = 0.0
  508. total_billable = 0.0
  509. total_non_billable = 0.0
  510. # Get working intervals for the resource and company calendar
  511. # This is the same approach as Odoo Planning's Gantt chart
  512. start_utc = pytz.utc.localize(start_datetime)
  513. end_utc = pytz.utc.localize(end_datetime)
  514. if employee.resource_id:
  515. resource_work_intervals, calendar_work_intervals = employee.resource_id._get_valid_work_intervals(
  516. start_utc, end_utc, calendars=employee.company_id.resource_calendar_id
  517. )
  518. else:
  519. # Fallback to company calendar if no resource
  520. calendar_work_intervals = {employee.company_id.resource_calendar_id.id: []}
  521. resource_work_intervals = {}
  522. for slot in planning_slots:
  523. # Use the same logic as Odoo Planning's Gantt chart
  524. # Calculate duration only within the specified period
  525. hours = slot._get_duration_over_period(
  526. start_utc, end_utc,
  527. resource_work_intervals, calendar_work_intervals, has_allocated_hours=False
  528. )
  529. # Check if the slot is linked to a billable project
  530. if slot.project_id and slot.project_id.allow_billable:
  531. total_billable += hours
  532. else:
  533. total_non_billable += hours
  534. total_planned += hours
  535. return total_planned, total_billable, total_non_billable
  536. def _calculate_actual_hours(self, employee, start_date, end_date):
  537. """
  538. Calculate actual hours from timesheets
  539. """
  540. # Get timesheets for the employee in the date range (excluding time off)
  541. timesheets = self.env['account.analytic.line'].search([
  542. ('employee_id', '=', employee.id),
  543. ('date', '>=', start_date),
  544. ('date', '<=', end_date),
  545. ('project_id', '!=', False), # Only project timesheets
  546. ('holiday_id', '=', False), # Exclude time off timesheets
  547. ])
  548. total_billable = 0.0
  549. total_non_billable = 0.0
  550. for timesheet in timesheets:
  551. hours = timesheet.unit_amount or 0.0
  552. # Additional filter: exclude time off tasks
  553. if timesheet.task_id and timesheet.task_id.name and 'time off' in timesheet.task_id.name.lower():
  554. continue # Skip time off tasks
  555. # Additional filter: exclude time off from name
  556. if timesheet.name and 'tiempo personal' in timesheet.name.lower():
  557. continue # Skip personal time entries
  558. # Check if the project is billable
  559. if timesheet.project_id and timesheet.project_id.allow_billable:
  560. total_billable += hours
  561. else:
  562. total_non_billable += hours
  563. return total_billable, total_non_billable
  564. @api.model
  565. def calculate_efficiency_for_period(self, start_month=None, end_month=None):
  566. """
  567. Calculate efficiency for all employees for a given period
  568. """
  569. if not start_month:
  570. # Default: last 3 months and next 6 months
  571. current_date = date.today()
  572. start_month = (current_date - relativedelta(months=3)).strftime('%Y-%m')
  573. end_month = (current_date + relativedelta(months=6)).strftime('%Y-%m')
  574. # Generate list of months
  575. months = self._generate_month_list(start_month, end_month)
  576. # Get all active employees of type 'employee'
  577. employees = self.env['hr.employee'].search([
  578. ('active', '=', True),
  579. ('employee_type', '=', 'employee')
  580. ])
  581. created_records = []
  582. for employee in employees:
  583. for month in months:
  584. # Calculate efficiency data
  585. efficiency_data = self._calculate_employee_efficiency(employee, month)
  586. # Check if there are changes compared to the latest record
  587. latest_record = self.search([
  588. ('employee_id', '=', employee.id),
  589. ('month_year', '=', month),
  590. ('company_id', '=', employee.company_id.id),
  591. ], order='calculation_date desc', limit=1)
  592. has_changes = False
  593. if latest_record:
  594. # Compare current data with latest record
  595. fields_to_compare = [
  596. 'available_hours', 'planned_hours', 'planned_billable_hours',
  597. 'planned_non_billable_hours', 'actual_billable_hours',
  598. 'actual_non_billable_hours'
  599. ]
  600. # Check basic fields
  601. for field in fields_to_compare:
  602. if abs(efficiency_data[field] - latest_record[field]) > 0.01: # Tolerance for floating point
  603. has_changes = True
  604. break
  605. # If no changes in basic fields, check dynamic indicators
  606. if not has_changes:
  607. # Get all active indicators
  608. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  609. for indicator in active_indicators:
  610. field_name = self._get_indicator_field_name(indicator.name)
  611. # Calculate current indicator value
  612. current_value = indicator.evaluate_formula(efficiency_data)
  613. # Get previous indicator value from record
  614. previous_value = getattr(latest_record, field_name, None)
  615. # Compare values with tolerance
  616. if (current_value is not None and previous_value is not None and
  617. abs(current_value - previous_value) > 0.01):
  618. has_changes = True
  619. break
  620. elif current_value != previous_value: # Handle None vs value cases
  621. has_changes = True
  622. break
  623. else:
  624. # No previous record exists, so this is a change
  625. has_changes = True
  626. # Only create new record if there are changes
  627. if has_changes:
  628. # Archive existing records for this employee and month
  629. existing_records = self.search([
  630. ('employee_id', '=', employee.id),
  631. ('month_year', '=', month),
  632. ('company_id', '=', employee.company_id.id),
  633. ])
  634. if existing_records:
  635. existing_records.write({'active': False})
  636. # Create new record
  637. new_record = self.create(efficiency_data)
  638. created_records.append(new_record)
  639. # Calculate indicators for all newly created records
  640. if created_records:
  641. # Convert list to recordset
  642. created_recordset = self.browse([record.id for record in created_records])
  643. created_recordset._calculate_all_indicators()
  644. return {
  645. 'created': len(created_records),
  646. 'updated': 0, # No longer updating existing records
  647. 'total_processed': len(created_records),
  648. }
  649. @api.model
  650. def _init_dynamic_system(self):
  651. """
  652. Initialize dynamic fields and views when module is installed
  653. """
  654. import logging
  655. _logger = logging.getLogger(__name__)
  656. try:
  657. _logger.info("Starting dynamic system initialization...")
  658. # Step 1: Create dynamic field records for existing indicators
  659. self._create_default_dynamic_fields()
  660. _logger.info("Default dynamic fields created successfully")
  661. # Step 2: Dynamic fields are created via hr.efficiency.dynamic.field model
  662. _logger.info("Dynamic fields creation handled by hr.efficiency.dynamic.field model")
  663. # Step 3: Update views
  664. self._update_views_with_dynamic_fields()
  665. _logger.info("Views updated successfully")
  666. # Step 4: Force recompute of existing records
  667. records = self.search([])
  668. if records:
  669. records._invalidate_cache()
  670. _logger.info(f"Invalidated cache for {len(records)} records")
  671. _logger.info("Dynamic system initialization completed successfully")
  672. except Exception as e:
  673. _logger.error(f"Error during dynamic system initialization: {str(e)}")
  674. raise
  675. @api.model
  676. def _post_init_hook(self):
  677. """
  678. Post-install hook to ensure dynamic fields are created for existing indicators
  679. """
  680. import logging
  681. _logger = logging.getLogger(__name__)
  682. try:
  683. _logger.info("Running post-install hook for hr_efficiency module")
  684. # Ensure all active indicators have manual fields
  685. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  686. for indicator in active_indicators:
  687. self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
  688. # Update views with dynamic fields
  689. self._update_views_with_dynamic_fields()
  690. # Apply default values to existing records
  691. self._apply_default_values_to_existing_records()
  692. _logger.info(f"Post-install hook completed. Processed {len(active_indicators)} indicators")
  693. except Exception as e:
  694. _logger.error(f"Error in post-install hook: {str(e)}")
  695. raise
  696. @api.model
  697. def create(self, vals_list):
  698. """
  699. Override create to calculate indicators when records are created
  700. """
  701. records = super().create(vals_list)
  702. # Calculate indicators for newly created records
  703. if records:
  704. records._calculate_all_indicators()
  705. return records
  706. def write(self, vals):
  707. """
  708. Override write to recalculate indicators when records are updated
  709. """
  710. result = super().write(vals)
  711. # Recalculate indicators for updated records
  712. self._calculate_all_indicators()
  713. return result
  714. @api.model
  715. def _register_hook(self):
  716. """
  717. Called when the registry is loaded.
  718. Update views with dynamic fields on every module load/restart.
  719. """
  720. super()._register_hook()
  721. try:
  722. # Ensure aggregation fields exist for dynamic indicators
  723. self._ensure_aggregation_fields_exist()
  724. # Update views with current dynamic fields on every module load
  725. self._update_views_with_dynamic_fields()
  726. except Exception as e:
  727. # Log error but don't prevent module loading
  728. import logging
  729. _logger = logging.getLogger(__name__)
  730. _logger.warning(f"Could not update dynamic views on module load: {str(e)}")
  731. @api.model
  732. def _ensure_aggregation_fields_exist(self):
  733. """
  734. Asegura que existan campos de agregación para todos los indicadores activos
  735. """
  736. try:
  737. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  738. for indicator in active_indicators:
  739. field_name = self._get_indicator_field_name(indicator.name)
  740. agg_field_name = f"{field_name}_agg"
  741. # Verificar si el campo de agregación ya existe en ir.model.fields
  742. agg_field_record = self.env['ir.model.fields'].search([
  743. ('model', '=', 'hr.efficiency'),
  744. ('name', '=', agg_field_name)
  745. ], limit=1)
  746. if not agg_field_record:
  747. # Determinar tipo de agregación basado en el tipo de indicador
  748. agg_type = 'avg' if indicator.indicator_type == 'percentage' else 'sum'
  749. # Obtener el model_id para hr.efficiency
  750. model_record = self.env['ir.model'].search([('model', '=', 'hr.efficiency')], limit=1)
  751. if model_record:
  752. # Crear el campo de agregación
  753. self.env['ir.model.fields'].create({
  754. 'name': agg_field_name,
  755. 'model_id': model_record.id,
  756. 'field_description': f'{indicator.name} (Agregación)',
  757. 'ttype': 'float',
  758. 'state': 'manual',
  759. 'store': True,
  760. 'help': f'Campo de agregación para {indicator.name}'
  761. })
  762. import logging
  763. _logger = logging.getLogger(__name__)
  764. _logger.info(f"Created aggregation field: {agg_field_name} for indicator: {indicator.name}")
  765. else:
  766. import logging
  767. _logger = logging.getLogger(__name__)
  768. _logger.warning(f"Model hr.efficiency not found for aggregation field: {agg_field_name}")
  769. import logging
  770. _logger = logging.getLogger(__name__)
  771. _logger.info(f"Created aggregation field: {agg_field_name} for indicator: {indicator.name}")
  772. except Exception as e:
  773. import logging
  774. _logger = logging.getLogger(__name__)
  775. _logger.warning(f"Error creating aggregation fields: {str(e)}")
  776. @api.model
  777. def _update_views_with_dynamic_fields(self):
  778. """
  779. Update inherited views to include dynamic fields after module is loaded
  780. """
  781. import logging
  782. _logger = logging.getLogger(__name__)
  783. try:
  784. # Get active indicators ordered by sequence (consistent with other parts)
  785. active_indicators = self.env['hr.efficiency.indicator'].search([('active', '=', True)], order='sequence')
  786. fields_to_display = []
  787. for indicator in active_indicators:
  788. field_name = self._get_indicator_field_name(indicator.name)
  789. # Check if this indicator has a manual field
  790. manual_field = self.env['ir.model.fields'].search([
  791. ('model', '=', 'hr.efficiency'),
  792. ('state', '=', 'manual'),
  793. ('ttype', '=', 'float'),
  794. ('name', '=', field_name),
  795. ], limit=1)
  796. if manual_field:
  797. # Indicator with manual field
  798. fields_to_display.append({
  799. 'name': field_name,
  800. 'field_description': indicator.name,
  801. 'indicator': indicator
  802. })
  803. else:
  804. # Create manual field for this indicator
  805. _logger.info(f"Creating manual field for indicator '{indicator.name}'")
  806. self.env['hr.efficiency.indicator']._create_dynamic_field(indicator)
  807. # Add to display list after creation
  808. fields_to_display.append({
  809. 'name': field_name,
  810. 'field_description': indicator.name,
  811. 'indicator': indicator
  812. })
  813. _logger.info(f"Found {len(fields_to_display)} fields to add to views")
  814. # Build dynamic fields XML for list view
  815. dynamic_fields_xml = ''
  816. for field_info in fields_to_display:
  817. # Determine widget based on indicator type
  818. indicator = field_info['indicator']
  819. widget_map = {
  820. 'percentage': 'percentage', # Changed back to 'percentage' to show % symbol
  821. 'hours': 'float_time',
  822. 'currency': 'monetary',
  823. 'number': 'float'
  824. }
  825. widget = widget_map.get(indicator.indicator_type, 'float') if indicator else 'float'
  826. field_xml = f'<field name="{field_info["name"]}" widget="{widget}" optional="show"'
  827. # Add field name as string (tooltip will be shown automatically from field description)
  828. if indicator:
  829. field_xml += f' string="{indicator.name}"'
  830. # Add decorations based on indicator thresholds
  831. if indicator:
  832. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  833. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  834. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  835. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  836. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  837. field_xml += '/>'
  838. dynamic_fields_xml += field_xml
  839. # Add aggregation field for this indicator
  840. agg_field_name = f"{field_info['name']}_agg"
  841. agg_type = 'avg' if indicator.indicator_type == 'percentage' else 'sum'
  842. agg_label = f"Total {indicator.name}" if agg_type == 'sum' else f"Average {indicator.name}"
  843. agg_field_xml = f'<field name="{agg_field_name}" {agg_type}="{agg_label}" optional="hide" invisible="1"/>'
  844. dynamic_fields_xml += agg_field_xml
  845. # Update inherited list view
  846. inherited_list_view = self.env.ref('hr_efficiency.view_hr_efficiency_list_inherited', raise_if_not_found=False)
  847. if inherited_list_view:
  848. if dynamic_fields_xml:
  849. new_arch = f"""
  850. <xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">{dynamic_fields_xml}</xpath>
  851. """
  852. else:
  853. # If no dynamic fields, remove any existing dynamic fields from the view
  854. new_arch = """
  855. <xpath expr=\"//field[@name='expected_hours_to_date']\" position=\"after\">
  856. <!-- No dynamic fields to display -->
  857. </xpath>
  858. """
  859. inherited_list_view.write({'arch': new_arch})
  860. _logger.info(f"Updated inherited list view with {len(fields_to_display)} dynamic fields")
  861. # Build dynamic fields XML for form view
  862. form_dynamic_fields_xml = ''
  863. for field_info in fields_to_display:
  864. # Determine widget based on indicator type
  865. indicator = field_info['indicator']
  866. widget_map = {
  867. 'percentage': 'badge',
  868. 'hours': 'float_time',
  869. 'currency': 'monetary',
  870. 'number': 'float'
  871. }
  872. widget = widget_map.get(indicator.indicator_type, 'badge') if indicator else 'badge'
  873. field_xml = f'<field name="{field_info["name"]}" widget="{widget}"'
  874. # Add help text with indicator description (valid in form views)
  875. if indicator and indicator.description:
  876. # Escape quotes in description for XML
  877. help_text = indicator.description.replace('"', '&quot;')
  878. field_xml += f' help="{help_text}"'
  879. # Add decorations based on indicator thresholds
  880. if indicator:
  881. # Use standard efficiency ranges: >=0.9 green, >=0.8 yellow, >=0.7 orange, <0.7 red or zero
  882. field_xml += f' decoration-success="{field_info["name"]} &gt;= 0.9"'
  883. field_xml += f' decoration-warning="{field_info["name"]} &gt;= 0.8 and {field_info["name"]} &lt; 0.9"'
  884. field_xml += f' decoration-info="{field_info["name"]} &gt;= 0.7 and {field_info["name"]} &lt; 0.8"'
  885. field_xml += f' decoration-danger="{field_info["name"]} &lt; 0.7 or {field_info["name"]} == 0"'
  886. field_xml += '/>'
  887. form_dynamic_fields_xml += field_xml
  888. # Update inherited form view
  889. inherited_form_view = self.env.ref('hr_efficiency.view_hr_efficiency_form_inherited', raise_if_not_found=False)
  890. if inherited_form_view:
  891. if form_dynamic_fields_xml:
  892. new_form_arch = f"""
  893. <xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">{form_dynamic_fields_xml}</xpath>
  894. """
  895. else:
  896. # If no dynamic fields, remove any existing dynamic fields from the view
  897. new_form_arch = """
  898. <xpath expr=\"//field[@name='overall_efficiency']\" position=\"before\">
  899. <!-- No dynamic fields to display -->
  900. </xpath>
  901. """
  902. inherited_form_view.write({'arch': new_form_arch})
  903. _logger.info("Updated inherited form view with dynamic fields")
  904. except Exception as e:
  905. _logger.error(f"Error updating views with dynamic fields: {str(e)}")
  906. raise
  907. def _generate_month_list(self, start_month, end_month):
  908. """
  909. Generate list of months between start_month and end_month (inclusive)
  910. """
  911. months = []
  912. # Convert date objects to datetime if needed
  913. if isinstance(start_month, date):
  914. start_month = start_month.strftime('%Y-%m')
  915. if isinstance(end_month, date):
  916. end_month = end_month.strftime('%Y-%m')
  917. current = datetime.strptime(start_month, '%Y-%m')
  918. end = datetime.strptime(end_month, '%Y-%m')
  919. while current <= end:
  920. months.append(current.strftime('%Y-%m'))
  921. current = current + relativedelta(months=1)
  922. return months
  923. @api.model
  924. def _cron_calculate_efficiency(self):
  925. """
  926. Cron job to automatically calculate efficiency
  927. """
  928. self.calculate_efficiency_for_period()
  929. self._update_dynamic_filter_labels()
  930. @api.model
  931. def _update_dynamic_filter_labels(self):
  932. """
  933. Update dynamic filter labels based on current date
  934. """
  935. labels = self._get_dynamic_month_labels()
  936. # Update filter labels
  937. filter_mapping = {
  938. 'filter_two_months_ago': labels['two_months_ago'],
  939. 'filter_last_month': labels['last_month'],
  940. 'filter_current_month': labels['current_month'],
  941. 'filter_next_month': labels['next_month'],
  942. 'filter_two_months_ahead': labels['two_months_ahead'],
  943. }
  944. for filter_name, label in filter_mapping.items():
  945. try:
  946. filter_record = self.env.ref(f'hr_efficiency.{filter_name}', raise_if_not_found=False)
  947. if filter_record:
  948. filter_record.write({'name': label})
  949. except Exception as e:
  950. _logger.warning(f"Could not update filter {filter_name}: {str(e)}")
  951. @api.model
  952. def _get_month_filter_options(self):
  953. """
  954. Get dynamic month filter options for search view
  955. Returns a list of tuples (month_year, display_name) for the last 2, current, and next 2 months
  956. """
  957. current_date = date.today()
  958. months = []
  959. # Last 2 months
  960. for i in range(2, 0, -1):
  961. month_date = current_date - relativedelta(months=i)
  962. month_year = month_date.strftime('%Y-%m')
  963. month_name = month_date.strftime('%B %Y') # e.g., "August 2024"
  964. months.append((month_year, month_name))
  965. # Current month
  966. current_month_year = current_date.strftime('%Y-%m')
  967. current_month_name = current_date.strftime('%B %Y')
  968. months.append((current_month_year, current_month_name))
  969. # Next 2 months
  970. for i in range(1, 3):
  971. month_date = current_date + relativedelta(months=i)
  972. month_year = month_date.strftime('%Y-%m')
  973. month_name = month_date.strftime('%B %Y')
  974. months.append((month_year, month_name))
  975. return months
  976. @api.model
  977. def _get_dynamic_month_labels(self):
  978. """
  979. Get dynamic month labels for filters
  980. Returns a dictionary with month labels like "Mes 1", "Mes 2", "Mes 3 << actual", etc.
  981. """
  982. current_date = date.today()
  983. labels = {}
  984. # 2 months ago
  985. month_2_ago = current_date - relativedelta(months=2)
  986. labels['two_months_ago'] = f"Mes {month_2_ago.strftime('%m')}"
  987. # 1 month ago
  988. month_1_ago = current_date - relativedelta(months=1)
  989. labels['last_month'] = f"Mes {month_1_ago.strftime('%m')}"
  990. # Current month
  991. labels['current_month'] = f"Mes {current_date.strftime('%m')} << actual"
  992. # Next month
  993. month_1_ahead = current_date + relativedelta(months=1)
  994. labels['next_month'] = f"Mes {month_1_ahead.strftime('%m')}"
  995. # 2 months ahead
  996. month_2_ahead = current_date + relativedelta(months=2)
  997. labels['two_months_ahead'] = f"Mes {month_2_ahead.strftime('%m')}"
  998. return labels
  999. def _count_working_days(self, start_date, end_date, employee=None):
  1000. """
  1001. Count working days between two dates considering employee calendar
  1002. """
  1003. working_days = 0
  1004. current_date = start_date
  1005. while current_date <= end_date:
  1006. # Check if it's a working day according to employee calendar
  1007. if self._is_working_day(current_date, employee):
  1008. working_days += 1
  1009. current_date += relativedelta(days=1)
  1010. return working_days
  1011. def _is_working_day(self, check_date, employee=None):
  1012. """
  1013. Check if a date is a working day considering employee calendar
  1014. """
  1015. if not employee or not employee.resource_calendar_id:
  1016. # Fallback to basic weekday check
  1017. return check_date.weekday() < 5
  1018. try:
  1019. # Convert date to datetime for calendar check
  1020. check_datetime = datetime.combine(check_date, datetime.min.time())
  1021. # Check if the day is a working day according to employee calendar
  1022. working_hours = employee.resource_calendar_id._list_work_time_per_day(
  1023. check_datetime, check_datetime, compute_leaves=True
  1024. )
  1025. # If there are working hours, it's a working day
  1026. return bool(working_hours)
  1027. except Exception:
  1028. # Fallback to basic weekday check if there's any error
  1029. return check_date.weekday() < 5
  1030. @api.model
  1031. def _apply_default_values_to_existing_records(self):
  1032. """Apply default values to existing employee and company records"""
  1033. import logging
  1034. _logger = logging.getLogger(__name__)
  1035. try:
  1036. # Update employees with utilization_rate = 100.0 if not set
  1037. employees_to_update = self.env['hr.employee'].search([
  1038. ('utilization_rate', '=', 0.0)
  1039. ])
  1040. if employees_to_update:
  1041. employees_to_update.write({'utilization_rate': 100.0})
  1042. _logger.info(f"Updated {len(employees_to_update)} employees with default utilization_rate = 100.0%")
  1043. # Update companies with overhead = 40.0 if not set
  1044. companies_to_update = self.env['res.company'].search([
  1045. ('overhead', '=', 0.0)
  1046. ])
  1047. if companies_to_update:
  1048. companies_to_update.write({'overhead': 40.0})
  1049. _logger.info(f"Updated {len(companies_to_update)} companies with default overhead = 40.0%")
  1050. _logger.info("Default values applied successfully to existing records")
  1051. except Exception as e:
  1052. _logger.error(f"Error applying default values to existing records: {str(e)}")
  1053. # Don't raise the exception to avoid breaking the installation