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