[MERGE] sale_{project,timesheet}: Simplify project, task and employee rates
Purpose ====== With the fixs we've made in project, the difference between the various rates grows thinner. Currently, the only thing that makes a difference is whether an SO is defined on the project or not. We should simplify these very similar concepts for the user. In fact, the user will not choose the pricing type anymore, he will configure what he wants and the pricing_type is computed based on it. ## Details - Simplify the pricing type in the project settings, as said before, the pricing_type is no longer stored and visible in the project.project form view. This field will be computed based on the configuration made by the user in the 'Invoicing' of project form view. - Remove the filter on the selection list for SOL in project.task, when the SO is defined in the project. Now a task can have a partner different than the one defined in its project. And this filter blocked the choice of the SOL in this case because the partner is different. - Update the sale_timesheet_tour since the pricing_type is not visible, this tour need an update to correctly work. - Remove the pricing_type in contrains of _check_sale_line_type method. Since the pricing_type is not compute, this field must no longer be in the constrains. - When the customer of the task is different then the one in the project, the SOL determined in the timesheet of the task is the SOL of the task (That is the task rate as pricing type). - Remove the link_selection in create SO wizard of project. Because the fix done in stable, hide this field and thus this wizard only create a new SO. That's why this field is deprecated and unused, so it is removed. - Remove related pricing type field in the create SO wizard. Since the pricing type is no longer choose by the user, this field is useless in this wizard. That's why, this field is removed. - Remove the warning displayed when the user unchecked the allow_billable checkbox in project.project form view. Because this warning is no longer useful and very annoying for the user each time he has this warning. The user should be conscious the impact of this changes. task-2442683 -- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#66181 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='service_type']" position="after">
|
||||
<field name="service_tracking" widget="radio" attrs="{'invisible': [('type','!=','service')]}"/>
|
||||
<field name="project_id" context="{'default_allow_billable': True, 'default_pricing_type': 'task_rate'}" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"/>
|
||||
<field name="project_template_id" context="{'active_test': False, 'default_allow_billable': True, 'default_pricing_type': 'fixed_rate'}" attrs="{'invisible':[('service_tracking','not in',['task_in_project', 'project_only'])]}"/>
|
||||
<field name="project_id" context="{'default_allow_billable': True}" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"/>
|
||||
<field name="project_template_id" context="{'active_test': False, 'default_allow_billable': True}" attrs="{'invisible':[('service_tracking','not in',['task_in_project', 'project_only'])]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
<field name="sale_order_id" attrs="{'invisible': True}" groups="sales_team.group_sale_salesman"/>
|
||||
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}"/>
|
||||
<field name="commercial_partner_id" invisible="1" />
|
||||
<field name="project_sale_order_id" invisible="1"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 <i>(e.g. Brandon Freeman)</i>.',
|
||||
@@ -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.',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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.')
|
||||
@@ -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,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<button string="Project Overview" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}" groups="project.group_project_manager"/>
|
||||
<button class="d-none d-md-inline oe_stat_button"
|
||||
type="object" name="action_view_so" icon="fa-dollar"
|
||||
attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('pricing_type', '=', 'task_rate')]}"
|
||||
attrs="{'invisible': ['|', ('allow_billable', '=', False), ('sale_order_id', '=', False)]}"
|
||||
string="Sales Order"
|
||||
groups="sales_team.group_sale_salesman_all_leads"/>
|
||||
</div>
|
||||
@@ -22,18 +22,18 @@
|
||||
<group>
|
||||
<group>
|
||||
<field name="display_create_order" invisible="1"/>
|
||||
<field name="pricing_type" attrs="{'invisible': [('allow_billable', '=', False)], 'required': ['&', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
|
||||
<field name="pricing_type" invisible="1" widget="radio"/>
|
||||
<field name="timesheet_product_id" string="Default Service" invisible="1" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
|
||||
<field name="sale_order_id" attrs="{'invisible': [('pricing_type', '=', 'task_rate')], 'readonly': [('sale_order_id', '!=', False)]}" force_save="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
|
||||
<field name="sale_line_id" string="Default Sales Order Item" attrs="{'invisible': [('pricing_type', '=', 'task_rate')]}" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
|
||||
<field name="sale_order_id" invisible="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
|
||||
<field name="sale_line_id" string="Default Sales Order Item" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="sale_line_employee_ids" attrs="{'invisible': [('pricing_type', '!=', 'employee_rate')]}">
|
||||
<field name="sale_line_employee_ids">
|
||||
<tree editable="bottom">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="partner_id" invisible="1"/>
|
||||
<field name="employee_id" options="{'no_create': True}"/>
|
||||
<field name="sale_line_id" attrs="{'required': True}" options="{'no_create': True}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
|
||||
<field name="sale_line_id" attrs="{'required': True}" options="{'no_create': True}"/>
|
||||
<field name="price_unit" widget="monetary" force_save="1" options="{'currency_field': 'currency_id'}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</tree>
|
||||
@@ -62,9 +62,6 @@
|
||||
<field name="inherit_id" ref="sale_timesheet.project_project_view_form"/>
|
||||
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="sale_order_id" position="attributes">
|
||||
<attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute>
|
||||
</field>
|
||||
<field name="sale_line_id" position="attributes">
|
||||
<attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute>
|
||||
</field>
|
||||
@@ -154,7 +151,7 @@
|
||||
<field name="so_line"
|
||||
attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}"
|
||||
context="{'with_remaining_hours': True, 'with_price_unit': True}" options="{'no_create': True, 'no_open': True}"
|
||||
domain="[('is_service', '=', True), ('order_partner_id', 'child_of', parent.commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', parent.project_sale_order_id)]"
|
||||
domain="[('is_service', '=', True), ('order_partner_id', 'child_of', parent.commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]"
|
||||
optional="hide"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='remaining_hours']" position="after">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,26 +6,19 @@
|
||||
<field name="model">project.create.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create a Sales Order">
|
||||
<field name="link_selection" nolabel="1" widget="radio" options="{'horizontal': true}" invisible="1"/>
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" readonly="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="partner_id" domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]"/>
|
||||
<field name="pricing_type" invisible="1"/>
|
||||
</group>
|
||||
<group attrs="{'invisible': [('link_selection', '!=', 'link')]}">
|
||||
<field name="sale_order_id" options="{'no_create': True, 'no_create_edit': True}" attrs="{'required': [('link_selection', '=', 'link')]}"/>
|
||||
<field name="commercial_partner_id" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group attrs="{'invisible': ['&', ('link_selection', '=', 'link'), ('pricing_type', '!=', 'employee_rate')]}">
|
||||
<field name="line_ids" nolabel="1" attrs="{'required': [('pricing_type', '=', 'employee_rate')]}">
|
||||
<group>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<tree editable="bottom">
|
||||
<field name="employee_id" options="{'no_create_edit': True, 'no_create': True}" attrs="{'column_invisible': [('parent.pricing_type', '=', 'fixed_rate')], 'required': [('parent.pricing_type', '=', 'employee_rate')]}"/>
|
||||
<field name="sale_line_id" options="{'no_create_edit': True, 'no_create': True, 'no_open': True}" domain="[('is_service', '=', True), ('order_id', '=', parent.sale_order_id)]" attrs="{'column_invisible': ['|', ('parent.link_selection', '!=', 'link'), ('parent.pricing_type', '=', 'fixed_rate')], 'required': ['&', ('parent.link_selection', '=', 'link'), ('parent.pricing_type', '=', 'employee_rate')]}"/>
|
||||
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}" attrs="{'column_invisible': [('parent.link_selection', '=', 'link')], 'required': [('parent.link_selection', '!=', 'link')]}"/>
|
||||
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}" attrs="{'readonly': [('parent.link_selection', '=', 'link')]}"/>
|
||||
<field name="employee_id" options="{'no_create_edit': True, 'no_create': True}"/>
|
||||
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/>
|
||||
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
@@ -34,8 +27,7 @@
|
||||
<field name="info_invoice" nolabel="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Link to Sales Order" type="object" name="action_link_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '!=', 'link')]}"/>
|
||||
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight" attrs="{'invisible': [('link_selection', '=', 'link')]}"/>
|
||||
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
|
||||
<button string="Cancel" special="cancel" type="object" class="btn btn-secondary oe_inline"/>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user