sale_order.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. import math
  4. from collections import defaultdict
  5. from odoo import api, fields, models, _
  6. from odoo.osv import expression
  7. from odoo.tools import float_compare
  8. class SaleOrder(models.Model):
  9. _inherit = 'sale.order'
  10. timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale')
  11. timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user")
  12. # override domain
  13. project_id = fields.Many2one(domain="[('pricing_type', '!=', 'employee_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
  14. timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
  15. timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
  16. def _compute_timesheet_ids(self):
  17. timesheet_groups = self.env['account.analytic.line'].sudo().read_group(
  18. [('so_line', 'in', self.mapped('order_line').ids), ('project_id', '!=', False)],
  19. ['so_line', 'ids:array_agg(id)'],
  20. ['so_line'])
  21. timesheets_per_sol = {group['so_line'][0]: (group['ids'], group['so_line_count']) for group in timesheet_groups}
  22. for order in self:
  23. timesheet_ids = []
  24. timesheet_count = 0
  25. for sale_line_id in order.order_line.filtered('is_service').ids:
  26. list_timesheet_ids, count = timesheets_per_sol.get(sale_line_id, ([], 0))
  27. timesheet_ids.extend(list_timesheet_ids)
  28. timesheet_count += count
  29. order.update({
  30. 'timesheet_ids': self.env['account.analytic.line'].browse(timesheet_ids),
  31. 'timesheet_count': timesheet_count,
  32. })
  33. @api.depends('company_id.project_time_mode_id', 'timesheet_ids', 'company_id.timesheet_encode_uom_id')
  34. def _compute_timesheet_total_duration(self):
  35. if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
  36. self.update({'timesheet_total_duration': 0})
  37. return
  38. group_data = self.env['account.analytic.line'].sudo()._read_group([
  39. ('order_id', 'in', self.ids), ('project_id', '!=', False)
  40. ], ['order_id', 'unit_amount'], ['order_id'])
  41. timesheet_unit_amount_dict = defaultdict(float)
  42. timesheet_unit_amount_dict.update({data['order_id'][0]: data['unit_amount'] for data in group_data})
  43. for sale_order in self:
  44. total_time = sale_order.company_id.project_time_mode_id._compute_quantity(timesheet_unit_amount_dict[sale_order.id], sale_order.timesheet_encode_uom_id)
  45. sale_order.timesheet_total_duration = round(total_time)
  46. def _compute_field_value(self, field):
  47. if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'):
  48. return super()._compute_field_value(field)
  49. # Get SOs which their state is not equal to upselling and if at least a SOL has warning prepaid service upsell set to True and the warning has not already been displayed
  50. upsellable_orders = self.filtered(lambda so:
  51. so.state == 'sale'
  52. and so.invoice_status != 'upselling'
  53. and so.id
  54. and (so.user_id or so.partner_id.user_id) # salesperson needed to assign upsell activity
  55. )
  56. super(SaleOrder, upsellable_orders.with_context(mail_activity_automation_skip=True))._compute_field_value(field)
  57. for order in upsellable_orders:
  58. upsellable_lines = order._get_prepaid_service_lines_to_upsell()
  59. if upsellable_lines:
  60. order._create_upsell_activity()
  61. # We want to display only one time the warning for each SOL
  62. upsellable_lines.write({'has_displayed_warning_upsell': True})
  63. super(SaleOrder, self - upsellable_orders)._compute_field_value(field)
  64. def _get_prepaid_service_lines_to_upsell(self):
  65. """ Retrieve all sols which need to display an upsell activity warning in the SO
  66. These SOLs should contain a product which has:
  67. - type="service",
  68. - service_policy="ordered_prepaid",
  69. """
  70. self.ensure_one()
  71. precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
  72. return self.order_line.filtered(lambda sol:
  73. sol.is_service
  74. and not sol.has_displayed_warning_upsell # we don't want to display many times the warning each time we timesheet on the SOL
  75. and sol.product_id.service_policy == 'ordered_prepaid'
  76. and float_compare(
  77. sol.qty_delivered,
  78. sol.product_uom_qty * (sol.product_id.service_upsell_threshold or 1.0),
  79. precision_digits=precision
  80. ) > 0
  81. )
  82. def action_view_timesheet(self):
  83. self.ensure_one()
  84. action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
  85. action['context'] = {
  86. 'search_default_billable_timesheet': True
  87. } # erase default filters
  88. if self.order_line:
  89. tasks = self.order_line.task_id._filter_access_rules_python('write')
  90. if tasks:
  91. action['context']['default_task_id'] = tasks[0].id
  92. else:
  93. projects = self.order_line.project_id._filter_access_rules_python('write')
  94. if projects:
  95. action['context']['default_project_id'] = projects[0].id
  96. if self.timesheet_count > 0:
  97. action['domain'] = [('so_line', 'in', self.order_line.ids), ('project_id', '!=', False)]
  98. else:
  99. action = {'type': 'ir.actions.act_window_close'}
  100. return action
  101. def _create_invoices(self, grouped=False, final=False, date=None):
  102. """Link timesheets to the created invoices. Date interval is injected in the
  103. context in sale_make_invoice_advance_inv wizard.
  104. """
  105. moves = super()._create_invoices(grouped=grouped, final=final, date=date)
  106. moves._link_timesheets_to_invoice(self.env.context.get("timesheet_start_date"), self.env.context.get("timesheet_end_date"))
  107. return moves
  108. class SaleOrderLine(models.Model):
  109. _inherit = "sale.order.line"
  110. qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
  111. analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
  112. remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available', compute_sudo=True)
  113. remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True)
  114. has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell')
  115. timesheet_ids = fields.One2many('account.analytic.line', 'so_line', domain=[('project_id', '!=', False)], string='Timesheets')
  116. def name_get(self):
  117. res = super(SaleOrderLine, self).name_get()
  118. with_remaining_hours = self.env.context.get('with_remaining_hours')
  119. if with_remaining_hours:
  120. names = dict(res)
  121. result = []
  122. uom_hour = self.env.ref('uom.product_uom_hour')
  123. uom_day = self.env.ref('uom.product_uom_day')
  124. for line in self:
  125. name = names.get(line.id)
  126. if line.remaining_hours_available:
  127. company = self.env.company
  128. encoding_uom = company.timesheet_encode_uom_id
  129. remaining_time = ''
  130. if encoding_uom == uom_hour:
  131. hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
  132. round_minutes = minutes / 30
  133. minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
  134. if minutes > 1:
  135. minutes = 0
  136. hours += 1
  137. else:
  138. minutes = minutes * 30
  139. remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
  140. sign='-' if line.remaining_hours < 0 else '',
  141. hours=hours,
  142. minutes=minutes)
  143. elif encoding_uom == uom_day:
  144. remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
  145. remaining_time = ' ({qty:.02f} {unit})'.format(
  146. qty=remaining_days,
  147. unit=_('days') if abs(remaining_days) > 1 else _('day')
  148. )
  149. name = '{name}{remaining_time}'.format(
  150. name=name,
  151. remaining_time=remaining_time
  152. )
  153. result.append((line.id, name))
  154. return result
  155. return res
  156. @api.depends('product_id.service_policy')
  157. def _compute_remaining_hours_available(self):
  158. uom_hour = self.env.ref('uom.product_uom_hour')
  159. for line in self:
  160. is_ordered_prepaid = line.product_id.service_policy == 'ordered_prepaid'
  161. is_time_product = line.product_uom.category_id == uom_hour.category_id
  162. line.remaining_hours_available = is_ordered_prepaid and is_time_product
  163. @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
  164. def _compute_remaining_hours(self):
  165. uom_hour = self.env.ref('uom.product_uom_hour')
  166. for line in self:
  167. remaining_hours = None
  168. if line.remaining_hours_available:
  169. qty_left = line.product_uom_qty - line.qty_delivered
  170. remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour)
  171. line.remaining_hours = remaining_hours
  172. @api.depends('product_id')
  173. def _compute_qty_delivered_method(self):
  174. """ Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
  175. super(SaleOrderLine, self)._compute_qty_delivered_method()
  176. for line in self:
  177. if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
  178. line.qty_delivered_method = 'timesheet'
  179. @api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
  180. def _compute_qty_delivered(self):
  181. super(SaleOrderLine, self)._compute_qty_delivered()
  182. lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
  183. domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
  184. mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
  185. for line in lines_by_timesheet:
  186. line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0)
  187. def _timesheet_compute_delivered_quantity_domain(self):
  188. """ Hook for validated timesheet in addionnal module """
  189. domain = [('project_id', '!=', False)]
  190. if self._context.get('accrual_entry_date'):
  191. domain += [('date', '<=', self._context['accrual_entry_date'])]
  192. return domain
  193. ###########################################
  194. # Service : Project and task generation
  195. ###########################################
  196. def _convert_qty_company_hours(self, dest_company):
  197. company_time_uom_id = dest_company.project_time_mode_id
  198. planned_hours = 0.0
  199. product_uom = self.product_uom
  200. if product_uom == self.env.ref('uom.product_uom_unit'):
  201. product_uom = self.env.ref('uom.product_uom_hour')
  202. if product_uom.category_id == company_time_uom_id.category_id:
  203. if product_uom != company_time_uom_id:
  204. planned_hours = product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id)
  205. else:
  206. planned_hours = self.product_uom_qty
  207. return planned_hours
  208. def _timesheet_create_project(self):
  209. project = super()._timesheet_create_project()
  210. project_uom = project.timesheet_encode_uom_id
  211. uom_ids = set(project_uom + self.order_id.order_line.mapped('product_uom'))
  212. uom_unit = self.env.ref('uom.product_uom_unit')
  213. uom_hour = self.env.ref('uom.product_uom_hour')
  214. uom_per_id = {}
  215. for uom in uom_ids:
  216. if uom == uom_unit:
  217. uom = uom_hour
  218. if uom.category_id == project_uom.category_id:
  219. uom_per_id[uom.id] = uom
  220. allocated_hours = 0.0
  221. for line in self.order_id.order_line:
  222. product_type = line.product_id.service_tracking
  223. if line.is_service and (product_type == 'task_in_project' or product_type == 'project_only') and line.product_id.project_template_id == self.product_id.project_template_id:
  224. if uom_per_id.get(line.product_uom.id) or line.product_uom.id == uom_unit.id:
  225. allocated_hours += line.product_uom_qty * uom_per_id.get(line.product_uom.id, project_uom).factor_inv * uom_hour.factor
  226. project.write({
  227. 'allocated_hours': allocated_hours,
  228. 'allow_timesheets': True,
  229. })
  230. return project
  231. def _timesheet_create_project_prepare_values(self):
  232. """Generate project values"""
  233. values = super()._timesheet_create_project_prepare_values()
  234. values['allow_billable'] = True
  235. return values
  236. def _recompute_qty_to_invoice(self, start_date, end_date):
  237. """ Recompute the qty_to_invoice field for product containing timesheets
  238. Search the existed timesheets between the given period in parameter.
  239. Retrieve the unit_amount of this timesheet and then recompute
  240. the qty_to_invoice for each current product.
  241. :param start_date: the start date of the period
  242. :param end_date: the end date of the period
  243. """
  244. lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
  245. domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
  246. refund_account_moves = self.order_id.invoice_ids.filtered(lambda am: am.state == 'posted' and am.move_type == 'out_refund').reversed_entry_id
  247. timesheet_domain = [
  248. '|',
  249. ('timesheet_invoice_id', '=', False),
  250. ('timesheet_invoice_id.state', '=', 'cancel')]
  251. if refund_account_moves:
  252. credited_timesheet_domain = [('timesheet_invoice_id.state', '=', 'posted'), ('timesheet_invoice_id', 'in', refund_account_moves.ids)]
  253. timesheet_domain = expression.OR([timesheet_domain, credited_timesheet_domain])
  254. domain = expression.AND([domain, timesheet_domain])
  255. if start_date:
  256. domain = expression.AND([domain, [('date', '>=', start_date)]])
  257. if end_date:
  258. domain = expression.AND([domain, [('date', '<=', end_date)]])
  259. mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
  260. for line in lines_by_timesheet:
  261. qty_to_invoice = mapping.get(line.id, 0.0)
  262. if qty_to_invoice:
  263. line.qty_to_invoice = qty_to_invoice
  264. else:
  265. prev_inv_status = line.invoice_status
  266. line.qty_to_invoice = qty_to_invoice
  267. line.invoice_status = prev_inv_status
  268. def _get_action_per_item(self):
  269. """ Get action per Sales Order Item
  270. When the Sales Order Item contains a service product then the action will be View Timesheets.
  271. :returns: Dict containing id of SOL as key and the action as value
  272. """
  273. action_per_sol = super()._get_action_per_item()
  274. timesheet_action = self.env.ref('sale_timesheet.timesheet_action_from_sales_order_item').id
  275. timesheet_ids_per_sol = {}
  276. if self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
  277. timesheet_read_group = self.env['account.analytic.line']._read_group([('so_line', 'in', self.ids), ('project_id', '!=', False)], ['so_line', 'ids:array_agg(id)'], ['so_line'])
  278. timesheet_ids_per_sol = {res['so_line'][0]: res['ids'] for res in timesheet_read_group}
  279. for sol in self:
  280. timesheet_ids = timesheet_ids_per_sol.get(sol.id, [])
  281. if sol.is_service and len(timesheet_ids) > 0:
  282. action_per_sol[sol.id] = timesheet_action, timesheet_ids[0] if len(timesheet_ids) == 1 else False
  283. return action_per_sol