# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ from odoo.tools.safe_eval import safe_eval from odoo.tools.sql import column_exists, create_column class SaleOrder(models.Model): _inherit = 'sale.order' tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") visible_project = fields.Boolean('Display project', compute='_compute_visible_project', readonly=True) project_id = fields.Many2one( 'project.project', 'Project', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help='Select a non billable project on which tasks can be created.') project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.") @api.depends('order_line.product_id.project_id') def _compute_tasks_ids(self): for order in self: order.tasks_ids = self.env['project.task'].search(['|', ('sale_line_id', 'in', order.order_line.ids), ('sale_order_id', '=', order.id)]) order.tasks_count = len(order.tasks_ids) @api.depends('order_line.product_id.service_tracking') def _compute_visible_project(self): """ Users should be able to select a project_id on the SO if at least one SO line has a product with its service tracking configured as 'task_in_project' """ for order in self: order.visible_project = any( service_tracking == 'task_in_project' for service_tracking in order.order_line.mapped('product_id.service_tracking') ) @api.depends('order_line.product_id', 'order_line.project_id') def _compute_project_ids(self): for order in self: projects = order.order_line.mapped('product_id.project_id') projects |= order.order_line.mapped('project_id') projects |= order.project_id order.project_ids = projects @api.onchange('project_id') def _onchange_project_id(self): """ Set the SO analytic account to the selected project's analytic account """ if self.project_id.analytic_account_id: self.analytic_account_id = self.project_id.analytic_account_id def _action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super()._action_confirm() if len(self.company_id) == 1: # All orders are in the same company self.order_line.sudo().with_company(self.company_id)._timesheet_service_generation() else: # Orders from different companies are confirmed together for order in self: order.order_line.sudo().with_company(order.company_id)._timesheet_service_generation() return result def action_view_task(self): self.ensure_one() list_view_id = self.env.ref('project.view_task_tree2').id form_view_id = self.env.ref('project.view_task_form2').id action = {'type': 'ir.actions.act_window_close'} task_projects = self.tasks_ids.mapped('project_id') if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...) action = self.with_context(active_id=task_projects.id).env['ir.actions.actions']._for_xml_id( 'project.act_project_project_2_project_task_all') action['domain'] = [('id', 'in', self.tasks_ids.ids)] if action.get('context'): eval_context = self.env['ir.actions.actions']._get_eval_context() eval_context.update({'active_id': task_projects.id}) action_context = safe_eval(action['context'], eval_context) action_context.update(eval_context) action['context'] = action_context else: action = self.env["ir.actions.actions"]._for_xml_id("project.action_view_task") action['context'] = {} # erase default context to avoid default filter if len(self.tasks_ids) > 1: # cross project kanban task action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot']] elif len(self.tasks_ids) == 1: # single task -> form view action['views'] = [(form_view_id, 'form')] action['res_id'] = self.tasks_ids.id # filter on the task of the current SO action.setdefault('context', {}) action['context'].update({'search_default_sale_order_id': self.id}) return action def action_view_project_ids(self): self.ensure_one() view_form_id = self.env.ref('project.edit_project').id view_kanban_id = self.env.ref('project.view_project_kanban').id action = { 'type': 'ir.actions.act_window', 'domain': [('id', 'in', self.project_ids.ids)], 'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')], 'view_mode': 'kanban,form', 'name': _('Projects'), 'res_model': 'project.project', } return action def write(self, values): if 'state' in values and values['state'] == 'cancel': self.project_id.sale_line_id = False return super(SaleOrder, self).write(values) class SaleOrderLine(models.Model): _inherit = "sale.order.line" project_id = fields.Many2one( 'project.project', 'Generated Project', index=True, copy=False, help="Project generated by the sales order item") task_id = fields.Many2one( 'project.task', 'Generated Task', index=True, copy=False, help="Task generated by the sales order item") is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.") @api.depends('product_id') def _compute_is_service(self): for so_line in self: so_line.is_service = so_line.product_id.type == 'service' @api.depends('product_id') def _compute_product_updatable(self): for line in self: if line.product_id.type == 'service' and line.state == 'sale': line.product_updatable = False else: super(SaleOrderLine, line)._compute_product_updatable() def _auto_init(self): """ Create column to stop ORM from computing it himself (too slow) """ if not column_exists(self.env.cr, 'sale_order_line', 'is_service'): create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool') self.env.cr.execute(""" UPDATE sale_order_line line SET is_service = (pt.type = 'service') FROM product_product pp LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id WHERE pp.id = line.product_id """) return super()._auto_init() @api.model_create_multi def create(self, vals_list): lines = super().create(vals_list) # Do not generate task/project when expense SO line, but allow # generate task with hours=0. for line in lines: if line.state == 'sale' and not line.is_expense: line.sudo()._timesheet_service_generation() # if the SO line created a task, post a message on the order if line.task_id: msg_body = _("Task Created (%s): %s") % (line.product_id.name, line.task_id.id, line.task_id.name) line.order_id.message_post(body=msg_body) return lines def write(self, values): result = super().write(values) # changing the ordered quantity should change the planned hours on the # task, whatever the SO state. It will be blocked by the super in case # of a locked sale order. if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False): for line in self: if line.task_id and line.product_id.type == 'service': planned_hours = line._convert_qty_company_hours(line.task_id.company_id) line.task_id.write({'planned_hours': planned_hours}) return result ########################################### # Service : Project and task generation ########################################### def _convert_qty_company_hours(self, dest_company): return self.product_uom_qty def _timesheet_create_project_prepare_values(self): """Generate project values""" account = self.order_id.analytic_account_id if not account: self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) account = self.order_id.analytic_account_id # create the project or duplicate one return { 'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name, 'analytic_account_id': account.id, 'partner_id': self.order_id.partner_id.id, 'sale_line_id': self.id, 'sale_order_id': self.order_id.id, 'active': True, 'company_id': self.company_id.id, } def _timesheet_create_project(self): """ Generate project for the given so line, and link it. :param project: record of project.project in which the task should be created :return task: record of the created task """ self.ensure_one() values = self._timesheet_create_project_prepare_values() if self.product_id.project_template_id: values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name) project = self.product_id.project_template_id.copy(values) project.tasks.write({ 'sale_line_id': self.id, 'partner_id': self.order_id.partner_id.id, 'email_from': self.order_id.partner_id.email, }) # duplicating a project doesn't set the SO on sub-tasks project.tasks.filtered(lambda task: task.parent_id != False).write({ 'sale_line_id': self.id, 'sale_order_id': self.order_id, }) else: project = self.env['project.project'].create(values) # Avoid new tasks to go to 'Undefined Stage' if not project.type_ids: project.type_ids = self.env['project.task.type'].create({'name': _('New')}) # link project as generated by current so line self.write({'project_id': project.id}) return project def _timesheet_create_task_prepare_values(self, project): self.ensure_one() planned_hours = self._convert_qty_company_hours(self.company_id) sale_line_name_parts = self.name.split('\n') title = sale_line_name_parts[0] or self.product_id.name description = '
'.join(sale_line_name_parts[1:]) return { 'name': title if project.sale_line_id else '%s: %s' % (self.order_id.name or '', title), 'planned_hours': planned_hours, 'partner_id': self.order_id.partner_id.id, 'email_from': self.order_id.partner_id.email, 'description': description, 'project_id': project.id, 'sale_line_id': self.id, 'sale_order_id': self.order_id.id, 'company_id': project.company_id.id, 'user_id': False, # force non assigned task, as created as sudo() } def _timesheet_create_task(self, project): """ Generate task for the given so line, and link it. :param project: record of project.project in which the task should be created :return task: record of the created task """ values = self._timesheet_create_task_prepare_values(project) task = self.env['project.task'].sudo().create(values) self.write({'task_id': task.id}) # post message on task task_msg = _("This task has been created from: %s (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name) task.message_post(body=task_msg) return task def _timesheet_service_generation(self): """ For service lines, create the task or the project. If already exists, it simply links the existing one to the line. Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new project/task. This explains the searches on 'sale_line_id' on project/task. This also implied if so line of generated task has been modified, we may regenerate it. """ so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project') so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project']) # search so lines from SO of current so lines having their project generated, in order to check if the current one can # create its own project, or reuse the one of its order. map_so_project = {} if so_line_new_project: order_ids = self.mapped('order_id').ids so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)]) map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project} so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)]) map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates} # search the global project of current SO lines, in which create their task map_sol_project = {} if so_line_task_global_project: map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project} def _can_create_project(sol): if not sol.project_id: if sol.product_id.project_template_id: return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates elif sol.order_id.id not in map_so_project: return True return False def _determine_project(so_line): """Determine the project for this sale order line. Rules are different based on the service_tracking: - 'project_only': the project_id can only come from the sale order line itself - 'task_in_project': the project_id comes from the sale order line only if no project_id was configured on the parent sale order""" if so_line.product_id.service_tracking == 'project_only': return so_line.project_id elif so_line.product_id.service_tracking == 'task_in_project': return so_line.order_id.project_id or so_line.project_id return False # task_global_project: create task in global project for so_line in so_line_task_global_project: if not so_line.task_id: if map_sol_project.get(so_line.id): so_line._timesheet_create_task(project=map_sol_project[so_line.id]) # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too. # if 'task_in_project' and project_id configured on SO, use that one instead for so_line in so_line_new_project: project = _determine_project(so_line) if not project and _can_create_project(so_line): project = so_line._timesheet_create_project() if so_line.product_id.project_template_id: map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project else: map_so_project[so_line.order_id.id] = project elif not project: # Attach subsequent SO lines to the created project so_line.project_id = ( map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id)) or map_so_project.get(so_line.order_id.id) ) if so_line.product_id.service_tracking == 'task_in_project': if not project: if so_line.product_id.project_template_id: project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] else: project = map_so_project[so_line.order_id.id] if not so_line.task_id: so_line._timesheet_create_task(project=project)