[MERGE] sale_timesheet: employee rate for fsm projects
Purpose ====== Currently, the user can configure his field service project to be billed at an employee rate but it just won't work. The rates configured for each employee on the project are not taken into account when the task is marked as done. As a consequence, the only workaround to support this kind of use-case is to create 1 task per employee with a different rate (or to add service products on the task). Those solutions are far from being ideal. We should give more flexibility. ## Details To reach the goal, some updates are made: - Move the timesheet_product field in employee mappings into the fsm sale module, because this field is only used for fsm projects, - render _timesheet_determine_sale_line no static to easily override it, - fix bug when the user change project on task form view, the project for non billed timesheets is not correctly changed, - let the SOL in task when the user wants to change the project of it to a project with pricing type defined as employee rate. Before when the pricing type of the destination project is employee rate, we remove the SOL of the task. task-2376382 -- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#66157 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
@@ -46,7 +46,7 @@ class AccountAnalyticLine(models.Model):
|
||||
@api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable')
|
||||
def _compute_so_line(self):
|
||||
for timesheet in self.filtered(lambda t: not t.is_so_line_edited and t._is_not_billed()): # Get only the timesheets are not yet invoiced
|
||||
timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id)
|
||||
timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line()
|
||||
|
||||
def _is_not_billed(self):
|
||||
self.ensure_one()
|
||||
@@ -76,29 +76,29 @@ class AccountAnalyticLine(models.Model):
|
||||
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
|
||||
return values
|
||||
|
||||
@api.model
|
||||
def _timesheet_determine_sale_line(self, task, employee, project):
|
||||
def _timesheet_determine_sale_line(self):
|
||||
""" Deduce the SO line associated to the timesheet line:
|
||||
1/ timesheet on task rate: the so line will be the one from the task
|
||||
2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback
|
||||
on the one on the project
|
||||
"""
|
||||
if not task:
|
||||
if project.pricing_type == 'employee_rate':
|
||||
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)])
|
||||
self.ensure_one()
|
||||
|
||||
if not self.task_id:
|
||||
if self.project_id.pricing_type == 'employee_rate':
|
||||
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id), ('employee_id', '=', self.employee_id.id)])
|
||||
if map_entry:
|
||||
return map_entry.sale_line_id
|
||||
if project.sale_line_id:
|
||||
return project.sale_line_id
|
||||
if task.allow_billable and task.sale_line_id:
|
||||
if task.pricing_type in ('task_rate', 'fixed_rate'):
|
||||
return task.sale_line_id
|
||||
if self.project_id.sale_line_id:
|
||||
return self.project_id.sale_line_id
|
||||
if self.task_id.allow_billable and self.task_id.sale_line_id:
|
||||
if self.task_id.pricing_type in ('task_rate', 'fixed_rate'):
|
||||
return self.task_id.sale_line_id
|
||||
else: # then pricing_type = 'employee_rate'
|
||||
map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee)
|
||||
map_entry = self.project_id.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == self.employee_id)
|
||||
if map_entry:
|
||||
return map_entry.sale_line_id
|
||||
if task.sale_line_id or project.sale_line_id:
|
||||
return task.sale_line_id or project.sale_line_id
|
||||
return self.task_id.sale_line_id
|
||||
return False
|
||||
|
||||
def _timesheet_get_portal_domain(self):
|
||||
|
||||
@@ -262,15 +262,6 @@ class ProjectTask(models.Model):
|
||||
if not self.sale_line_id:
|
||||
self.sale_line_id = self.project_id.sale_line_id
|
||||
|
||||
def write(self, values):
|
||||
res = super(ProjectTask, self).write(values)
|
||||
# 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.pricing_type == 'employee_rate':
|
||||
self.write({'sale_line_id': False})
|
||||
return res
|
||||
|
||||
def _get_last_sol_of_customer(self):
|
||||
# Get the last SOL made for the customer in the current task where we need to compute
|
||||
self.ensure_one()
|
||||
@@ -284,7 +275,7 @@ class ProjectTask(models.Model):
|
||||
def _get_timesheet(self):
|
||||
# return not invoiced timesheet and timesheet without so_line or so_line linked to task
|
||||
timesheet_ids = super(ProjectTask, self)._get_timesheet()
|
||||
return timesheet_ids.filtered(lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') and (not t.so_line or t.so_line == t.task_id._origin.sale_line_id))
|
||||
return timesheet_ids.filtered(lambda t: t._is_not_billed())
|
||||
|
||||
def _get_action_view_so_ids(self):
|
||||
return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
|
||||
|
||||
@@ -12,13 +12,6 @@ class ProjectProductEmployeeMap(models.Model):
|
||||
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)])
|
||||
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id')
|
||||
timesheet_product_id = fields.Many2one(
|
||||
'product.product', string='Service',
|
||||
domain="""[
|
||||
('type', '=', 'service'),
|
||||
('invoice_policy', '=', 'delivery'),
|
||||
('service_type', '=', 'timesheet'),
|
||||
'|', ('company_id', '=', False), ('company_id', '=', company_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)
|
||||
|
||||
@@ -30,26 +23,16 @@ class ProjectProductEmployeeMap(models.Model):
|
||||
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})
|
||||
|
||||
@api.depends('sale_line_id', 'sale_line_id.price_unit', 'timesheet_product_id')
|
||||
@api.depends('sale_line_id', 'sale_line_id.price_unit')
|
||||
def _compute_price_unit(self):
|
||||
for line in self:
|
||||
if line.sale_line_id:
|
||||
line.price_unit = line.sale_line_id.price_unit
|
||||
line.currency_id = line.sale_line_id.currency_id
|
||||
elif line.timesheet_product_id:
|
||||
line.price_unit = line.timesheet_product_id.lst_price
|
||||
line.currency_id = line.timesheet_product_id.currency_id
|
||||
else:
|
||||
line.price_unit = 0
|
||||
line.currency_id = False
|
||||
|
||||
@api.onchange('timesheet_product_id')
|
||||
def _onchange_timesheet_product_id(self):
|
||||
if self.timesheet_product_id:
|
||||
self.price_unit = self.timesheet_product_id.lst_price
|
||||
else:
|
||||
self.price_unit = 0.0
|
||||
|
||||
@api.model
|
||||
def create(self, values):
|
||||
res = super(ProjectProductEmployeeMap, self).create(values)
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import math
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_compare
|
||||
from odoo.tools import float_compare, format_amount
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
@@ -134,41 +135,61 @@ class SaleOrderLine(models.Model):
|
||||
|
||||
def name_get(self):
|
||||
res = super(SaleOrderLine, self).name_get()
|
||||
if self.env.context.get('with_remaining_hours'):
|
||||
with_remaining_hours = self.env.context.get('with_remaining_hours')
|
||||
with_price_unit = self.env.context.get('with_price_unit')
|
||||
if with_remaining_hours or with_price_unit:
|
||||
names = dict(res)
|
||||
result = []
|
||||
uom_hour = self.env.ref('uom.product_uom_hour')
|
||||
uom_day = self.env.ref('uom.product_uom_day')
|
||||
uom_hour = with_remaining_hours and self.env.ref('uom.product_uom_hour')
|
||||
uom_day = with_remaining_hours and self.env.ref('uom.product_uom_day')
|
||||
sols_by_so_dict = with_price_unit and defaultdict(lambda: self.env[self._name]) # key: (sale_order_id, product_id), value: sale order line
|
||||
for line in self:
|
||||
name = names.get(line.id)
|
||||
if line.remaining_hours_available:
|
||||
company = self.env.company
|
||||
encoding_uom = company.timesheet_encode_uom_id
|
||||
remaining_time = ''
|
||||
if encoding_uom == uom_hour:
|
||||
hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
|
||||
round_minutes = minutes / 30
|
||||
minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
|
||||
if minutes > 1:
|
||||
minutes = 0
|
||||
hours += 1
|
||||
else:
|
||||
minutes = minutes * 30
|
||||
remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
|
||||
sign='-' if line.remaining_hours < 0 else '',
|
||||
hours=hours,
|
||||
minutes=minutes)
|
||||
elif encoding_uom == uom_day:
|
||||
remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
|
||||
remaining_time = ' ({qty:.02f} {unit})'.format(
|
||||
qty=remaining_days,
|
||||
unit=_('days') if abs(remaining_days) > 1 else _('day')
|
||||
if with_remaining_hours:
|
||||
name = names.get(line.id)
|
||||
if line.remaining_hours_available:
|
||||
company = self.env.company
|
||||
encoding_uom = company.timesheet_encode_uom_id
|
||||
remaining_time = ''
|
||||
if encoding_uom == uom_hour:
|
||||
hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
|
||||
round_minutes = minutes / 30
|
||||
minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
|
||||
if minutes > 1:
|
||||
minutes = 0
|
||||
hours += 1
|
||||
else:
|
||||
minutes = minutes * 30
|
||||
remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
|
||||
sign='-' if line.remaining_hours < 0 else '',
|
||||
hours=hours,
|
||||
minutes=minutes)
|
||||
elif encoding_uom == uom_day:
|
||||
remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
|
||||
remaining_time = ' ({qty:.02f} {unit})'.format(
|
||||
qty=remaining_days,
|
||||
unit=_('days') if abs(remaining_days) > 1 else _('day')
|
||||
)
|
||||
name = '{name}{remaining_time}'.format(
|
||||
name=name,
|
||||
remaining_time=remaining_time
|
||||
)
|
||||
name = '{name}{remaining_time}'.format(
|
||||
name=name,
|
||||
remaining_time=remaining_time
|
||||
)
|
||||
result.append((line.id, name))
|
||||
if with_price_unit:
|
||||
names[line.id] = name
|
||||
if not with_price_unit:
|
||||
result.append((line.id, name))
|
||||
if with_price_unit:
|
||||
sols_by_so_dict[line.order_id.id, line.product_id.id] += line
|
||||
|
||||
if with_price_unit:
|
||||
for sols in sols_by_so_dict.values():
|
||||
if len(sols) > 1:
|
||||
result += [(
|
||||
line.id,
|
||||
'%s - %s' % (
|
||||
names.get(line.id), format_amount(self.env, line.price_unit, line.currency_id))
|
||||
) for line in sols]
|
||||
else:
|
||||
result.append((sols.id, names.get(sols.id)))
|
||||
return result
|
||||
return res
|
||||
|
||||
|
||||
@@ -23,13 +23,13 @@
|
||||
<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="timesheet_product_id" string="Service" invisible="1" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
|
||||
<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}"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="sale_line_employee_ids" attrs="{'invisible': [('pricing_type', '!=', 'employee_rate')]}">
|
||||
<tree editable="top">
|
||||
<tree editable="bottom">
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="project_id" invisible="1"/>
|
||||
<field name="employee_id" options="{'no_create': True}"/>
|
||||
@@ -153,7 +153,7 @@
|
||||
<field name="timesheet_invoice_id" invisible="1"/>
|
||||
<field name="so_line"
|
||||
attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}"
|
||||
context="{'with_remaining_hours': True}" options="{'no_create': True, 'no_open': True}"
|
||||
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)]"
|
||||
optional="hide"/>
|
||||
</xpath>
|
||||
@@ -192,7 +192,7 @@
|
||||
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='sale_line_id']" position="attributes">
|
||||
<attribute name="context">{'with_remaining_hours': True}</attribute>
|
||||
<attribute name="context">{'with_remaining_hours': True, 'with_price_unit': True}</attribute>
|
||||
<attribute name="attrs">
|
||||
{'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]}
|
||||
</attribute>
|
||||
|
||||
Reference in New Issue
Block a user