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 @@
@@ -22,18 +22,18 @@
-
+
-
-
+
+
-
+
-
+
-
+
@@ -62,9 +62,6 @@
-
- {'no_create': True, 'no_edit': True, 'delete': False}
- {'no_create': True, 'no_edit': True, 'delete': False}
@@ -154,7 +151,7 @@
diff --git a/addons/sale_timesheet/wizard/project_create_sale_order.py b/addons/sale_timesheet/wizard/project_create_sale_order.py
index bd8d3765d4f..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,8 +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")
- 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 +55,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 +68,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:
@@ -133,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')
@@ -182,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):
@@ -313,23 +262,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..c23273e4883 100644
--- a/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
+++ b/addons/sale_timesheet/wizard/project_create_sale_order_views.xml
@@ -6,26 +6,19 @@
project.create.sale.order