diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 2e30ae70eca..29a124df9fb 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -13,32 +13,26 @@ 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', - compute="_compute_sale_order_id", store=True, readonly=False, - domain="[('order_line.product_id.type', '=', 'service'), ('partner_id', '=', partner_id), ('state', 'in', ['sale', 'done'])]", - copy=False, help="Sales order to which the project is linked.") + sale_order_id = fields.Many2one(string='Sales Order', related='sale_line_id.order_id', help="Sales order to which the project is linked.") project_overview = fields.Boolean('Show Project Overview', compute='_compute_project_overview') - _sql_constraints = [ - ('sale_order_required_if_sale_line', "CHECK((sale_line_id IS NOT NULL AND sale_order_id IS NOT NULL) OR (sale_line_id IS NULL))", 'The project should be linked to a sale order to select a sale order item.'), - ] - @api.model def _map_tasks_default_valeus(self, task, project): defaults = super()._map_tasks_default_valeus(task, project) defaults['sale_line_id'] = False return defaults - @api.depends('sale_order_id') - def _compute_sale_line_id(self): - self.filtered(lambda p: p.sale_line_id and (not p.sale_order_id or p.sale_order_id != p.sale_line_id.order_id)).update({'sale_line_id': False}) - @api.depends('partner_id') - def _compute_sale_order_id(self): - self.filtered(lambda p: p.sale_order_id and (not p.partner_id or p.sale_order_id.partner_id != p.partner_id)).update({'sale_order_id': False}) + def _compute_sale_line_id(self): + self.filtered( + lambda p: + p.sale_line_id and ( + not p.partner_id or p.sale_line_id.order_partner_id.commercial_partner_id != p.partner_id.commercial_partner_id + ) + ).update({'sale_line_id': False}) @api.depends('analytic_account_id') def _compute_project_overview(self): @@ -53,7 +47,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/models/sale_order.py b/addons/sale_project/models/sale_order.py index cca78bd03f8..6652df26f1d 100644 --- a/addons/sale_project/models/sale_order.py +++ b/addons/sale_project/models/sale_order.py @@ -211,7 +211,6 @@ class SaleOrderLine(models.Model): 'analytic_account_id': account.id, 'partner_id': self.order_id.partner_id.id, 'sale_line_id': self.id, - 'sale_order_id': self.order_id.id, 'active': True, 'company_id': self.company_id.id, } 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_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/models/account.py b/addons/sale_timesheet/models/account.py index f3749791256..56d906cf5d7 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -95,7 +95,11 @@ class AccountAnalyticLine(models.Model): if self.task_id.pricing_type in ('task_rate', 'fixed_rate'): 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) + map_entry = self.project_id.sale_line_employee_ids.filtered( + lambda map_entry: + map_entry.employee_id == self.employee_id + and map_entry.sale_line_id.order_partner_id.commercial_partner_id == self.task_id.commercial_partner_id + ) if map_entry: return map_entry.sale_line_id return self.task_id.sale_line_id 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..6fd059a195f 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." @@ -46,14 +49,67 @@ class Project(models.Model): compute="_compute_timesheet_product_id", store=True, readonly=False, default=_default_timesheet_product_id) warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate') + partner_id = fields.Many2one(compute='_compute_partner_id', store=True, readonly=False) - @api.depends('allow_billable', 'sale_order_id', 'partner_id', 'pricing_type') + @api.depends('sale_line_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_line_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_line_id', '=', False), ('allow_billable', '=', True)] + - value = 'fixed_rate': [('sale_line_employee_ids', '=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', True)] + - value = 'employee_rate': [('sale_line_employee_ids', '!=', False), ('allow_billable', '=', True)] + - value is False: [('allow_billable', '=', False)] + - operator = '!=': + - value = 'task_rate': ['|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', True)] + - value = 'fixed_rate': ['|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '=', False), ('allow_billable', '=', True)] + - value = 'employee_rate': [('sale_line_employee_ids', '=', 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)] + + sol_cond = ('sale_line_id', '!=', False) + mapping_cond = ('sale_line_employee_ids', '!=', False) + if value == 'task_rate': + domain = [expression.NOT_OPERATOR, sol_cond, expression.NOT_OPERATOR, mapping_cond] + elif value == 'fixed_rate': + domain = [sol_cond, expression.NOT_OPERATOR, mapping_cond] + else: # value == 'employee_rate' + domain = [sol_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('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): @@ -82,23 +138,36 @@ 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') - def _check_sale_line_type(self): + @api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id') + def _compute_partner_id(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.")) + if project.partner_id: + continue + if project.allow_billable and project.allow_timesheets and project.pricing_type != 'task_rate': + sol = project.sale_line_id or project.sale_line_employee_ids.sale_line_id[:1] + project.partner_id = sol.order_partner_id - @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 - }} + @api.depends('partner_id') + def _compute_sale_line_id(self): + super()._compute_sale_line_id() + for project in self.filtered(lambda p: not p.sale_line_id and p.partner_id and p.pricing_type == 'employee_rate'): + # Give a SOL by default either the last SOL with service product and remaining_hours > 0 + sol = self.env['sale.order.line'].search([ + ('is_service', '=', True), + ('order_partner_id', 'child_of', project.partner_id.commercial_partner_id.id), + ('is_expense', '=', False), + ('state', 'in', ['sale', 'done']), + ('remaining_hours', '>', 0) + ], limit=1) + project.sale_line_id = sol or project.sale_line_employee_ids.sale_line_id[:1] # get the first SOL containing in the employee mappings if no sol found in the search + + @api.constrains('sale_line_id') + def _check_sale_line_type(self): + 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.")) def write(self, values): res = super(Project, self).write(values) @@ -224,7 +293,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: @@ -234,12 +303,15 @@ 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 @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable') def _compute_sale_line(self): billable_tasks = self.filtered('allow_billable') + (self - billable_tasks).update({'sale_line_id': False}) super(ProjectTask, billable_tasks)._compute_sale_line() for task in billable_tasks.filtered(lambda t: not t.sale_line_id): task.sale_line_id = task._get_last_sol_of_customer() @@ -268,7 +340,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) 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..bcb55ffd2f6 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) @@ -19,9 +26,14 @@ class ProjectProductEmployeeMap(models.Model): ('uniqueness_employee', 'UNIQUE(project_id,employee_id)', 'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.'), ] - @api.depends('project_id.sale_order_id') + @api.depends('partner_id') def _compute_sale_line_id(self): - self.filtered(lambda map_entry: not map_entry.project_id.sale_order_id and map_entry.sale_line_id).update({'sale_line_id': None}) + self.filtered( + lambda map_entry: + map_entry.sale_line_id + and map_entry.partner_id + and map_entry.sale_line_id.order_partner_id.commercial_partner_id != map_entry.partner_id.commercial_partner_id + ).update({'sale_line_id': False}) @api.depends('sale_line_id', 'sale_line_id.price_unit') def _compute_price_unit(self): 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/static/tests/tours/sale_timesheet_tour.js b/addons/sale_timesheet/static/tests/tours/sale_timesheet_tour.js index e178275adcf..7ec13918753 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_line_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).', @@ -210,16 +198,6 @@ tour.register('sale_timesheet_tour', { trigger: 'ul.o_partner_autocomplete_dropdown > li:first-child > a', content: 'Select the customer in the autocomplete dropdown', run: 'click', -}, { - trigger: 'div[name="sale_order_id"]', - content: 'Select the first Sales Order for this customer to select a SOL.', - run: function (actions) { - actions.text('S', this.$anchor.find('input')); - }, -}, { - trigger: 'ul.ui-autocomplete > li:first-child > a', - content: 'Select the Sales Order in autocomplete dropdown.', - run: 'click', }, { trigger: 'div[name="sale_line_id"]', content: 'Select a Sales Order Item as Default Sales Order Item for each task in this project.', @@ -230,10 +208,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.', diff --git a/addons/sale_timesheet/tests/__init__.py b/addons/sale_timesheet/tests/__init__.py index 5307dbcf137..2f278cf8288 100644 --- a/addons/sale_timesheet/tests/__init__.py +++ b/addons/sale_timesheet/tests/__init__.py @@ -13,3 +13,4 @@ from . import test_upsell_warning from . import test_edit_so_line_timesheet from . import test_so_line_determined_in_timesheet from . import test_sale_timesheet_ui +from . import test_project_pricing_type 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..6556fb578ca 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,15 +75,12 @@ 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({ - 'sale_order_id': cls.sale_order_1.id, + cls.project_project_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="project_rate"', 'sale_line_id': cls.so1_line_order_no_task.id, }) - cls.project_employee_rate.write({ - 'sale_order_id': cls.sale_order_1.id, + cls.project_employee_rate = cls.project_task_rate.copy({ + 'name': 'Project with pricing_type="employee_rate"', 'partner_id': cls.sale_order_1.partner_id.id, }) cls.project_employee_rate_manager = cls.env['project.sale.line.employee.map'].create({ @@ -96,6 +94,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 +130,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 +154,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 +188,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 +210,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") @@ -244,6 +249,8 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.assertEqual(task.sale_line_id, self.project_employee_rate.sale_line_id, "Task created in a project billed on 'employee rate' should be linked to the SOL defined in the project.") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") + task.write({'sale_line_id': False}) # remove the SOL to check if the timesheet has no SOL when there is no SOL in the task + # log timesheet on task timesheet1 = Timesheet.create({ 'name': 'Test Line', @@ -390,10 +397,10 @@ class TestProjectBilling(TestCommonSaleTimesheet): }) subtask._onchange_project() - self.assertFalse(task.sale_line_id, "Task moved in a employee rate billable project have empty so line") + self.assertEqual(task.sale_line_id, task.project_id.sale_line_id, "Task moved in a employee rate billable project have the SOL of the project") self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project") - self.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line") + self.assertEqual(subtask.sale_line_id, task.project_id.sale_line_id, "Subask moved in a employee rate billable project have the SOL of the project") self.assertEqual(subtask.partner_id, task.project_id.partner_id, "Subask created in a project billed on 'employee rate' should have the same customer as the one from the project") def test_customer_change_in_project(self): @@ -409,16 +416,18 @@ class TestProjectBilling(TestCommonSaleTimesheet): self.project_project_rate.write({ 'partner_id': self.partner_2.id, }) - 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.assertFalse(self.project_project_rate.sale_order_id, "The SO in the project should be False because the previous SO customer does not match 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 previous SOL customer does not match the actual customer of the project.") + 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({ 'partner_id': self.partner_2.id, }) - self.assertFalse(self.project_employee_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_employee_rate.sale_line_id, "The SOL in the project should be False because the SO is removed too.") + self.assertFalse(self.project_employee_rate.sale_order_id, "The SO in the project should be False because the previous SO customer does not match the actual customer of the project.") + self.assertFalse(self.project_employee_rate.sale_line_id, "The SOL in the project should be False because the previous SOL customer does not match the actual customer of the project.") # 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_project_pricing_type.py b/addons/sale_timesheet/tests/test_project_pricing_type.py new file mode 100644 index 00000000000..b3538f9b4d7 --- /dev/null +++ b/addons/sale_timesheet/tests/test_project_pricing_type.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from .common import TestCommonSaleTimesheet + + +@tagged('-at_install', 'post_install') +class TestProjectPricingType(TestCommonSaleTimesheet): + + def test_pricing_type(self): + """ Test the _compute_pricing_type when the user add a sales order item or some employee mappings in the project + + Test Case: + ========= + 1) Take a project non billable and check if the pricing_type is equal to False + 2) Set allow_billable to True and check if the pricing_type is equal to task_rate (if no SOL and no mappings) + 3) Set a customer and a SOL in the project and check if the pricing_type is equal to fixed_rate (project rate) + 4) Set a employee mapping and check if the pricing_type is equal to employee_rate + """ + # 1) Take a project non billable and check if the pricing_type is equal to False + project = self.project_non_billable + self.assertFalse(project.allow_billable, 'The allow_billable should be false if the project is non billable.') + self.assertFalse(project.pricing_type, 'The pricing type of this project should be equal to False since it is non billable.') + + # 2) Set allow_billable to True and check if the pricing_type is equal to task_rate (if no SOL and no mappings) + project.write({ + 'allow_billable': True, + }) + + self.assertTrue(project.allow_billable, 'The allow_billable should be updated and equal to True.') + self.assertFalse(project.sale_order_id, 'The sales order should be unset.') + self.assertFalse(project.sale_line_id, 'The sales order item should be unset.') + self.assertFalse(project.sale_line_employee_ids, 'The employee mappings should be empty.') + self.assertEqual(project.pricing_type, 'task_rate', 'The pricing type should be equal to task_rate.') + + # 3) Set a customer and a SOL in the project and check if the pricing_type is equal to fixed_rate (project rate) + project.write({ + 'partner_id': self.partner_b.id, + 'sale_line_id': self.so.order_line[0].id, + }) + + self.assertEqual(project.sale_order_id, self.so, 'The sales order should be equal to the one set in the project.') + self.assertEqual(project.sale_line_id, self.so.order_line[0], 'The sales order item should be the one chosen.') + self.assertEqual(project.pricing_type, 'fixed_rate', 'The pricing type should be equal to fixed_rate since the project has a sales order item.') + + # 4) Set a employee mapping and check if the pricing_type is equal to employee_rate + project.write({ + 'sale_line_employee_ids': [(0, 0, { + 'employee_id': self.employee_user.id, + 'sale_line_id': self.so.order_line[1].id, + })] + }) + + self.assertEqual(len(project.sale_line_employee_ids), 1, 'The project should have an employee mapping.') + self.assertEqual(project.pricing_type, 'employee_rate', 'The pricing type should be equal to employee_rate since the project has an employee mapping.') + + # Even if the project has no sales order item, since it has an employee mapping, the pricing type must be equal to employee_rate. + project.write({ + 'sale_line_id': False, + }) + self.assertFalse(project.sale_order_id, 'The sales order of the project should be empty.') + self.assertFalse(project.sale_line_id, 'The sales order item of the project should be empty.') + self.assertEqual(project.pricing_type, 'employee_rate', 'The pricing type should always be equal to employee_rate.') 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..21562969796 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,8 +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({ - 'sale_order_id': self.so.id, + self.project_project_rate = self.project_task_rate.copy({ + 'name': 'Project with pricing_type="project_rate"', 'sale_line_id': self.so.order_line[0].id, }) @@ -107,8 +107,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({ - 'sale_order_id': self.so.id, + self.project_employee_rate = self.project_task_rate.copy({ + 'name': 'Project with pricing_type="employee_rate"', 'sale_line_id': self.so.order_line[0].id, 'sale_line_employee_ids': [(0, 0, { 'employee_id': self.employee_user.id, diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index 2de900b12ca..4d0a8cabd28 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -10,7 +10,7 @@