123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324 |
- # -*- coding: utf-8 -*-
- # Part of Odoo. See LICENSE file for full copyright and licensing details.
- import math
- from collections import defaultdict
- from odoo import api, fields, models, _
- from odoo.osv import expression
- from odoo.tools import float_compare
- class SaleOrder(models.Model):
- _inherit = 'sale.order'
- timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale')
- timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user")
- # override domain
- project_id = fields.Many2one(domain="[('pricing_type', '!=', 'employee_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
- timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
- 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")
- def _compute_timesheet_ids(self):
- timesheet_groups = self.env['account.analytic.line'].sudo().read_group(
- [('so_line', 'in', self.mapped('order_line').ids), ('project_id', '!=', False)],
- ['so_line', 'ids:array_agg(id)'],
- ['so_line'])
- timesheets_per_sol = {group['so_line'][0]: (group['ids'], group['so_line_count']) for group in timesheet_groups}
- for order in self:
- timesheet_ids = []
- timesheet_count = 0
- for sale_line_id in order.order_line.filtered('is_service').ids:
- list_timesheet_ids, count = timesheets_per_sol.get(sale_line_id, ([], 0))
- timesheet_ids.extend(list_timesheet_ids)
- timesheet_count += count
- order.update({
- 'timesheet_ids': self.env['account.analytic.line'].browse(timesheet_ids),
- 'timesheet_count': timesheet_count,
- })
- @api.depends('company_id.project_time_mode_id', 'timesheet_ids', 'company_id.timesheet_encode_uom_id')
- def _compute_timesheet_total_duration(self):
- if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
- self.update({'timesheet_total_duration': 0})
- return
- group_data = self.env['account.analytic.line'].sudo()._read_group([
- ('order_id', 'in', self.ids), ('project_id', '!=', False)
- ], ['order_id', 'unit_amount'], ['order_id'])
- timesheet_unit_amount_dict = defaultdict(float)
- timesheet_unit_amount_dict.update({data['order_id'][0]: data['unit_amount'] for data in group_data})
- for sale_order in self:
- 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)
- sale_order.timesheet_total_duration = round(total_time)
- def _compute_field_value(self, field):
- if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'):
- return super()._compute_field_value(field)
- # 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
- upsellable_orders = self.filtered(lambda so:
- so.state == 'sale'
- and so.invoice_status != 'upselling'
- and so.id
- and (so.user_id or so.partner_id.user_id) # salesperson needed to assign upsell activity
- )
- super(SaleOrder, upsellable_orders.with_context(mail_activity_automation_skip=True))._compute_field_value(field)
- for order in upsellable_orders:
- upsellable_lines = order._get_prepaid_service_lines_to_upsell()
- if upsellable_lines:
- order._create_upsell_activity()
- # We want to display only one time the warning for each SOL
- upsellable_lines.write({'has_displayed_warning_upsell': True})
- super(SaleOrder, self - upsellable_orders)._compute_field_value(field)
- def _get_prepaid_service_lines_to_upsell(self):
- """ Retrieve all sols which need to display an upsell activity warning in the SO
- These SOLs should contain a product which has:
- - type="service",
- - service_policy="ordered_prepaid",
- """
- self.ensure_one()
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- return self.order_line.filtered(lambda sol:
- sol.is_service
- and not sol.has_displayed_warning_upsell # we don't want to display many times the warning each time we timesheet on the SOL
- and sol.product_id.service_policy == 'ordered_prepaid'
- and float_compare(
- sol.qty_delivered,
- sol.product_uom_qty * (sol.product_id.service_upsell_threshold or 1.0),
- precision_digits=precision
- ) > 0
- )
- def action_view_timesheet(self):
- self.ensure_one()
- action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
- action['context'] = {
- 'search_default_billable_timesheet': True
- } # erase default filters
- if self.order_line:
- tasks = self.order_line.task_id._filter_access_rules_python('write')
- if tasks:
- action['context']['default_task_id'] = tasks[0].id
- else:
- projects = self.order_line.project_id._filter_access_rules_python('write')
- if projects:
- action['context']['default_project_id'] = projects[0].id
- if self.timesheet_count > 0:
- action['domain'] = [('so_line', 'in', self.order_line.ids), ('project_id', '!=', False)]
- else:
- action = {'type': 'ir.actions.act_window_close'}
- return action
- def _create_invoices(self, grouped=False, final=False, date=None):
- """Link timesheets to the created invoices. Date interval is injected in the
- context in sale_make_invoice_advance_inv wizard.
- """
- moves = super()._create_invoices(grouped=grouped, final=final, date=date)
- moves._link_timesheets_to_invoice(self.env.context.get("timesheet_start_date"), self.env.context.get("timesheet_end_date"))
- return moves
- class SaleOrderLine(models.Model):
- _inherit = "sale.order.line"
- qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
- analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
- remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available', compute_sudo=True)
- remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True)
- has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell')
- timesheet_ids = fields.One2many('account.analytic.line', 'so_line', domain=[('project_id', '!=', False)], string='Timesheets')
- def name_get(self):
- res = super(SaleOrderLine, self).name_get()
- with_remaining_hours = self.env.context.get('with_remaining_hours')
- if with_remaining_hours:
- names = dict(res)
- result = []
- uom_hour = self.env.ref('uom.product_uom_hour')
- uom_day = self.env.ref('uom.product_uom_day')
- for line in self:
- name = names.get(line.id)
- if line.remaining_hours_available:
- company = self.env.company
- encoding_uom = company.timesheet_encode_uom_id
- remaining_time = ''
- if encoding_uom == uom_hour:
- hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
- round_minutes = minutes / 30
- minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
- if minutes > 1:
- minutes = 0
- hours += 1
- else:
- minutes = minutes * 30
- remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
- sign='-' if line.remaining_hours < 0 else '',
- hours=hours,
- minutes=minutes)
- elif encoding_uom == uom_day:
- remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
- remaining_time = ' ({qty:.02f} {unit})'.format(
- qty=remaining_days,
- unit=_('days') if abs(remaining_days) > 1 else _('day')
- )
- name = '{name}{remaining_time}'.format(
- name=name,
- remaining_time=remaining_time
- )
- result.append((line.id, name))
- return result
- return res
- @api.depends('product_id.service_policy')
- def _compute_remaining_hours_available(self):
- uom_hour = self.env.ref('uom.product_uom_hour')
- for line in self:
- is_ordered_prepaid = line.product_id.service_policy == 'ordered_prepaid'
- is_time_product = line.product_uom.category_id == uom_hour.category_id
- line.remaining_hours_available = is_ordered_prepaid and is_time_product
- @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
- def _compute_remaining_hours(self):
- uom_hour = self.env.ref('uom.product_uom_hour')
- for line in self:
- remaining_hours = None
- if line.remaining_hours_available:
- qty_left = line.product_uom_qty - line.qty_delivered
- remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour)
- line.remaining_hours = remaining_hours
- @api.depends('product_id')
- def _compute_qty_delivered_method(self):
- """ Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
- super(SaleOrderLine, self)._compute_qty_delivered_method()
- for line in self:
- if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
- line.qty_delivered_method = 'timesheet'
- @api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
- def _compute_qty_delivered(self):
- super(SaleOrderLine, self)._compute_qty_delivered()
- lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
- domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
- mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
- for line in lines_by_timesheet:
- line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0)
- def _timesheet_compute_delivered_quantity_domain(self):
- """ Hook for validated timesheet in addionnal module """
- domain = [('project_id', '!=', False)]
- if self._context.get('accrual_entry_date'):
- domain += [('date', '<=', self._context['accrual_entry_date'])]
- return domain
- ###########################################
- # Service : Project and task generation
- ###########################################
- def _convert_qty_company_hours(self, dest_company):
- company_time_uom_id = dest_company.project_time_mode_id
- planned_hours = 0.0
- product_uom = self.product_uom
- if product_uom == self.env.ref('uom.product_uom_unit'):
- product_uom = self.env.ref('uom.product_uom_hour')
- if product_uom.category_id == company_time_uom_id.category_id:
- if product_uom != company_time_uom_id:
- planned_hours = product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id)
- else:
- planned_hours = self.product_uom_qty
- return planned_hours
- def _timesheet_create_project(self):
- project = super()._timesheet_create_project()
- project_uom = project.timesheet_encode_uom_id
- uom_ids = set(project_uom + self.order_id.order_line.mapped('product_uom'))
- uom_unit = self.env.ref('uom.product_uom_unit')
- uom_hour = self.env.ref('uom.product_uom_hour')
- uom_per_id = {}
- for uom in uom_ids:
- if uom == uom_unit:
- uom = uom_hour
- if uom.category_id == project_uom.category_id:
- uom_per_id[uom.id] = uom
- allocated_hours = 0.0
- for line in self.order_id.order_line:
- product_type = line.product_id.service_tracking
- 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:
- if uom_per_id.get(line.product_uom.id) or line.product_uom.id == uom_unit.id:
- allocated_hours += line.product_uom_qty * uom_per_id.get(line.product_uom.id, project_uom).factor_inv * uom_hour.factor
- project.write({
- 'allocated_hours': allocated_hours,
- 'allow_timesheets': True,
- })
- return project
- def _timesheet_create_project_prepare_values(self):
- """Generate project values"""
- values = super()._timesheet_create_project_prepare_values()
- values['allow_billable'] = True
- return values
- def _recompute_qty_to_invoice(self, start_date, end_date):
- """ Recompute the qty_to_invoice field for product containing timesheets
- Search the existed timesheets between the given period in parameter.
- Retrieve the unit_amount of this timesheet and then recompute
- the qty_to_invoice for each current product.
- :param start_date: the start date of the period
- :param end_date: the end date of the period
- """
- lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
- domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
- refund_account_moves = self.order_id.invoice_ids.filtered(lambda am: am.state == 'posted' and am.move_type == 'out_refund').reversed_entry_id
- timesheet_domain = [
- '|',
- ('timesheet_invoice_id', '=', False),
- ('timesheet_invoice_id.state', '=', 'cancel')]
- if refund_account_moves:
- credited_timesheet_domain = [('timesheet_invoice_id.state', '=', 'posted'), ('timesheet_invoice_id', 'in', refund_account_moves.ids)]
- timesheet_domain = expression.OR([timesheet_domain, credited_timesheet_domain])
- domain = expression.AND([domain, timesheet_domain])
- if start_date:
- domain = expression.AND([domain, [('date', '>=', start_date)]])
- if end_date:
- domain = expression.AND([domain, [('date', '<=', end_date)]])
- mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
- for line in lines_by_timesheet:
- qty_to_invoice = mapping.get(line.id, 0.0)
- if qty_to_invoice:
- line.qty_to_invoice = qty_to_invoice
- else:
- prev_inv_status = line.invoice_status
- line.qty_to_invoice = qty_to_invoice
- line.invoice_status = prev_inv_status
- def _get_action_per_item(self):
- """ Get action per Sales Order Item
- When the Sales Order Item contains a service product then the action will be View Timesheets.
- :returns: Dict containing id of SOL as key and the action as value
- """
- action_per_sol = super()._get_action_per_item()
- timesheet_action = self.env.ref('sale_timesheet.timesheet_action_from_sales_order_item').id
- timesheet_ids_per_sol = {}
- if self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
- 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'])
- timesheet_ids_per_sol = {res['so_line'][0]: res['ids'] for res in timesheet_read_group}
- for sol in self:
- timesheet_ids = timesheet_ids_per_sol.get(sol.id, [])
- if sol.is_service and len(timesheet_ids) > 0:
- action_per_sol[sol.id] = timesheet_action, timesheet_ids[0] if len(timesheet_ids) == 1 else False
- return action_per_sol
|