# Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ from odoo.exceptions import ValidationError, AccessError from odoo.osv import expression from odoo.tools import SQL from odoo.tools.misc import unquote class ProjectTask(models.Model): _inherit = "project.task" def _domain_sale_line_id(self): domain = expression.AND([ self.env['sale.order.line']._sellable_lines_domain(), self.env['sale.order.line']._domain_sale_line_service(), [ '|', ('order_partner_id.commercial_partner_id.id', 'parent_of', unquote('partner_id if partner_id else []')), ('order_partner_id', '=?', unquote('partner_id')), ], ]) return domain sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', store=True, help="Sales order to which the task is linked.", group_expand="_group_expand_sales_order") sale_line_id = fields.Many2one( 'sale.order.line', 'Sales Order Item', copy=True, tracking=True, index='btree_not_null', recursive=True, compute='_compute_sale_line', store=True, readonly=False, domain=lambda self: str(self._domain_sale_line_id()), help="Sales Order Item to which the time spent on this task will be added in order to be invoiced to your customer.\n" "By default the sales order item set on the project will be selected. In the absence of one, the last prepaid sales order item that has time remaining will be used.\n" "Remove the sales order item in order to make this task non billable. You can also change or remove the sales order item of each timesheet entry individually.") project_sale_order_id = fields.Many2one('sale.order', string="Project's sale order", related='project_id.sale_order_id') sale_order_state = fields.Selection(related='sale_order_id.state') task_to_invoice = fields.Boolean("To invoice", compute='_compute_task_to_invoice', search='_search_task_to_invoice', groups='sales_team.group_sale_salesman_all_leads') allow_billable = fields.Boolean(related="project_id.allow_billable") partner_id = fields.Many2one(inverse='_inverse_partner_id') # Project sharing fields display_sale_order_button = fields.Boolean(string='Display Sales Order', compute='_compute_display_sale_order_button') @property def SELF_READABLE_FIELDS(self): return super().SELF_READABLE_FIELDS | {'allow_billable', 'sale_order_id', 'sale_line_id', 'display_sale_order_button'} @api.model def _group_expand_sales_order(self, sales_orders, domain): start_date = self._context.get('gantt_start_date') scale = self._context.get('gantt_scale') if not (start_date and scale): return sales_orders search_on_comodel = self._search_on_comodel(domain, "sale_order_id", "sale.order") if search_on_comodel: return search_on_comodel return sales_orders @api.depends('sale_line_id', 'project_id', 'allow_billable') def _compute_sale_order_id(self): for task in self: if not task.allow_billable: task.sale_order_id = False continue sale_order = ( task.sale_line_id.order_id or task.project_id.sale_order_id or task.sale_order_id ) if sale_order and not task.partner_id: task.partner_id = sale_order.partner_id consistent_partners = ( sale_order.partner_id | sale_order.partner_invoice_id | sale_order.partner_shipping_id ).commercial_partner_id if task.partner_id.commercial_partner_id in consistent_partners: task.sale_order_id = sale_order else: task.sale_order_id = False @api.depends('allow_billable') def _compute_partner_id(self): billable_task = self.filtered(lambda t: t.allow_billable or (not self._origin and t.parent_id.allow_billable)) (self - billable_task).partner_id = False super(ProjectTask, billable_task)._compute_partner_id() def _inverse_partner_id(self): for task in self: # check that sale_line_id/sale_order_id and customer are consistent consistent_partners = ( task.sale_order_id.partner_id | task.sale_order_id.partner_invoice_id | task.sale_order_id.partner_shipping_id ).commercial_partner_id if task.sale_order_id and task.partner_id.commercial_partner_id not in consistent_partners: task.sale_order_id = task.sale_line_id = False @api.depends('sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'milestone_id.sale_line_id', 'allow_billable') def _compute_sale_line(self): for task in self: if not (task.allow_billable or task.parent_id.allow_billable): task.sale_line_id = False continue if not task.sale_line_id: # if the project_id is set then it means the task is classic task or a subtask with another project than its parent. # To determine the sale_line_id, we first need to look at the parent before the project to manage the case of subtasks. # Two sub-tasks in the same project do not necessarily have the same sale_line_id (need to look at the parent task). sale_line = False if task.parent_id.sale_line_id and task.parent_id.partner_id.commercial_partner_id == task.partner_id.commercial_partner_id: sale_line = task.parent_id.sale_line_id elif task.project_id.sale_line_id and task.project_id.partner_id.commercial_partner_id == task.partner_id.commercial_partner_id: sale_line = task.project_id.sale_line_id task.sale_line_id = sale_line or task.milestone_id.sale_line_id @api.depends('sale_order_id') def _compute_display_sale_order_button(self): if not self.sale_order_id: self.display_sale_order_button = False return try: sale_orders = self.env['sale.order'].search([('id', 'in', self.sale_order_id.ids)]) for task in self: task.display_sale_order_button = task.sale_order_id in sale_orders except AccessError: self.display_sale_order_button = False @api.constrains('sale_line_id') def _check_sale_line_type(self): for task in self.sudo(): if task.sale_line_id: if not task.sale_line_id.is_service or task.sale_line_id.is_expense: raise ValidationError(_( 'You cannot link the order item %(order_id)s - %(product_id)s to this task because it is a re-invoiced expense.', order_id=task.sale_line_id.order_id.name, product_id=task.sale_line_id.product_id.display_name, )) def _ensure_sale_order_linked(self, sol_ids): """ Orders created from project/task are supposed to be confirmed to match the typical flow from sales, but since we allow SO creation from the project/task itself we want to confirm newly created SOs immediately after creation. However this would leads to SOs being confirmed without a single product, so we'd rather do it on record save. """ quotations = self.env['sale.order.line'].sudo()._read_group( domain=[('state', '=', 'draft'), ('id', 'in', sol_ids)], aggregates=['order_id:recordset'], )[0][0] if quotations: quotations.action_confirm() @api.model_create_multi def create(self, vals_list): tasks = super().create(vals_list) sol_ids = { vals['sale_line_id'] for vals in vals_list if vals.get('sale_line_id') } if sol_ids: tasks._ensure_sale_order_linked(list(sol_ids)) return tasks def write(self, vals): task = super().write(vals) if sol_id := vals.get('sale_line_id'): self._ensure_sale_order_linked([sol_id]) return task # --------------------------------------------------- # Actions # --------------------------------------------------- def _get_action_view_so_ids(self): return self.sale_order_id.ids def action_view_so(self): so_ids = self._get_action_view_so_ids() action_window = { "type": "ir.actions.act_window", "res_model": "sale.order", "name": _("Sales Order"), "views": [[False, "list"], [False, "kanban"], [False, "form"]], "context": {"create": False, "show_sale": True}, "domain": [["id", "in", so_ids]], } if len(so_ids) == 1: action_window["views"] = [[False, "form"]] action_window["res_id"] = so_ids[0] return action_window def action_project_sharing_view_so(self): self.ensure_one() if not self.display_sale_order_button: return {} return { "name": "Portal Sale Order", "type": "ir.actions.act_url", "url": self.sale_order_id.access_url, } def _rating_get_partner(self): partner = self.partner_id or self.sale_line_id.order_id.partner_id return partner or super()._rating_get_partner() @api.depends('sale_order_id.invoice_status', 'sale_order_id.order_line') def _compute_task_to_invoice(self): for task in self: if task.sale_order_id: task.task_to_invoice = bool(task.sale_order_id.invoice_status not in ('no', 'invoiced')) else: task.task_to_invoice = False @api.model def _search_task_to_invoice(self, operator, value): sql = SQL("""( SELECT so.id FROM sale_order so WHERE so.invoice_status != 'invoiced' AND so.invoice_status != 'no' )""") operator_new = 'in' if (bool(operator == '=') ^ bool(value)): operator_new = 'not in' return [('sale_order_id', operator_new, sql)] @api.onchange('sale_line_id') def _onchange_partner_id(self): if not self.partner_id and self.sale_line_id: self.partner_id = self.sale_line_id.order_partner_id def _get_projects_to_make_billable_domain(self, additional_domain=None): return expression.AND([ super()._get_projects_to_make_billable_domain(additional_domain), [ ('partner_id', '!=', False), ('allow_billable', '=', False), ('project_id', '!=', False), ], ])