diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 53d1291df8b..61ffb11def9 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -41,17 +41,18 @@ class SaleOrder(models.Model): order.tasks_count = len(order.tasks_ids) @api.multi - @api.depends('order_line.product_id') + @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') order.project_ids = projects @api.multi def action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self).action_confirm() - self.order_line._timesheet_service_generation() + self.order_line.sudo()._timesheet_service_generation() return result @api.multi @@ -128,7 +129,8 @@ class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) - task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item") + project_id = fields.Many2one('project.project', 'Generated Project', index=True, help="Project generated by the sales order item") + task_id = fields.Many2one('project.task', 'Generated Task', index=True, 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.") analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) @@ -177,11 +179,15 @@ class SaleOrderLine(models.Model): # Do not generate task/project when expense SO line, but allow # generate task with hours=0. if line.state == 'sale' and not line.is_expense: - line._timesheet_service_generation() + 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 line ########################################### - ## Service : Project and task generation + # Service : Project and task generation ########################################### def _convert_qty_company_hours(self): @@ -192,32 +198,33 @@ class SaleOrderLine(models.Model): planned_hours = self.product_uom_qty return planned_hours - def _timesheet_find_project(self): + @api.multi + 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() - Project = self.env['project.project'] - project = self.product_id.with_context(force_company=self.company_id.id).project_id - if not project: - # find the project corresponding to the analytic account of the sales order + 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 - if not account: - self.order_id._create_analytic_account(prefix=self.product_id.default_code or None) - account = self.order_id.analytic_account_id - project = Project.search([('analytic_account_id', '=', account.id)], limit=1) - if not project: - project_name = '%s (%s)' % (account.name, self.order_partner_id.ref) if self.order_partner_id.ref else account.name - project = Project.create({ - 'name': project_name, - 'allow_timesheets': self.product_id.service_type == 'timesheet', - 'analytic_account_id': account.id, - }) - # set the SO line origin if product should create project - if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']: - project.write({'sale_line_id': self.id}) + + # create the project or duplicate one + values = { + '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, + 'allow_timesheets': True, + 'analytic_account_id': account.id, + 'partner_id': self.order_id.partner_id.id, + 'sale_line_id': self.id, + } + project = self.env['project.project'].create(values) + # link project as generated by current so line + self.write({'project_id': project.id}) return project - def _timesheet_create_task_prepare_values(self): + def _timesheet_create_task_prepare_values(self, project): self.ensure_one() - project = self._timesheet_find_project() planned_hours = self._convert_qty_company_hours() return { 'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name), @@ -229,66 +236,67 @@ class SaleOrderLine(models.Model): 'project_id': project.id, 'sale_line_id': self.id, 'company_id': self.company_id.id, - 'user_id': False, # force non assigned task, as created as sudo() + 'user_id': False, # force non assigned task, as created as sudo() } @api.multi - def _timesheet_create_task(self): + def _timesheet_create_task(self, project): """ Generate task for the given so line, and link it. - - :return a mapping with the so line id and its linked task - :rtype dict + :param project: record of project.project in which the task should be created + :return task: record of the created task """ - result = {} - for so_line in self: - # create task - values = so_line._timesheet_create_task_prepare_values() - task = self.env['project.task'].sudo().create(values) - so_line.write({'task_id': task.id}) - # post message on SO - msg_body = _("Task Created (%s): %s") % (so_line.product_id.name, task.id, task.name) - so_line.order_id.message_post(body=msg_body) - # post message on task - task_msg = _("This task has been created from: %s (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name) - task.message_post(body=task_msg) - - result[so_line.id] = task - return result - - @api.multi - def _timesheet_find_task(self): - """ Find the task generated by the so lines. If no task linked, it will be - created automatically. - - :return a mapping with the so line id and its linked task - :rtype dict - """ - # one search for all so lines - tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)]) - task_sol_mapping = {task.sale_line_id.id: task for task in tasks} - - result = {} - for so_line in self: - # If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task. - task = task_sol_mapping.get(so_line.id) - # If not found, create one task for the so line - if not task: - task = so_line._timesheet_create_task()[so_line.id] - result[so_line.id] = task - return result + 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 @api.multi 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. """ - for so_line in self.filtered(lambda sol: sol.is_service): - # create task - if so_line.product_id.service_tracking == 'task_global_project': - so_line._timesheet_find_task() - # create project - if so_line.product_id.service_tracking == 'project_only': - so_line._timesheet_find_project() - # create project and task - if so_line.product_id.service_tracking == 'task_new_project': - so_line._timesheet_find_task() + 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_new_project']) + + # search so lines from SO of current so lines having already generated their project, in order to check if the current one can + # create its own project, or reuse + 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_new_project']), ('product_id.project_template_id', '=', False)]) + map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project} + + # 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_context(force_company=sol.company_id.id).project_id for sol in so_line_task_global_project} + + def _can_create_project(sol): + if not sol.project_id: + if sol.order_id.id not in map_so_project: + return True + 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_new_project: create a new project, based or not on a template (1 per SO). May be create a task too. + if so_line_new_project: + for so_line in so_line_new_project: + project = so_line.project_id + if not project and _can_create_project(so_line): + project = so_line._timesheet_create_project() + map_so_project[so_line.order_id.id] = project + if so_line.product_id.service_tracking == 'task_new_project': + if not project: + project = map_so_project[so_line.order_id.id] + so_line._timesheet_create_task(project=project) diff --git a/addons/sale_timesheet/tests/test_sale_timesheet.py b/addons/sale_timesheet/tests/test_sale_timesheet.py index 212fa2b550c..588480a56a7 100644 --- a/addons/sale_timesheet/tests/test_sale_timesheet.py +++ b/addons/sale_timesheet/tests/test_sale_timesheet.py @@ -56,9 +56,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): so_line_ordered_global_project.product_id_change() sale_order.action_confirm() task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)]) + project_serv1 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation") - self.assertTrue(sale_order.project_ids, "A project should have been created by the SO, when confirmed.") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 2 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv1.analytic_account_id, "The created project should be linked to the analytic account of the SO") # create invoice sale_order.action_invoice_create() @@ -102,7 +104,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): }) self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so') - # add so line with produdct "create task in new project". (project will be the one from SO) + # add so line with produdct "create task in new project". so_line_ordered_task_new_project = self.env['sale.order.line'].create({ 'name': self.product_order_timesheet3.name, 'product_id': self.product_order_timesheet3.id, @@ -111,17 +113,16 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'price_unit': self.product_order_timesheet3.list_price, 'order_id': sale_order.id, }) - task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)]) - project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)]) + self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.') self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'new task in new project' product, since it reuse the one from 'project only'.") # create invoice invoice_id = sale_order.action_invoice_create() invoice = self.env['account.invoice'].browse(invoice_id) self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO") self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong') - self.assertEqual(project_serv2, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.") def test_timesheet_delivery(self): """ Test timesheet invoicing with 'invoice on delivery' timetracked products @@ -160,22 +161,24 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # confirm SO sale_order.action_confirm() - task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) - task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) - project_serv3 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)]) + task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) + project_serv2 = self.env['project.project'].search([('sale_line_id', '=', so_line_deliver_task_project.id)]) - self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project") - self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project") - self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") + self.assertEqual(task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project") + self.assertTrue(task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project") + self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in a new project") self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation') - self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project") + self.assertEqual(sale_order.analytic_account_id, task_serv2.project_id.analytic_account_id, "SO should have create a project") self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation") + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") # let's log some timesheets self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': task_serv2.project_id.id, # global project - 'task_id': task_serv2.id, + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, 'unit_amount': 10.5, 'employee_id': self.employee_manager.id, }) @@ -191,8 +194,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): # log some timesheets again self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': task_serv2.project_id.id, # global project - 'task_id': task_serv2.id, + 'project_id': task_serv1.project_id.id, # global project + 'task_id': task_serv1.id, 'unit_amount': 39.5, 'employee_id': self.employee_user.id, }) @@ -214,12 +217,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'price_unit': self.product_delivery_timesheet4.list_price, 'order_id': sale_order.id, }) - self.assertEqual(project_serv3, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO") + self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'.") # let's log some timesheets on the project self.env['account.analytic.line'].create({ 'name': 'Test Line', - 'project_id': project_serv3.id, # global project + 'project_id': project_serv2.id, # global project 'unit_amount': 7, 'employee_id': self.employee_user.id, }) @@ -258,6 +261,10 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): self.assertTrue(sale_order.project_ids, "Sales Order should have create a project") self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation') + project_serv2 = so_line_manual_only_project.project_id + self.assertTrue(project_serv2, "A second project is created when selling 'project only' after SO confirmation.") + self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO") + # let's log some timesheets (on task and project) timesheet1 = self.env['account.analytic.line'].create({ 'name': 'Test Line', @@ -274,6 +281,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart): 'employee_id': self.employee_manager.id, }) + self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'.") self.assertEqual(so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too") self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category") self.assertTrue(float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line")