From 3c7626fe0861854d3fb51d9cbef206683935deec Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 15 Feb 2021 08:38:41 +0100 Subject: [PATCH 01/18] [IMP] sale_timesheet: simplify the pricing type in project settings Before this commit, the user must choose a pricing type and configure it. This commit changes the pricing type to a compute non-stored field and now the user will configure what he wants for his project and the pricing type changed according it. That is, the SO, SOL and employee mappings fields will always be visible in the basic project. When the user set the SO and SOL the pricing type becomes project rate and if he add an employee mapping, then the pricing type becomes employee rate. task-2442683 closes odoo/odoo#66181 --- addons/sale_project/views/product_views.xml | 4 +- addons/sale_timesheet/models/product.py | 2 +- addons/sale_timesheet/models/project.py | 62 ++++++++++++++++++- addons/sale_timesheet/models/sale_order.py | 3 +- addons/sale_timesheet/tests/common.py | 8 --- .../tests/test_project_billing.py | 25 +++++--- .../test_project_billing_multicompany.py | 1 - .../test_so_line_determined_in_timesheet.py | 6 +- .../views/project_task_views.xml | 8 +-- 9 files changed, 89 insertions(+), 30 deletions(-) diff --git a/addons/sale_project/views/product_views.xml b/addons/sale_project/views/product_views.xml index 5afcd0a3b1e..ae0a57a3e5a 100644 --- a/addons/sale_project/views/product_views.xml +++ b/addons/sale_project/views/product_views.xml @@ -8,8 +8,8 @@ - - + + diff --git a/addons/sale_timesheet/models/product.py b/addons/sale_timesheet/models/product.py index 8067d837487..c251e5084c7 100644 --- a/addons/sale_timesheet/models/product.py +++ b/addons/sale_timesheet/models/product.py @@ -20,7 +20,7 @@ class ProductTemplate(models.Model): ], ondelete={'timesheet': 'set default'}) # override domain project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") - project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', 'in', ('fixed_rate', 'employee_rate')), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") + project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', '!=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]") service_upsell_warning = fields.Boolean('Upsell Warning', help="The salesperson in charge will be assigned an activity informing him of an upselling opportunity once the selected threshold is reached.") service_upsell_threshold = fields.Float('Threshold', help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.") diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 01268b00d40..a09e6ccb53a 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -4,7 +4,8 @@ from collections import defaultdict from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError +from odoo.osv import expression +from odoo.exceptions import ValidationError, UserError # YTI PLEASE SPLIT ME @@ -29,6 +30,8 @@ class Project(models.Model): ('fixed_rate', 'Project rate'), ('employee_rate', 'Employee rate') ], string="Pricing", default="task_rate", + compute='_compute_pricing_type', + search='_search_pricing_type', help='The task rate is perfect if you would like to bill different services to different customers at different rates. The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.') sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False, help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked." @@ -47,7 +50,62 @@ class Project(models.Model): default=_default_timesheet_product_id) warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate') - @api.depends('allow_billable', 'sale_order_id', 'partner_id', 'pricing_type') + @api.depends('sale_order_id', 'sale_line_employee_ids', 'allow_billable') + def _compute_pricing_type(self): + billable_projects = self.filtered('allow_billable') + for project in billable_projects: + if project.sale_line_employee_ids: + project.pricing_type = 'employee_rate' + elif project.sale_order_id: + project.pricing_type = 'fixed_rate' + else: + project.pricing_type = 'task_rate' + (self - billable_projects).update({'pricing_type': False}) + + def _search_pricing_type(self, operator, value): + """ Search method for pricing_type field. + + This method returns a domain based on the operator and the value given in parameter: + - operator = '=': + - value = 'task_rate': [('sale_line_employee_ids', '=', False), ('sale_order_id', '=', False), ('allow_billable', '=', True)] + - value = 'fixed_rate': [('sale_line_employee_ids', '=', False), ('sale_order_id', '!=', False), ('allow_billable', '=', True)] + - value = 'employee_rate': [('sale_line_employee_ids', '!=', False), ('sale_order_id', '!=', False), ('allow_billable', '=', True)] + - value is False: [('allow_billable', '=', False)] + - operator = '!=': + - value = 'task_rate': ['|', ('sale_line_employee_ids', '!=', False), ('sale_order_id', '!=', False), ('allow_billable', '=', True)] + - value = 'fixed_rate': ['|', ('sale_line_employee_ids', '!=', False), ('sale_order_id', '=', False), ('allow_billable', '=', True)] + - value = 'employee_rate': [('sale_line_employee_ids', '=', False), ('sale_order_id', '=', False), ('allow_billable', '=', True)] + - value is False: [('allow_billable', '!=', False)] + + :param operator: the supported operator is either '=' or '!='. + :param value: the value than the field should be is among these values into the following tuple: (False, 'task_rate', 'fixed_rate', 'employee_rate'). + + :returns: the domain to find the expected projects. + """ + if operator not in ('=', '!='): + raise UserError(_('Operation not supported')) + if not ((isinstance(value, bool) and value is False) or (isinstance(value, str) and value in ('task_rate', 'fixed_rate', 'employee_rate'))): + return UserError(_('Value does not exist in the pricing type')) + if value is False: + return [('allow_billable', operator, value)] + + so_cond = ('sale_order_id', '!=', False) + mapping_cond = ('sale_line_employee_ids', '!=', False) + if value == 'task_rate': + domain = [expression.NOT_OPERATOR, so_cond, expression.NOT_OPERATOR, mapping_cond] + elif value == 'fixed_rate': + domain = [so_cond, expression.NOT_OPERATOR, mapping_cond] + else: # value == 'employee_rate' + domain = [so_cond, mapping_cond] + + domain = expression.normalize_domain(domain) + if operator != '=': + domain.insert(0, expression.NOT_OPERATOR) + domain = expression.distribute_not(domain) + domain = expression.AND([domain, [('allow_billable', '=', True)]]) + return domain + + @api.depends('sale_order_id', 'partner_id', 'pricing_type') def _compute_display_create_order(self): for project in self: show = True diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index c2d5ed1a8ea..afc20297b7e 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -16,7 +16,7 @@ class SaleOrder(models.Model): timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user") # override domain - project_id = fields.Many2one(domain="[('pricing_type', 'in', ('fixed_rate', 'task_rate')), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]") + project_id = fields.Many2one(domain="[('pricing_type', '!=', 'employee_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]") timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id') timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit") @@ -254,7 +254,6 @@ class SaleOrderLine(models.Model): """Generate project values""" values = super()._timesheet_create_project_prepare_values() values['allow_billable'] = True - values['pricing_type'] = 'fixed_rate' return values def _recompute_qty_to_invoice(self, start_date, end_date): diff --git a/addons/sale_timesheet/tests/common.py b/addons/sale_timesheet/tests/common.py index 86032bb572c..2645a985537 100644 --- a/addons/sale_timesheet/tests/common.py +++ b/addons/sale_timesheet/tests/common.py @@ -91,14 +91,6 @@ class TestCommonSaleTimesheet(TestSaleCommon): 'partner_id': cls.partner_b.id, 'analytic_account_id': cls.analytic_account_sale.id, }) - cls.project_project_rate = cls.project_task_rate.copy({ - 'name': 'Project with pricing_type="project_rate"', - 'pricing_type': 'fixed_rate', - }) - cls.project_employee_rate = cls.project_task_rate.copy({ - 'name': 'Project with pricing_type="employee_rate"', - 'pricing_type': 'employee_rate', - }) cls.project_subtask = Project.create({ 'name': "Sub Task Project (non billable)", diff --git a/addons/sale_timesheet/tests/test_project_billing.py b/addons/sale_timesheet/tests/test_project_billing.py index acc4ec6e8e3..02187ca1243 100644 --- a/addons/sale_timesheet/tests/test_project_billing.py +++ b/addons/sale_timesheet/tests/test_project_billing.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheet +from odoo.fields import Command from odoo.tests import tagged @tagged('post_install', '-at_install') @@ -74,14 +75,13 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) cls.sale_order_2.action_confirm() - cls.project_task_rate = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_task.id)], limit=1) - cls.project_task_rate2 = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_template.id)], limit=1) - - cls.project_project_rate.write({ + cls.project_project_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="project_rate"', 'sale_order_id': cls.sale_order_1.id, 'sale_line_id': cls.so1_line_order_no_task.id, }) - cls.project_employee_rate.write({ + cls.project_employee_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="employee_rate"', 'sale_order_id': cls.sale_order_1.id, 'partner_id': cls.sale_order_1.partner_id.id, }) @@ -96,6 +96,9 @@ class TestProjectBilling(TestCommonSaleTimesheet): 'employee_id': cls.employee_user.id, }) + cls.project_task_rate = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_task.id)], limit=1) + cls.project_task_rate2 = cls.env['project.project'].search([('sale_line_id', '=', cls.so2_line_deliver_project_template.id)], limit=1) + def test_make_billable_at_task_rate(self): """ Starting from a non billable project, make it billable at task rate """ Timesheet = self.env['account.analytic.line'] @@ -129,11 +132,14 @@ class TestProjectBilling(TestCommonSaleTimesheet): # Change project to billable at task rate self.project_non_billable.write({ 'allow_billable': True, - 'pricing_type': 'fixed_rate', }) # create wizard - wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({}) + wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({ + 'line_ids': [ + Command.create({'product_id': self.product_delivery_timesheet3.id, 'price_unit': self.product_delivery_timesheet3.lst_price}), + ], + }) self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project") self.assertEqual(len(wizard.line_ids), 1, "The wizard should have one line") @@ -150,6 +156,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.assertTrue(sale_order.order_line.task_id, "The SOL creates a task as they were no task already present in the project (system limitation)") self.assertEqual(sale_order.order_line.task_id.project_id, self.project_non_billable, "The created task should be in the project") self.assertEqual(sale_order.order_line.qty_delivered, timesheet1.unit_amount + timesheet2.unit_amount, "The create SOL should have an delivered quantity equals to the sum of tasks'timesheets") + self.assertEqual(self.project_non_billable.pricing_type, 'fixed_rate', 'The pricing type of the project should be project rate since we linked a SO in the project.') def test_make_billable_at_employee_rate(self): """ Starting from a non billable project, make it billable at employee rate """ @@ -183,7 +190,6 @@ class TestProjectBilling(TestCommonSaleTimesheet): # Change project to billable at employee rate self.project_non_billable.write({ 'allow_billable': True, - 'pricing_type': 'employee_rate', }) # create wizard @@ -206,6 +212,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.assertEqual(sale_order.partner_id, self.project_non_billable.partner_id, "The customer of the SO should be the same as the project") self.assertEqual(len(sale_order.order_line), 2, "The SO should have 2 lines, as in wizard map there were 2 time the same product with the same price (for 2 different employees)") self.assertEqual(len(self.project_non_billable.sale_line_employee_ids), 3, "The project have 3 lines in its map") + self.assertEqual(self.project_non_billable.pricing_type, 'employee_rate', 'The pricing type of the project should be employee rate since we have some mappings in this project.') self.assertEqual(self.project_non_billable.sale_line_id, sale_order.order_line[0], "The wizard sets sale line fallbakc on project as the first of the list") self.assertEqual(task.sale_line_id, sale_order.order_line[0], "The wizard sets sale line fallback on tasks") self.assertEqual(task.partner_id, wizard.partner_id, "The wizard sets the customer on tasks to make SOL line field visible") @@ -411,6 +418,7 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) self.assertFalse(self.project_project_rate.sale_order_id, "The SO in the project should be False because the previous SO does not for the actual customer of the project.") self.assertFalse(self.project_project_rate.sale_line_id, "The SOL in the project should be False because the SO is removed too.") + self.assertEqual(self.project_project_rate.pricing_type, 'task_rate', 'Since there is no SO and SOL in the project, the pricing type should be task rate.') # 2) Take project with pricing_type="employee_rate", change the existing customer to another and check if the SO and SOL are equal to False. self.project_employee_rate.write({ @@ -422,3 +430,4 @@ class TestProjectBilling(TestCommonSaleTimesheet): # 2.1) Check if the SOL in mapping is also equal to False self.assertFalse(self.project_employee_rate_manager.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.") self.assertFalse(self.project_employee_rate_user.sale_line_id, "The SOL in the mapping should be False because the actual customer in the project has not this SOL.") + self.assertEqual(self.project_employee_rate.pricing_type, 'employee_rate', 'Since the mappings have not been removed, the pricing type should remain the same, that is employee rate.') diff --git a/addons/sale_timesheet/tests/test_project_billing_multicompany.py b/addons/sale_timesheet/tests/test_project_billing_multicompany.py index 5722688dd83..ace380b3b93 100644 --- a/addons/sale_timesheet/tests/test_project_billing_multicompany.py +++ b/addons/sale_timesheet/tests/test_project_billing_multicompany.py @@ -17,7 +17,6 @@ class TestProjectBillingMulticompany(TestCommonSaleTimesheet): 'name': "Non Billable Project", 'allow_timesheets': True, 'allow_billable': True, - 'pricing_type': 'fixed_rate', 'company_id': cls.env.company.id, }) diff --git a/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py b/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py index 78d98e4ca75..56108cd888b 100644 --- a/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py +++ b/addons/sale_timesheet/tests/test_so_line_determined_in_timesheet.py @@ -66,7 +66,8 @@ class TestSoLineDeterminedInTimesheet(TestCommonSaleTimesheet): 4) Change the SOL in the task and check if the SOL in the timesheet has also changed. """ # 1) Define a SO and SOL in the project - self.project_project_rate.write({ + self.project_project_rate = self.project_task_rate.copy({ + 'name': 'Project with pricing_type="project_rate"', 'sale_order_id': self.so.id, 'sale_line_id': self.so.order_line[0].id, }) @@ -107,7 +108,8 @@ class TestSoLineDeterminedInTimesheet(TestCommonSaleTimesheet): 6) Change the SOL in the mapping and check if the timesheet conserne by the mapping has its SOL has been changed too. """ # 1) Define a SO, SOL and mapping for an employee in the project, - self.project_employee_rate.write({ + self.project_employee_rate = self.project_task_rate.copy({ + 'name': 'Project with pricing_type="employee_rate"', 'sale_order_id': self.so.id, 'sale_line_id': self.so.order_line[0].id, 'sale_line_employee_ids': [(0, 0, { diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 2de900b12ca..2e72e04decb 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -22,13 +22,13 @@ - + - - + + - + From 863662ffe018320574561d284b9d7ddeb9e02ae9 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 15 Feb 2021 10:06:42 +0100 Subject: [PATCH 02/18] [IMP] sale_project: not filter SOLs if SO defined in project Before this commit, when the pricing type of the project is either project rate or employee rate, the list of possible SOLs in sale_line_id is filtered to have only the SOLs in the SO. This commit removes this filter because we just want to use the SOL set in the project as a default value. task-2442683 closes odoo/odoo#66181 --- addons/sale_project/models/project.py | 2 +- addons/sale_project/views/project_task_views.xml | 1 - addons/sale_timesheet/views/project_task_views.xml | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 2e30ae70eca..22f021c47d9 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -53,7 +53,7 @@ class ProjectTask(models.Model): sale_order_id = fields.Many2one('sale.order', 'Sales Order', help="Sales order to which the task is linked.") sale_line_id = fields.Many2one( - 'sale.order.line', 'Sales Order Item', domain="[('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', project_sale_order_id)]", + 'sale.order.line', 'Sales Order Item', domain="[('is_service', '=', True), ('order_partner_id', 'child_of', commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]", compute='_compute_sale_line', store=True, readonly=False, copy=False, help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. " "Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.") diff --git a/addons/sale_project/views/project_task_views.xml b/addons/sale_project/views/project_task_views.xml index 2d2bd57e83e..465b1f1428d 100644 --- a/addons/sale_project/views/project_task_views.xml +++ b/addons/sale_project/views/project_task_views.xml @@ -46,7 +46,6 @@ - diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 2e72e04decb..31d1f56b55e 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -154,7 +154,7 @@ From 0cf146cb2d0d22669d796c167ab46a740e6572c7 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 15 Feb 2021 10:17:12 +0100 Subject: [PATCH 03/18] [IMP] sale_timesheet: update sale timesheet tour task-2442683 closes odoo/odoo#66181 --- .../static/tests/tours/sale_timesheet_tour.js | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index e178275adcf..bcace6da789 100644 --- a/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js +++ b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js @@ -185,21 +185,9 @@ tour.register('sale_timesheet_tour', { trigger: 'div.o_notebook_headers', content: 'Click on Invoicing tab to configure the invoicing of this project.', run: function (actions) { - const notebookId = $('div[name="pricing_type"]').closest("div.tab-pane").attr('id'); + const notebookId = $('div[name="sale_order_id"]').closest("div.tab-pane").attr('id'); actions.click(this.$anchor.find(`a[data-toggle="tab"][href="#${notebookId}"]`)); }, -}, { - trigger: 'div[name="pricing_type"]', - content: 'Check if the pricing type is equal to Task Rate', - run: function (actions) { - const pricingType = this.$anchor.find('input[data-value="task_rate"]').attr('checked'); - if (!pricingType) - console.error('The default pricing type of the project should be "Task Rate".'); - } -}, { - trigger: 'input[data-value="fixed_rate"]', - content: 'Change the pricing type to "Project Rate"', - run: 'click', }, { trigger: 'div[name="partner_id"]', content: 'Add the customer for this project to select an SO and SOL for this customer (e.g. Brandon Freeman).', @@ -230,10 +218,6 @@ tour.register('sale_timesheet_tour', { trigger: 'ul.ui-autocomplete > li:first-child > a', content: 'Select the Sales Order Item in the autocomplete dropdown.', run: 'click', -}, { - trigger: 'input[data-value="employee_rate"]', - content: 'Select "Employee rate" as pricing type.', - run: 'click', }, { trigger: 'div[name="sale_line_employee_ids"] td.o_field_x2many_list_row_add > a[role="button"]', content: 'Click on Add a line on the mapping list view.', From f228ac33bd88fb5ffbd407b1bf596145efa39ca2 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 15 Feb 2021 14:34:10 +0100 Subject: [PATCH 04/18] [IMP] sale_timesheet: removes pricing_type in _check_sale_line_type Since the pricing_type is compute non_stored field and the fixed_rate value of this is set when the sale_line_id is defined in the project. The condition checks if the pricing_type='fixed_rate' is useless. task-2442683 closes odoo/odoo#66181 --- addons/sale_timesheet/models/project.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index a09e6ccb53a..7bd201035b2 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -140,14 +140,13 @@ class Project(models.Model): for project in self.filtered(lambda p: not p.project_overview): project.project_overview = project.allow_billable or project.allow_timesheets - @api.constrains('sale_line_id', 'pricing_type') + @api.constrains('sale_line_id') def _check_sale_line_type(self): - for project in self: - if project.pricing_type == 'fixed_rate': - if project.sale_line_id and not project.sale_line_id.is_service: - raise ValidationError(_("A billable project should be linked to a Sales Order Item having a Service product.")) - if project.sale_line_id and project.sale_line_id.is_expense: - raise ValidationError(_("A billable project should be linked to a Sales Order Item that does not come from an expense or a vendor bill.")) + for project in self.filtered(lambda project: project.sale_line_id): + if not project.sale_line_id.is_service: + raise ValidationError(_("A billable project should be linked to a Sales Order Item having a Service product.")) + if project.sale_line_id.is_expense: + raise ValidationError(_("A billable project should be linked to a Sales Order Item that does not come from an expense or a vendor bill.")) @api.onchange('allow_billable') def _onchange_allow_billable(self): From 770410387563a2b96dc1f044a6ddef3eb29f7881 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 1 Mar 2021 11:07:12 +0100 Subject: [PATCH 05/18] [IMP] sale_timesheet: get SOL of task if customer different Before this commit, when the partner_id is set in project and the partner_id in the task is different, if the user changes the SOL in the task, the SOL of each timesheets keeps the SOL defined in the employee mapping. This commit gives the SOL of the task if the partner_id of the task is different than the one in the project. task-2442683 closes odoo/odoo#66181 --- addons/sale_timesheet/models/account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index f3749791256..a04bd038e3e 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -92,7 +92,7 @@ class AccountAnalyticLine(models.Model): if self.project_id.sale_line_id: return self.project_id.sale_line_id if self.task_id.allow_billable and self.task_id.sale_line_id: - if self.task_id.pricing_type in ('task_rate', 'fixed_rate'): + if self.task_id.pricing_type in ('task_rate', 'fixed_rate') or self.task_id.partner_id != self.project_id.partner_id: return self.task_id.sale_line_id else: # then pricing_type = 'employee_rate' map_entry = self.project_id.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == self.employee_id) From 102526b9f9998139395677ec7558a34ab7bdc159 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 1 Mar 2021 11:07:26 +0100 Subject: [PATCH 06/18] [IMP] sale_timesheet: get the last prepaid SOL Before this commit, we search the last prepaid service SOL in which the remaining hours is greater than 0 and it is in the SO of the project. The problem is when the customer of the task is different than the one in the project. No SOL is found as default SOL in the task because the SO in the project is not for the customer in the task. This commit checks if the customer of the task is the same than the one in the project, if yes, then we the search in the prepaid service SOL with the remaining hours is greater than 0 in the SO of the project. task-2442683 closes #66181 --- addons/sale_timesheet/models/project.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 7bd201035b2..aeb76806c85 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -325,7 +325,7 @@ class ProjectTask(models.Model): if not self.commercial_partner_id or not self.allow_billable: return False domain = [('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('remaining_hours', '>', 0)] - if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id: + if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id and self.commercial_partner_id == self.project_id.partner_id.commercial_partner_id: domain.append(('order_id', '=?', self.project_sale_order_id.id)) return self.env['sale.order.line'].search(domain, limit=1) From aa7e9060344e01bf739078f193113246be3d457b Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 16 Feb 2021 14:44:32 +0100 Subject: [PATCH 07/18] [IMP] sale_timesheet: remove link_selection in create SO wizard Before this commit the link_selection is no longer used because this field has recently been rendered invisible in the view. So, in this wizard we can only create a SO. This commit removes this field in the wizard and also the code about the link the project to an existing SO. task-2442683 closes odoo/odoo#66181 --- .../wizard/project_create_sale_order.py | 65 ++----------------- .../project_create_sale_order_views.xml | 15 ++--- 2 files changed, 9 insertions(+), 71 deletions(-) diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py index bd8d3765d4f..1bf91ec5886 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_create_sale_order.py @@ -55,7 +55,6 @@ class ProjectCreateSalesOrder(models.TransientModel): commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id') pricing_type = fields.Selection(related="project_id.pricing_type") - link_selection = fields.Selection([('create', 'Create a new sales order'), ('link', 'Link to an existing sales order')], required=True, default='create') sale_order_id = fields.Many2one( 'sale.order', string="Sales Order", domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") @@ -63,7 +62,7 @@ class ProjectCreateSalesOrder(models.TransientModel): line_ids = fields.One2many('project.create.sale.order.line', 'wizard_id', string='Lines') info_invoice = fields.Char(compute='_compute_info_invoice') - @api.depends('sale_order_id', 'link_selection') + @api.depends('sale_order_id') def _compute_info_invoice(self): for line in self: domain = self.env['sale.order.line']._timesheet_compute_delivered_quantity_domain() @@ -76,55 +75,12 @@ class ProjectCreateSalesOrder(models.TransientModel): label = _("hours") if company_uom == self.env.ref('uom.product_uom_day'): label = _("days") - if line.link_selection == 'create': - line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order.", amount=unit_amount, label=label) - else: - line.info_invoice = _("%(amount)s %(label)s will be added to the selected Sales Order.", amount=unit_amount, label=label) + line.info_invoice = _("%(amount)s %(label)s will be added to the new Sales Order.", amount=unit_amount, label=label) @api.onchange('partner_id') def _onchange_partner_id(self): self.sale_order_id = False - def action_link_sale_order(self): - task_no_sale_line = self.project_id.tasks.filtered(lambda task: not task.sale_line_id) - # link the project to the SO line - self.project_id.write({ - 'sale_line_id': self.sale_order_id.order_line[0].id, - 'sale_order_id': self.sale_order_id.id, - 'partner_id': self.partner_id.id, - }) - - if self.pricing_type == 'employee_rate': - lines_already_present = dict([(l.employee_id.id, l) for l in self.project_id.sale_line_employee_ids]) - EmployeeMap = self.env['project.sale.line.employee.map'].sudo() - - for wizard_line in self.line_ids: - if wizard_line.employee_id.id not in lines_already_present: - EmployeeMap.create({ - 'project_id': self.project_id.id, - 'sale_line_id': wizard_line.sale_line_id.id, - 'employee_id': wizard_line.employee_id.id, - }) - else: - lines_already_present[wizard_line.employee_id.id].write({ - 'sale_line_id': wizard_line.sale_line_id.id - }) - - # assign SOL to timesheets - for map_entry in self.project_id.sale_line_employee_ids: - self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({ - 'so_line': map_entry.sale_line_id.id - }) - else: - dict_product_sol = dict([(l.product_id.id, l.id) for l in self.sale_order_id.order_line]) - # remove SOL for task without product - # and if a task has a product that match a product from a SOL, we put this SOL on task. - for task in task_no_sale_line: - if not task.timesheet_product_id: - task.sale_line_id = False - elif task.timesheet_product_id.id in dict_product_sol: - task.write({'sale_line_id': dict_product_sol[task.timesheet_product_id.id]}) - def action_create_sale_order(self): # if project linked to SO line or at least on tasks with SO line, then we consider project as billable. if self.project_id.sale_line_id: @@ -313,23 +269,12 @@ class ProjectCreateSalesOrderLine(models.TransientModel): price_unit = fields.Float("Unit Price", help="Unit price of the sales order item.") currency_id = fields.Many2one('res.currency', string="Currency") employee_id = fields.Many2one('hr.employee', string="Employee", help="Employee that has timesheets on the project.") - sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute='_compute_sale_line_id', store=True, readonly=False) _sql_constraints = [ ('unique_employee_per_wizard', 'UNIQUE(wizard_id, employee_id)', "An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again."), ] - @api.onchange('product_id', 'sale_line_id') + @api.onchange('product_id') def _onchange_product_id(self): - if self.wizard_id.link_selection == 'link': - self.price_unit = self.sale_line_id.price_unit - self.currency_id = self.sale_line_id.currency_id - else: - self.price_unit = self.product_id.lst_price or 0 - self.currency_id = self.product_id.currency_id - - @api.depends('wizard_id.sale_order_id') - def _compute_sale_line_id(self): - for line in self: - if line.sale_line_id and line.sale_line_id.order_id != line.wizard_id.sale_order_id: - line.sale_line_id = False + self.price_unit = self.product_id.lst_price or 0 + self.currency_id = self.product_id.currency_id diff --git a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml index 4ca4db51664..857b04aae71 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml +++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml @@ -6,7 +6,6 @@ project.create.sale.order
- @@ -14,18 +13,13 @@ - - - - - + - - - + + @@ -34,8 +28,7 @@
-
From af188dfb44859fcb8dfbce61c7902b4616857a9d Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 16 Feb 2021 14:49:33 +0100 Subject: [PATCH 08/18] [IMP] sale_timesheet: remove pricing_type in create SO wizard Since the pricing_type in the project is a compute non-stored field and the user edits no longer this field. The condition on this field is not valid. This commit removes this related field in the 'project.create.sale.order.line' wizard and change the condition using this field to keep the initial behaviour. task-2442683 closes odoo/odoo#66181 --- .../wizard/project_create_sale_order.py | 13 +++---------- .../wizard/project_create_sale_order_views.xml | 7 +++---- 2 files changed, 6 insertions(+), 14 deletions(-) diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py index 1bf91ec5886..f85807e0729 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order.py +++ b/addons/sale_timesheet/wizard/project_create_sale_order.py @@ -41,12 +41,6 @@ class ProjectCreateSalesOrder(models.TransientModel): 'product_id': default_product.id, 'price_unit': default_product.lst_price }) for e in employee_from_timesheet] - else: - result['line_ids'] = [ - (0, 0, { - 'product_id': p.id, - 'price_unit': p.lst_price - }) for p in project.task_ids.timesheet_product_id] return result project_id = fields.Many2one('project.project', "Project", domain=[('sale_line_id', '=', False)], help="Project for which we are creating a sales order", required=True) @@ -54,7 +48,6 @@ class ProjectCreateSalesOrder(models.TransientModel): partner_id = fields.Many2one('res.partner', string="Customer", required=True, help="Customer of the sales order") commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id') - pricing_type = fields.Selection(related="project_id.pricing_type") sale_order_id = fields.Many2one( 'sale.order', string="Sales Order", domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]") @@ -89,7 +82,7 @@ class ProjectCreateSalesOrder(models.TransientModel): if not self.line_ids: raise UserError(_("At least one line should be filled.")) - if self.pricing_type == 'employee_rate': + if self.line_ids.employee_id: # all employee having timesheet should be in the wizard map timesheet_employees = self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids)]).mapped('employee_id') map_employees = self.line_ids.mapped('employee_id') @@ -138,9 +131,9 @@ class ProjectCreateSalesOrder(models.TransientModel): return sale_order def _make_billable(self, sale_order): - if self.pricing_type == 'fixed_rate': + if not self.line_ids.employee_id: # Then we configure the project with pricing type is equal to project rate self._make_billable_at_project_rate(sale_order) - else: + else: # Then we configure the project with pricing type is equal to employee rate self._make_billable_at_employee_rate(sale_order) def _make_billable_at_project_rate(self, sale_order): diff --git a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml index 857b04aae71..c23273e4883 100644 --- a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml +++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml @@ -11,13 +11,12 @@ -
- - + + - + From 74f07717d096c280fbe09fba20514e0dce5744ef Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Thu, 18 Feb 2021 10:22:51 +0100 Subject: [PATCH 09/18] [IMP] sale_timesheet: remove warning when allow_billable changes Before this commit, when the user unchecks the allow_billable, we display a warning to alert him the impact of this changes. This commit removes this warning because we don't need it now, the user knows the impacts of this changes. task-2442683 --- addons/sale_timesheet/models/project.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index aeb76806c85..848fc71d352 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -148,15 +148,6 @@ class Project(models.Model): if project.sale_line_id.is_expense: raise ValidationError(_("A billable project should be linked to a Sales Order Item that does not come from an expense or a vendor bill.")) - @api.onchange('allow_billable') - def _onchange_allow_billable(self): - if self.task_ids._get_timesheet() and self.allow_timesheets and not self.allow_billable: - message = _("All timesheet hours that are not yet invoiced will be removed from Sales Order on save. Discard to avoid the change.") - return {'warning': { - 'title': _("Warning"), - 'message': message - }} - def write(self, values): res = super(Project, self).write(values) if 'allow_billable' in values and not values.get('allow_billable'): From 4909a3e26bfc6a71be47bf2ee256e142746d3af9 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Tue, 23 Feb 2021 11:59:37 +0100 Subject: [PATCH 10/18] [IMP] sale_timesheet: update visibility condition of create sale order button Before this commit, the "create sale order" button on project form view is visible when the project is billable, the partner is set and the pricing_type is different than 'task_rate'. But since the pricing_type changes we need to update this condition to display this button when the pricing_type is equal to 'task_rate'. Because the others pricing types mean the project has already had a SO. This commit changes the visibility condition of this button to display it when the project is billable, the partner is set and the pricing type is equal to 'task_rate'. task-2442683 --- addons/sale_timesheet/models/project.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 848fc71d352..2ce0eddc3c4 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -108,10 +108,7 @@ class Project(models.Model): @api.depends('sale_order_id', 'partner_id', 'pricing_type') def _compute_display_create_order(self): for project in self: - show = True - if not project.partner_id or project.pricing_type == 'task_rate' or not project.allow_billable or project.sale_order_id: - show = False - project.display_create_order = show + project.display_create_order = project.partner_id and project.pricing_type == 'task_rate' @api.depends('allow_timesheets', 'allow_billable') def _compute_timesheet_product_id(self): From c2b28801ef130e846cf40aef0335d9186538ca8b Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Thu, 25 Feb 2021 15:24:26 +0100 Subject: [PATCH 11/18] [IMP] sale_timesheet: remove SO if the partner is not the same Before this commit, when the user creates a task in a project in which the partner is defined, the partner in the task is the one in the project. If the user changes the partner in the task, then the SO in this task should not be the one in the project since the partner is different. This commit removes the SO if the partner in the task is different than the one in the SO. task-2442683 closes #66181 --- addons/sale_timesheet/models/project.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 2ce0eddc3c4..0e8ac1b730b 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -269,7 +269,7 @@ class ProjectTask(models.Model): for task in self: task.analytic_account_active = task.analytic_account_active or task.analytic_account_id.active - @api.depends('sale_line_id', 'project_id', 'allow_billable') + @api.depends('sale_line_id', 'project_id', 'allow_billable', 'commercial_partner_id') def _compute_sale_order_id(self): for task in self: if not task.allow_billable: @@ -279,6 +279,8 @@ class ProjectTask(models.Model): task.sale_order_id = task.sale_line_id.sudo().order_id elif task.project_id.sale_order_id: task.sale_order_id = task.project_id.sale_order_id + if task.commercial_partner_id != task.sale_order_id.partner_id.commercial_partner_id: + task.sale_order_id = False if task.sale_order_id and not task.partner_id: task.partner_id = task.sale_order_id.partner_id From 3918060e6d25008d60c9e2190786ccf0b4cd47f1 Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Fri, 26 Feb 2021 15:22:25 +0100 Subject: [PATCH 12/18] [IMP] sale_{project,timesheet}: not filter the SOL in project Before this commit, when the SO is defined in the project, the user can only select the SOLs defined in this SO. This commit removes this filter to select all service SOLs with their state is equal to 'sale' or 'done'. task-2442683 closes #66181 --- addons/sale_project/models/project.py | 2 +- .../models/project_sale_line_employee_map.py | 9 ++++++++- addons/sale_timesheet/views/project_task_views.xml | 5 +++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 22f021c47d9..e4bd59b0d26 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -13,7 +13,7 @@ class Project(models.Model): sale_line_id = fields.Many2one( 'sale.order.line', 'Sales Order Item', copy=False, compute="_compute_sale_line_id", store=True, readonly=False, - domain="[('is_service', '=', True), ('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", + domain="[('is_service', '=', True), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_partner_id', '=', partner_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. " "Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.") sale_order_id = fields.Many2one('sale.order', 'Sales Order', diff --git a/addons/sale_timesheet/models/project_sale_line_employee_map.py b/addons/sale_timesheet/models/project_sale_line_employee_map.py index c5bac16b553..7fe52e9136d 100644 --- a/addons/sale_timesheet/models/project_sale_line_employee_map.py +++ b/addons/sale_timesheet/models/project_sale_line_employee_map.py @@ -10,8 +10,15 @@ class ProjectProductEmployeeMap(models.Model): project_id = fields.Many2one('project.project', "Project", required=True) employee_id = fields.Many2one('hr.employee', "Employee", required=True) - sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute="_compute_sale_line_id", store=True, readonly=False, required=True, domain=[('is_service', '=', True)]) + sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute="_compute_sale_line_id", store=True, readonly=False, required=True, + domain="""[ + ('is_service', '=', True), + ('is_expense', '=', False), + ('state', 'in', ['sale', 'done']), + ('order_partner_id', '=', partner_id), + '|', ('company_id', '=', False), ('company_id', '=', company_id)]""") company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id') + partner_id = fields.Many2one(related='project_id.partner_id') price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=True) currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_price_unit', store=True, readonly=False) diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 31d1f56b55e..49182a2f643 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -10,7 +10,7 @@