[IMP] sale_{project,timesheet}: merge bill_type and pricing_type

Before this commit, the user need to select in these both fields to
configure the pricing type of a project. Three choices are possible :
    1. Task Rate
    2. Project Rate
    3. Employee Rate

This commit merges the both fields to have single field called
pricing_type and the user selects the one of three options cited above.
The goal of this changes is to facilitate the configuration of a
project.

task-2424382
This commit is contained in:
Xavier BOL (xbo)
2021-01-14 09:02:39 +01:00
parent 7672d0b91b
commit c824d7416e
10 changed files with 32 additions and 46 deletions
+2 -2
View File
@@ -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_bill_type': 'customer_task'}" 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_bill_type': 'customer_project', '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, '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'])]}"/>
</xpath>
</field>
</record>
+5 -7
View File
@@ -23,8 +23,8 @@ class AccountAnalyticLine(models.Model):
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False)
is_so_line_edited = fields.Boolean("Is Sales Order Item Manually Edited")
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master)
@api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.bill_type', 'task_id.pricing_type')
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to pricing_type of project (See in master)
@api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.pricing_type')
def _compute_timesheet_invoice_type(self):
for timesheet in self:
if timesheet.project_id: # AAL will be set to False
@@ -81,18 +81,16 @@ class AccountAnalyticLine(models.Model):
on the one on the project
"""
if not task:
if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate':
if project.pricing_type == 'employee_rate':
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)])
if map_entry:
return map_entry.sale_line_id
if project.sale_line_id:
return project.sale_line_id
if task.allow_billable:
if task.bill_type == 'customer_task':
if task.pricing_type in ('task_rate', 'fixed_rate'):
return task.sale_line_id
if task.pricing_type == 'fixed_rate':
return task.sale_line_id
elif task.pricing_type == 'employee_rate':
else: # then pricing_type = 'employee_rate'
map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee)
if map_entry:
return map_entry.sale_line_id
+2 -2
View File
@@ -19,8 +19,8 @@ class ProductTemplate(models.Model):
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set default'})
# override domain
project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_task'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_project'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
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])]")
def _default_visible_expense_policy(self):
visibility = self.user_has_groups('project.group_project_user')
+11 -16
View File
@@ -24,16 +24,12 @@ class Project(models.Model):
def _default_timesheet_product_id(self):
return self.env.ref('sale_timesheet.time_product', False)
bill_type = fields.Selection([
('customer_task', 'Different customers'),
('customer_project', 'A unique customer')
], string="Invoice Tasks to", default="customer_task",
help='When billing tasks individually, a Sales Order will be created from each task. It is perfect if you would like to bill different services to different customers at different rates. \n When billing the whole project, a Sales Order will be created from the project instead. This option is better if you would like to bill all the tasks of a given project to a specific customer either at a fixed rate, or at an employee rate.')
pricing_type = fields.Selection([
('task_rate', 'Task rate'),
('fixed_rate', 'Project rate'),
('employee_rate', 'Employee rate')
], string="Pricing", default="fixed_rate",
help='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.')
], string="Pricing", default="task_rate",
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."
"By extension, it defines the rate at which an employee's time on the project is billed.")
@@ -63,11 +59,11 @@ class Project(models.Model):
]
@api.depends('allow_billable', 'sale_order_id', 'partner_id', 'bill_type')
@api.depends('allow_billable', 'sale_order_id', 'partner_id', 'pricing_type')
def _compute_display_create_order(self):
for project in self:
show = True
if not project.partner_id or project.bill_type != 'customer_project' or not project.allow_billable or project.sale_order_id:
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
@@ -80,9 +76,9 @@ class Project(models.Model):
elif not project.timesheet_product_id:
project.timesheet_product_id = default_product
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id', 'bill_type')
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id')
def _compute_warning_employee_rate(self):
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.bill_type == 'customer_project' and p.pricing_type == 'employee_rate')
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.pricing_type == 'employee_rate')
employees = self.env['account.analytic.line'].read_group([('task_id', 'in', projects.task_ids.ids)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
dict_project_employee = defaultdict(list)
for line in employees:
@@ -208,7 +204,7 @@ class ProjectTask(models.Model):
if not result.get('timesheet_product_id', False) and 'project_id' in result:
project = self.env['project.project'].browse(result['project_id'])
if project.bill_type != 'customer_project' or project.pricing_type != 'employee_rate':
if project.pricing_type != 'employee_rate':
result['timesheet_product_id'] = project.timesheet_product_id.id
return result
@@ -216,7 +212,6 @@ class ProjectTask(models.Model):
sale_order_id = fields.Many2one(compute='_compute_sale_order_id', store=True, readonly=False,
domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]")
analytic_account_id = fields.Many2one('account.analytic.account', related='sale_order_id.analytic_account_id')
bill_type = fields.Selection(related="project_id.bill_type")
pricing_type = fields.Selection(related="project_id.pricing_type")
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True)
@@ -290,7 +285,7 @@ class ProjectTask(models.Model):
@api.onchange('project_id')
def _onchange_project(self):
if self.project_id and self.project_id.bill_type == 'customer_project':
if self.project_id and self.project_id.pricing_type != 'task_rate':
if not self.partner_id:
self.partner_id = self.project_id.partner_id
if not self.sale_line_id:
@@ -301,7 +296,7 @@ class ProjectTask(models.Model):
# Done after super to avoid constraints on field recomputation
if values.get('project_id'):
project_dest = self.env['project.project'].browse(values['project_id'])
if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate':
if project_dest.pricing_type == 'employee_rate':
self.write({'sale_line_id': False})
return res
@@ -311,7 +306,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.bill_type == 'customer_project' and self.project_sale_order_id:
if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id:
domain.append(('order_id', '=?', self.project_sale_order_id.id))
return self.env['sale.order.line'].search(domain, limit=1)
@@ -394,7 +394,7 @@ class Project(models.Model):
task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids]
if self.env.user.has_group('sales_team.group_sale_salesman'):
if self.bill_type == 'customer_project' and self.allow_billable and not self.sale_order_id:
if self.pricing_type != 'task_rate' and self.allow_billable and not self.sale_order_id:
actions.append({
'label': _("Create a Sales Order"),
'type': 'action',
+2 -3
View File
@@ -13,7 +13,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="['|', ('bill_type', '=', 'customer_task'), ('pricing_type', '=', 'fixed_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
project_id = fields.Many2one(domain="[('pricing_type', 'in', ('fixed_rate', 'task_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")
@@ -147,7 +147,7 @@ class SaleOrderLine(models.Model):
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
line.qty_delivered_method = 'timesheet'
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type', 'project_id.bill_type')
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
@@ -182,7 +182,6 @@ class SaleOrderLine(models.Model):
"""Generate project values"""
values = super()._timesheet_create_project_prepare_values()
values['allow_billable'] = True
values['bill_type'] = 'customer_project'
values['pricing_type'] = 'fixed_rate'
return values
@@ -11,7 +11,7 @@ class TestProjectBilling(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
# set up
cls.employee_tde = cls.env['hr.employee'].create({
'name': 'Employee TDE',
@@ -97,7 +97,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
'name': "Project billed at Employee Rate",
'allow_timesheets': True,
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'employee_rate',
'sale_order_id': cls.sale_order_1.id,
'partner_id': cls.sale_order_1.partner_id.id,
@@ -146,7 +145,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
# Change project to billable at task rate
self.project_non_billable.write({
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'fixed_rate',
})
task.timesheet_product_id = self.product_delivery_timesheet3
@@ -202,7 +200,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
# Change project to billable at employee rate
self.project_non_billable.write({
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'employee_rate',
})
@@ -260,7 +257,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
})
self.assertTrue(task.allow_billable, "Task in project 'employee rate' should be billable")
self.assertEqual(task.bill_type, 'customer_project', "Task in project 'employee rate' should be billed at employee rate")
self.assertEqual(task.pricing_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate")
self.assertFalse(task.sale_line_id, "Task created in a project billed on 'employee rate' should not be linked to a SOL")
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")
@@ -17,7 +17,7 @@ class TestProjectBillingMulticompany(TestCommonSaleTimesheet):
'name': "Non Billable Project",
'allow_timesheets': True,
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'fixed_rate',
'company_id': cls.env.company.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)]}"/>
<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), ('bill_type', '!=', 'customer_project')]}"
attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('pricing_type', '=', 'task_rate')]}"
string="Sales Order"
groups="sales_team.group_sale_salesman"/>
</div>
@@ -22,14 +22,13 @@
<group>
<group>
<field name="display_create_order" invisible="1"/>
<field name="bill_type" widget="radio"/>
<field name="pricing_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
<field name="timesheet_product_id" string="Service" attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), ('bill_type', '!=', 'customer_task')], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
<field name="sale_order_id" attrs="{'invisible': [('bill_type', '!=', 'customer_project')], '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': [('bill_type', '!=', 'customer_project')]}" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="pricing_type" attrs="{'invisible': [('allow_billable', '=', False)], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
<field name="timesheet_product_id" string="Service" attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), ('pricing_type', '!=', 'task_rate')], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" 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}"/>
</group>
</group>
<field name="sale_line_employee_ids" attrs="{'invisible': ['|', ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'employee_rate')]}">
<field name="sale_line_employee_ids" attrs="{'invisible': [('pricing_type', '!=', 'employee_rate')]}">
<tree editable="top">
<field name="company_id" invisible="1"/>
<field name="project_id" invisible="1"/>
@@ -142,7 +141,6 @@
<field name="has_multi_sol" invisible="1"/>
</xpath>
<xpath expr="//field[@name='partner_phone']" position="after">
<field name="bill_type" invisible="1"/>
<field name="pricing_type" invisible="1"/>
</xpath>
<xpath expr="//field[@name='timesheet_ids']" position="attributes">
@@ -25,7 +25,7 @@ class ProjectCreateSalesOrder(models.TransientModel):
result['project_id'] = active_id
if not result.get('partner_id', False):
result['partner_id'] = project.partner_id.id
if project.bill_type == 'customer_project' and not result.get('line_ids', False):
if project.pricing_type != 'task_rate' and not result.get('line_ids', False):
if project.pricing_type == 'employee_rate':
default_product = self.env.ref('sale_timesheet.time_product', False)
result['line_ids'] = [