hr_timesheet.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from collections import defaultdict
  4. from lxml import etree
  5. import re
  6. from odoo import api, Command, fields, models, _, _lt
  7. from odoo.exceptions import UserError, AccessError, ValidationError
  8. from odoo.osv import expression
  9. class AccountAnalyticLine(models.Model):
  10. _inherit = 'account.analytic.line'
  11. @api.model
  12. def _get_favorite_project_id(self, employee_id=False):
  13. employee_id = employee_id or self.env.user.employee_id.id
  14. last_timesheet_ids = self.search([
  15. ('employee_id', '=', employee_id),
  16. ('project_id', '!=', False),
  17. ], limit=5)
  18. if len(last_timesheet_ids.project_id) == 1:
  19. return last_timesheet_ids.project_id.id
  20. return False
  21. @api.model
  22. def default_get(self, field_list):
  23. result = super(AccountAnalyticLine, self).default_get(field_list)
  24. if not self.env.context.get('default_employee_id') and 'employee_id' in field_list and result.get('user_id'):
  25. result['employee_id'] = self.env['hr.employee'].search([('user_id', '=', result['user_id']), ('company_id', '=', result.get('company_id', self.env.company.id))], limit=1).id
  26. if not self._context.get('default_project_id') and self._context.get('is_timesheet'):
  27. employee_id = result.get('employee_id', self.env.context.get('default_employee_id', False))
  28. favorite_project_id = self._get_favorite_project_id(employee_id)
  29. if favorite_project_id:
  30. result['project_id'] = favorite_project_id
  31. return result
  32. def _domain_project_id(self):
  33. domain = [('allow_timesheets', '=', True)]
  34. if not self.user_has_groups('hr_timesheet.group_timesheet_manager'):
  35. return expression.AND([domain,
  36. ['|', ('privacy_visibility', '!=', 'followers'), ('message_partner_ids', 'in', [self.env.user.partner_id.id])]
  37. ])
  38. return domain
  39. def _domain_employee_id(self):
  40. if not self.user_has_groups('hr_timesheet.group_hr_timesheet_approver'):
  41. return [('user_id', '=', self.env.user.id)]
  42. return []
  43. task_id = fields.Many2one(
  44. 'project.task', 'Task', index='btree_not_null',
  45. compute='_compute_task_id', store=True, readonly=False,
  46. domain="[('project_id.allow_timesheets', '=', True), ('project_id', '=?', project_id)]")
  47. ancestor_task_id = fields.Many2one('project.task', related='task_id.ancestor_id', store=True, index='btree_not_null')
  48. project_id = fields.Many2one(
  49. 'project.project', 'Project', domain=_domain_project_id, index=True,
  50. compute='_compute_project_id', store=True, readonly=False)
  51. user_id = fields.Many2one(compute='_compute_user_id', store=True, readonly=False)
  52. employee_id = fields.Many2one('hr.employee', "Employee", domain=_domain_employee_id, context={'active_test': False},
  53. help="Define an 'hourly cost' on the employee to track the cost of their time.")
  54. job_title = fields.Char(related='employee_id.job_title')
  55. department_id = fields.Many2one('hr.department', "Department", compute='_compute_department_id', store=True, compute_sudo=True)
  56. manager_id = fields.Many2one('hr.employee', "Manager", related='employee_id.parent_id', store=True)
  57. encoding_uom_id = fields.Many2one('uom.uom', compute='_compute_encoding_uom_id')
  58. partner_id = fields.Many2one(compute='_compute_partner_id', store=True, readonly=False)
  59. def name_get(self):
  60. result = super().name_get()
  61. timesheets_read = self.env[self._name].search_read([('project_id', '!=', False), ('id', 'in', self.ids)], ['id', 'project_id', 'task_id'])
  62. if not timesheets_read:
  63. return result
  64. def _get_display_name(project_id, task_id):
  65. """ Get the display name of the timesheet based on the project and task
  66. :param project_id: tuple containing the id and the display name of the project
  67. :param task_id: tuple containing the id and the display name of the task if a task exists in the timesheet
  68. otherwise False.
  69. :returns: the display name of the timesheet
  70. """
  71. if task_id:
  72. return '%s - %s' % (project_id[1], task_id[1])
  73. return project_id[1]
  74. timesheet_dict = {res['id']: _get_display_name(res['project_id'], res['task_id']) for res in timesheets_read}
  75. return list({**dict(result), **timesheet_dict}.items())
  76. def _compute_encoding_uom_id(self):
  77. for analytic_line in self:
  78. analytic_line.encoding_uom_id = analytic_line.company_id.timesheet_encode_uom_id
  79. @api.depends('task_id.partner_id', 'project_id.partner_id')
  80. def _compute_partner_id(self):
  81. for timesheet in self:
  82. if timesheet.project_id:
  83. timesheet.partner_id = timesheet.task_id.partner_id or timesheet.project_id.partner_id
  84. @api.depends('task_id', 'task_id.project_id')
  85. def _compute_project_id(self):
  86. for line in self:
  87. if not line.task_id.project_id or line.project_id == line.task_id.project_id:
  88. continue
  89. line.project_id = line.task_id.project_id
  90. @api.depends('project_id')
  91. def _compute_task_id(self):
  92. for line in self.filtered(lambda line: not line.project_id):
  93. line.task_id = False
  94. @api.onchange('project_id')
  95. def _onchange_project_id(self):
  96. # TODO KBA in master - check to do it "properly", currently:
  97. # This onchange is used to reset the task_id when the project changes.
  98. # Doing it in the compute will remove the task_id when the project of a task changes.
  99. if self.project_id != self.task_id.project_id:
  100. self.task_id = False
  101. @api.depends('employee_id')
  102. def _compute_user_id(self):
  103. for line in self:
  104. line.user_id = line.employee_id.user_id if line.employee_id else self._default_user()
  105. @api.depends('employee_id')
  106. def _compute_department_id(self):
  107. for line in self:
  108. line.department_id = line.employee_id.department_id
  109. @api.model_create_multi
  110. def create(self, vals_list):
  111. # Before creating a timesheet, we need to put a valid employee_id in the vals
  112. default_user_id = self._default_user()
  113. user_ids = []
  114. employee_ids = []
  115. # 1/ Collect the user_ids and employee_ids from each timesheet vals
  116. for vals in vals_list:
  117. vals.update(self._timesheet_preprocess(vals))
  118. if not vals.get('project_id'):
  119. continue
  120. if not vals.get('name'):
  121. vals['name'] = '/'
  122. employee_id = vals.get('employee_id')
  123. user_id = vals.get('user_id', default_user_id)
  124. if employee_id and employee_id not in employee_ids:
  125. employee_ids.append(employee_id)
  126. elif user_id not in user_ids:
  127. user_ids.append(user_id)
  128. # 2/ Search all employees related to user_ids and employee_ids, in the selected companies
  129. employees = self.env['hr.employee'].sudo().search([
  130. '&', '|', ('user_id', 'in', user_ids), ('id', 'in', employee_ids), ('company_id', 'in', self.env.companies.ids)
  131. ])
  132. # ┌───── in search results = active/in companies ────────> was found with... ─── employee_id ───> (A) There is nothing to do, we will use this employee_id
  133. # 3/ Each employee └──── user_id ──────> (B)** We'll need to select the right employee for this user
  134. # └─ not in search results = archived/not in companies ──> (C) We raise an error as we can't create a timesheet for an archived employee
  135. # ** We can rely on the user to get the employee_id if
  136. # he has an active employee in the company of the timesheet
  137. # or he has only one active employee for all selected companies
  138. valid_employee_per_id = {}
  139. employee_id_per_company_per_user = defaultdict(dict)
  140. for employee in employees:
  141. if employee.id in employee_ids:
  142. valid_employee_per_id[employee.id] = employee
  143. else:
  144. employee_id_per_company_per_user[employee.user_id.id][employee.company_id.id] = employee.id
  145. # 4/ Put valid employee_id in each vals
  146. error_msg = _lt('Timesheets must be created with an active employee in the selected companies.')
  147. for vals in vals_list:
  148. if not vals.get('project_id'):
  149. continue
  150. employee_in_id = vals.get('employee_id')
  151. if employee_in_id:
  152. if employee_in_id in valid_employee_per_id:
  153. vals['user_id'] = valid_employee_per_id[employee_in_id].sudo().user_id.id # (A) OK
  154. continue
  155. else:
  156. raise ValidationError(error_msg) # (C) KO
  157. else:
  158. user_id = vals.get('user_id', default_user_id) # (B)...
  159. # ...Look for an employee, with ** conditions
  160. employee_per_company = employee_id_per_company_per_user.get(user_id)
  161. employee_out_id = False
  162. if employee_per_company:
  163. company_id = list(employee_per_company)[0] if len(employee_per_company) == 1\
  164. else vals.get('company_id', self.env.company.id)
  165. employee_out_id = employee_per_company.get(company_id, False)
  166. if employee_out_id:
  167. vals['employee_id'] = employee_out_id
  168. vals['user_id'] = user_id
  169. else: # ...and raise an error if they fail
  170. raise ValidationError(error_msg)
  171. # 5/ Finally, create the timesheets
  172. lines = super(AccountAnalyticLine, self).create(vals_list)
  173. for line, values in zip(lines, vals_list):
  174. if line.project_id: # applied only for timesheet
  175. line._timesheet_postprocess(values)
  176. return lines
  177. def write(self, values):
  178. # If it's a basic user then check if the timesheet is his own.
  179. if not (self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') or self.env.su) and any(self.env.user.id != analytic_line.user_id.id for analytic_line in self):
  180. raise AccessError(_("You cannot access timesheets that are not yours."))
  181. values = self._timesheet_preprocess(values)
  182. if values.get('employee_id'):
  183. employee = self.env['hr.employee'].browse(values['employee_id'])
  184. if not employee.active:
  185. raise UserError(_('You cannot set an archived employee to the existing timesheets.'))
  186. if 'name' in values and not values.get('name'):
  187. values['name'] = '/'
  188. result = super(AccountAnalyticLine, self).write(values)
  189. # applied only for timesheet
  190. self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
  191. return result
  192. @api.model
  193. def _get_view_cache_key(self, view_id=None, view_type='form', **options):
  194. """The override of _get_view changing the time field labels according to the company timesheet encoding UOM
  195. makes the view cache dependent on the company timesheet encoding uom"""
  196. key = super()._get_view_cache_key(view_id, view_type, **options)
  197. return key + (self.env.company.timesheet_encode_uom_id,)
  198. @api.model
  199. def _get_view(self, view_id=None, view_type='form', **options):
  200. """ Set the correct label for `unit_amount`, depending on company UoM """
  201. arch, view = super()._get_view(view_id, view_type, **options)
  202. arch = self._apply_timesheet_label(arch, view_type=view_type)
  203. return arch, view
  204. @api.model
  205. def _apply_timesheet_label(self, view_node, view_type='form'):
  206. doc = view_node
  207. encoding_uom = self.env.company.timesheet_encode_uom_id
  208. # Here, we select only the unit_amount field having no string set to give priority to
  209. # custom inheretied view stored in database. Even if normally, no xpath can be done on
  210. # 'string' attribute.
  211. for node in doc.xpath("//field[@name='unit_amount'][@widget='timesheet_uom'][not(@string)]"):
  212. node.set('string', _('%s Spent') % (re.sub(r'[\(\)]', '', encoding_uom.name or '')))
  213. return doc
  214. @api.model
  215. def _apply_time_label(self, view_node, related_model):
  216. doc = view_node
  217. Model = self.env[related_model]
  218. # Just fetch the name of the uom in `timesheet_encode_uom_id` of the current company
  219. encoding_uom_name = self.env.company.timesheet_encode_uom_id.with_context(prefetch_fields=False).sudo().name
  220. for node in doc.xpath("//field[@widget='timesheet_uom'][not(@string)] | //field[@widget='timesheet_uom_no_toggle'][not(@string)]"):
  221. name_with_uom = re.sub(_('Hours') + "|Hours", encoding_uom_name or '', Model._fields[node.get('name')]._description_string(self.env), flags=re.IGNORECASE)
  222. node.set('string', name_with_uom)
  223. return doc
  224. def _timesheet_get_portal_domain(self):
  225. if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
  226. # Then, he is internal user, and we take the domain for this current user
  227. return self.env['ir.rule']._compute_domain(self._name)
  228. return [
  229. '|',
  230. '&',
  231. '|',
  232. ('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
  233. ('task_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
  234. ('task_id.project_id.privacy_visibility', '=', 'portal'),
  235. '&',
  236. ('task_id', '=', False),
  237. '&',
  238. ('project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
  239. ('project_id.privacy_visibility', '=', 'portal')
  240. ]
  241. def _timesheet_preprocess(self, vals):
  242. """ Deduce other field values from the one given.
  243. Overrride this to compute on the fly some field that can not be computed fields.
  244. :param values: dict values for `create`or `write`.
  245. """
  246. project = self.env['project.project'].browse(vals.get('project_id', False))
  247. task = self.env['project.task'].browse(vals.get('task_id', False))
  248. # task implies project
  249. if task and not project:
  250. project = task.project_id
  251. if not project:
  252. raise ValidationError(_('You cannot create a timesheet on a private task.'))
  253. vals['project_id'] = project.id
  254. # task implies analytic account and tags
  255. if task and not vals.get('account_id'):
  256. task_analytic_account_id = task._get_task_analytic_account_id()
  257. vals['account_id'] = task_analytic_account_id.id
  258. vals['company_id'] = task_analytic_account_id.company_id.id or task.company_id.id
  259. if not task_analytic_account_id.active:
  260. raise UserError(_('You cannot add timesheets to a project or a task linked to an inactive analytic account.'))
  261. # project implies analytic account
  262. elif project and not vals.get('account_id'):
  263. vals['account_id'] = project.analytic_account_id.id
  264. vals['company_id'] = project.analytic_account_id.company_id.id or project.company_id.id
  265. if not project.analytic_account_id.active:
  266. raise UserError(_('You cannot add timesheets to a project linked to an inactive analytic account.'))
  267. # force customer partner, from the task or the project
  268. if project and not vals.get('partner_id'):
  269. partner_id = task.partner_id.id if task else project.partner_id.id
  270. if partner_id:
  271. vals['partner_id'] = partner_id
  272. # set timesheet UoM from the AA company (AA implies uom)
  273. if not vals.get('product_uom_id') and all(v in vals for v in ['account_id', 'project_id']): # project_id required to check this is timesheet flow
  274. analytic_account = self.env['account.analytic.account'].sudo().browse(vals['account_id'])
  275. uom_id = analytic_account.company_id.project_time_mode_id.id
  276. if not uom_id:
  277. company_id = vals.get('company_id', False)
  278. if not company_id:
  279. project = self.env['project.project'].browse(vals.get('project_id'))
  280. company_id = project.analytic_account_id.company_id.id or project.company_id.id
  281. uom_id = self.env['res.company'].browse(company_id).project_time_mode_id.id
  282. vals['product_uom_id'] = uom_id
  283. return vals
  284. def _timesheet_postprocess(self, values):
  285. """ Hook to update record one by one according to the values of a `write` or a `create`. """
  286. sudo_self = self.sudo() # this creates only one env for all operation that required sudo() in `_timesheet_postprocess_values`override
  287. values_to_write = self._timesheet_postprocess_values(values)
  288. for timesheet in sudo_self:
  289. if values_to_write[timesheet.id]:
  290. timesheet.write(values_to_write[timesheet.id])
  291. return values
  292. def _timesheet_postprocess_values(self, values):
  293. """ Get the addionnal values to write on record
  294. :param dict values: values for the model's fields, as a dictionary::
  295. {'field_name': field_value, ...}
  296. :return: a dictionary mapping each record id to its corresponding
  297. dictionary values to write (may be empty).
  298. """
  299. result = {id_: {} for id_ in self.ids}
  300. sudo_self = self.sudo() # this creates only one env for all operation that required sudo()
  301. # (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
  302. if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']):
  303. for timesheet in sudo_self:
  304. cost = timesheet._hourly_cost()
  305. amount = -timesheet.unit_amount * cost
  306. amount_converted = timesheet.employee_id.currency_id._convert(
  307. amount, timesheet.account_id.currency_id or timesheet.currency_id, self.env.company, timesheet.date)
  308. result[timesheet.id].update({
  309. 'amount': amount_converted,
  310. })
  311. return result
  312. def _is_timesheet_encode_uom_day(self):
  313. company_uom = self.env.company.timesheet_encode_uom_id
  314. return company_uom == self.env.ref('uom.product_uom_day')
  315. @api.model
  316. def _convert_hours_to_days(self, time):
  317. uom_hour = self.env.ref('uom.product_uom_hour')
  318. uom_day = self.env.ref('uom.product_uom_day')
  319. return round(uom_hour._compute_quantity(time, uom_day, raise_if_failure=False), 2)
  320. def _get_timesheet_time_day(self):
  321. return self._convert_hours_to_days(self.unit_amount)
  322. def _hourly_cost(self):
  323. self.ensure_one()
  324. return self.employee_id.hourly_cost or 0.0
  325. def _get_report_base_filename(self):
  326. task_ids = self.task_id
  327. if len(task_ids) == 1:
  328. return _('Timesheets - %s', task_ids.name)
  329. return _('Timesheets')
  330. def _default_user(self):
  331. return self.env.context.get('user_id', self.env.user.id)