hr_efficiency.py 56 KB

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