1# -*- coding: utf-8 -*- 2# Part of Odoo. See LICENSE file for full copyright and licensing details. 3 4from odoo import api, fields, models, _ 5 6from odoo.tools.safe_eval import safe_eval 7from odoo.tools.sql import column_exists, create_column 8 9 10class SaleOrder(models.Model): 11 _inherit = 'sale.order' 12 13 tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') 14 tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") 15 16 visible_project = fields.Boolean('Display project', compute='_compute_visible_project', readonly=True) 17 project_id = fields.Many2one( 18 'project.project', 'Project', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, 19 help='Select a non billable project on which tasks can be created.') 20 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.") 21 22 @api.depends('order_line.product_id.project_id') 23 def _compute_tasks_ids(self): 24 for order in self: 25 order.tasks_ids = self.env['project.task'].search(['|', ('sale_line_id', 'in', order.order_line.ids), ('sale_order_id', '=', order.id)]) 26 order.tasks_count = len(order.tasks_ids) 27 28 @api.depends('order_line.product_id.service_tracking') 29 def _compute_visible_project(self): 30 """ 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 31 configured as 'task_in_project' """ 32 for order in self: 33 order.visible_project = any( 34 service_tracking == 'task_in_project' for service_tracking in order.order_line.mapped('product_id.service_tracking') 35 ) 36 37 @api.depends('order_line.product_id', 'order_line.project_id') 38 def _compute_project_ids(self): 39 for order in self: 40 projects = order.order_line.mapped('product_id.project_id') 41 projects |= order.order_line.mapped('project_id') 42 projects |= order.project_id 43 order.project_ids = projects 44 45 @api.onchange('project_id') 46 def _onchange_project_id(self): 47 """ Set the SO analytic account to the selected project's analytic account """ 48 if self.project_id.analytic_account_id: 49 self.analytic_account_id = self.project_id.analytic_account_id 50 51 def _action_confirm(self): 52 """ On SO confirmation, some lines should generate a task or a project. """ 53 result = super()._action_confirm() 54 if len(self.company_id) == 1: 55 # All orders are in the same company 56 self.order_line.sudo().with_company(self.company_id)._timesheet_service_generation() 57 else: 58 # Orders from different companies are confirmed together 59 for order in self: 60 order.order_line.sudo().with_company(order.company_id)._timesheet_service_generation() 61 return result 62 63 def action_view_task(self): 64 self.ensure_one() 65 66 list_view_id = self.env.ref('project.view_task_tree2').id 67 form_view_id = self.env.ref('project.view_task_form2').id 68 69 action = {'type': 'ir.actions.act_window_close'} 70 task_projects = self.tasks_ids.mapped('project_id') 71 if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...) 72 action = self.with_context(active_id=task_projects.id).env['ir.actions.actions']._for_xml_id( 73 'project.act_project_project_2_project_task_all') 74 action['domain'] = [('id', 'in', self.tasks_ids.ids)] 75 if action.get('context'): 76 eval_context = self.env['ir.actions.actions']._get_eval_context() 77 eval_context.update({'active_id': task_projects.id}) 78 action_context = safe_eval(action['context'], eval_context) 79 action_context.update(eval_context) 80 action['context'] = action_context 81 else: 82 action = self.env["ir.actions.actions"]._for_xml_id("project.action_view_task") 83 action['context'] = {} # erase default context to avoid default filter 84 if len(self.tasks_ids) > 1: # cross project kanban task 85 action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot']] 86 elif len(self.tasks_ids) == 1: # single task -> form view 87 action['views'] = [(form_view_id, 'form')] 88 action['res_id'] = self.tasks_ids.id 89 # filter on the task of the current SO 90 action.setdefault('context', {}) 91 action['context'].update({'search_default_sale_order_id': self.id}) 92 return action 93 94 def action_view_project_ids(self): 95 self.ensure_one() 96 view_form_id = self.env.ref('project.edit_project').id 97 view_kanban_id = self.env.ref('project.view_project_kanban').id 98 action = { 99 'type': 'ir.actions.act_window', 100 'domain': [('id', 'in', self.project_ids.ids)], 101 'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')], 102 'view_mode': 'kanban,form', 103 'name': _('Projects'), 104 'res_model': 'project.project', 105 } 106 return action 107 108 def write(self, values): 109 if 'state' in values and values['state'] == 'cancel': 110 self.project_id.sale_line_id = False 111 return super(SaleOrder, self).write(values) 112 113 114class SaleOrderLine(models.Model): 115 _inherit = "sale.order.line" 116 117 project_id = fields.Many2one( 118 'project.project', 'Generated Project', 119 index=True, copy=False, help="Project generated by the sales order item") 120 task_id = fields.Many2one( 121 'project.task', 'Generated Task', 122 index=True, copy=False, help="Task generated by the sales order item") 123 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.") 124 125 @api.depends('product_id') 126 def _compute_is_service(self): 127 for so_line in self: 128 so_line.is_service = so_line.product_id.type == 'service' 129 130 @api.depends('product_id') 131 def _compute_product_updatable(self): 132 for line in self: 133 if line.product_id.type == 'service' and line.state == 'sale': 134 line.product_updatable = False 135 else: 136 super(SaleOrderLine, line)._compute_product_updatable() 137 138 def _auto_init(self): 139 """ 140 Create column to stop ORM from computing it himself (too slow) 141 """ 142 if not column_exists(self.env.cr, 'sale_order_line', 'is_service'): 143 create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool') 144 self.env.cr.execute(""" 145 UPDATE sale_order_line line 146 SET is_service = (pt.type = 'service') 147 FROM product_product pp 148 LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id 149 WHERE pp.id = line.product_id 150 """) 151 return super()._auto_init() 152 153 @api.model_create_multi 154 def create(self, vals_list): 155 lines = super().create(vals_list) 156 # Do not generate task/project when expense SO line, but allow 157 # generate task with hours=0. 158 for line in lines: 159 if line.state == 'sale' and not line.is_expense: 160 line.sudo()._timesheet_service_generation() 161 # if the SO line created a task, post a message on the order 162 if line.task_id: 163 msg_body = _("Task Created (%s): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (line.product_id.name, line.task_id.id, line.task_id.name) 164 line.order_id.message_post(body=msg_body) 165 return lines 166 167 def write(self, values): 168 result = super().write(values) 169 # changing the ordered quantity should change the planned hours on the 170 # task, whatever the SO state. It will be blocked by the super in case 171 # of a locked sale order. 172 if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False): 173 for line in self: 174 if line.task_id and line.product_id.type == 'service': 175 planned_hours = line._convert_qty_company_hours(line.task_id.company_id) 176 line.task_id.write({'planned_hours': planned_hours}) 177 return result 178 179 ########################################### 180 # Service : Project and task generation 181 ########################################### 182 183 def _convert_qty_company_hours(self, dest_company): 184 return self.product_uom_qty 185 186 def _timesheet_create_project_prepare_values(self): 187 """Generate project values""" 188 account = self.order_id.analytic_account_id 189 if not account: 190 self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) 191 account = self.order_id.analytic_account_id 192 193 # create the project or duplicate one 194 return { 195 '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, 196 'analytic_account_id': account.id, 197 'partner_id': self.order_id.partner_id.id, 198 'sale_line_id': self.id, 199 'sale_order_id': self.order_id.id, 200 'active': True, 201 'company_id': self.company_id.id, 202 } 203 204 def _timesheet_create_project(self): 205 """ Generate project for the given so line, and link it. 206 :param project: record of project.project in which the task should be created 207 :return task: record of the created task 208 """ 209 self.ensure_one() 210 values = self._timesheet_create_project_prepare_values() 211 if self.product_id.project_template_id: 212 values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name) 213 project = self.product_id.project_template_id.copy(values) 214 project.tasks.write({ 215 'sale_line_id': self.id, 216 'partner_id': self.order_id.partner_id.id, 217 'email_from': self.order_id.partner_id.email, 218 }) 219 # duplicating a project doesn't set the SO on sub-tasks 220 project.tasks.filtered(lambda task: task.parent_id != False).write({ 221 'sale_line_id': self.id, 222 'sale_order_id': self.order_id, 223 }) 224 else: 225 project = self.env['project.project'].create(values) 226 227 # Avoid new tasks to go to 'Undefined Stage' 228 if not project.type_ids: 229 project.type_ids = self.env['project.task.type'].create({'name': _('New')}) 230 231 # link project as generated by current so line 232 self.write({'project_id': project.id}) 233 return project 234 235 def _timesheet_create_task_prepare_values(self, project): 236 self.ensure_one() 237 planned_hours = self._convert_qty_company_hours(self.company_id) 238 sale_line_name_parts = self.name.split('\n') 239 title = sale_line_name_parts[0] or self.product_id.name 240 description = '<br/>'.join(sale_line_name_parts[1:]) 241 return { 242 'name': title if project.sale_line_id else '%s: %s' % (self.order_id.name or '', title), 243 'planned_hours': planned_hours, 244 'partner_id': self.order_id.partner_id.id, 245 'email_from': self.order_id.partner_id.email, 246 'description': description, 247 'project_id': project.id, 248 'sale_line_id': self.id, 249 'sale_order_id': self.order_id.id, 250 'company_id': project.company_id.id, 251 'user_id': False, # force non assigned task, as created as sudo() 252 } 253 254 def _timesheet_create_task(self, project): 255 """ Generate task for the given so line, and link it. 256 :param project: record of project.project in which the task should be created 257 :return task: record of the created task 258 """ 259 values = self._timesheet_create_task_prepare_values(project) 260 task = self.env['project.task'].sudo().create(values) 261 self.write({'task_id': task.id}) 262 # post message on task 263 task_msg = _("This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name) 264 task.message_post(body=task_msg) 265 return task 266 267 def _timesheet_service_generation(self): 268 """ For service lines, create the task or the project. If already exists, it simply links 269 the existing one to the line. 270 Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a 271 new project/task. This explains the searches on 'sale_line_id' on project/task. This also 272 implied if so line of generated task has been modified, we may regenerate it. 273 """ 274 so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project') 275 so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project']) 276 277 # search so lines from SO of current so lines having their project generated, in order to check if the current one can 278 # create its own project, or reuse the one of its order. 279 map_so_project = {} 280 if so_line_new_project: 281 order_ids = self.mapped('order_id').ids 282 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)]) 283 map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project} 284 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)]) 285 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} 286 287 # search the global project of current SO lines, in which create their task 288 map_sol_project = {} 289 if so_line_task_global_project: 290 map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project} 291 292 def _can_create_project(sol): 293 if not sol.project_id: 294 if sol.product_id.project_template_id: 295 return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates 296 elif sol.order_id.id not in map_so_project: 297 return True 298 return False 299 300 def _determine_project(so_line): 301 """Determine the project for this sale order line. 302 Rules are different based on the service_tracking: 303 304 - 'project_only': the project_id can only come from the sale order line itself 305 - 'task_in_project': the project_id comes from the sale order line only if no project_id was configured 306 on the parent sale order""" 307 308 if so_line.product_id.service_tracking == 'project_only': 309 return so_line.project_id 310 elif so_line.product_id.service_tracking == 'task_in_project': 311 return so_line.order_id.project_id or so_line.project_id 312 313 return False 314 315 # task_global_project: create task in global project 316 for so_line in so_line_task_global_project: 317 if not so_line.task_id: 318 if map_sol_project.get(so_line.id): 319 so_line._timesheet_create_task(project=map_sol_project[so_line.id]) 320 321 # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too. 322 # if 'task_in_project' and project_id configured on SO, use that one instead 323 for so_line in so_line_new_project: 324 project = _determine_project(so_line) 325 if not project and _can_create_project(so_line): 326 project = so_line._timesheet_create_project() 327 if so_line.product_id.project_template_id: 328 map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project 329 else: 330 map_so_project[so_line.order_id.id] = project 331 elif not project: 332 # Attach subsequent SO lines to the created project 333 so_line.project_id = ( 334 map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id)) 335 or map_so_project.get(so_line.order_id.id) 336 ) 337 if so_line.product_id.service_tracking == 'task_in_project': 338 if not project: 339 if so_line.product_id.project_template_id: 340 project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] 341 else: 342 project = map_so_project[so_line.order_id.id] 343 if not so_line.task_id: 344 so_line._timesheet_create_task(project=project) 345