Skip to content
Permalink
Browse files

[IMP] sale_timesheet: exclude timesheet line from billing

  • Loading branch information...
alexey-pelykh committed Feb 12, 2019
1 parent 49a005b commit ede680156d8fe0b5bfdf7b73488074e8594b5714
@@ -14,6 +14,10 @@ def _default_sale_line_domain(self):
domain = super(AccountAnalyticLine, self)._default_sale_line_domain()
return expression.OR([domain, [('qty_delivered_method', '=', 'timesheet')]])

exclude_from_sale_order = fields.Boolean(
string='Exclude from Sale Order',
help='Checking this would exclude this timesheet entry from Sale Order',
)
timesheet_invoice_type = fields.Selection([
('billable_time', 'Billable Time'),
('billable_fixed', 'Billable Fixed'),
@@ -22,12 +26,12 @@ def _default_sale_line_domain(self):
timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")

@api.multi
@api.depends('so_line.product_id', 'project_id', 'task_id')
@api.depends('so_line.product_id', 'project_id', 'task_id', 'exclude_from_sale_order')
def _compute_timesheet_invoice_type(self):
for timesheet in self:
if timesheet.project_id: # AAL will be set to False
invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable'
if timesheet.task_id and timesheet.so_line.product_id.type == 'service':
if timesheet.task_id and timesheet.so_line.product_id.type == 'service' and not timesheet.exclude_from_sale_order:
if timesheet.so_line.product_id.invoice_policy == 'delivery':
if timesheet.so_line.product_id.service_type == 'timesheet':
invoice_type = 'billable_time'
@@ -37,53 +41,103 @@ def _compute_timesheet_invoice_type(self):
invoice_type = 'billable_fixed'
timesheet.timesheet_invoice_type = invoice_type

@api.onchange('employee_id')
@api.onchange('task_id', 'employee_id')
def _onchange_task_id_employee_id(self):
if self.project_id: # timesheet only
if self.task_id.billable_type == 'task_rate':
self.so_line = self.task_id.sale_line_id
elif self.task_id.billable_type == 'employee_rate':
self.so_line = self._timesheet_determine_sale_line(self.task_id, self.employee_id)
else:
self.so_line = False
self.so_line = self._timesheet_get_sale_line()

@api.onchange('exclude_from_sale_order')
def _onchange_exclude_from_sale_order(self):
if self.project_id: # timesheet only
self.so_line = self._timesheet_get_sale_line()

@api.constrains('so_line', 'project_id')
def _check_sale_line_in_project_map(self):
def _check_so_line_valid_for_project(self):
for timesheet in self:
if timesheet.project_id and timesheet.so_line: # billed timesheet
if timesheet.so_line not in timesheet.project_id.mapped('sale_line_employee_ids.sale_line_id') | timesheet.task_id.sale_line_id | timesheet.project_id.sale_line_id:
raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line."))
if not timesheet.project_id or not timesheet.so_line:
continue

if timesheet.so_line not in timesheet._get_valid_so_line_ids():
raise ValidationError(_(
'This timesheet line cannot be billed: there is no Sale'
' Order Item defined on the task, nor on the project.'
' Please define one to save your timesheet line.'
))

@api.multi
def _get_valid_so_line_ids(self):
self.ensure_one()

return (
self.project_id.mapped(
'sale_line_employee_ids.sale_line_id'
)
| self.task_id.sale_line_id
| self.project_id.sale_line_id
)

@api.multi
def write(self, values):
# prevent to update invoiced timesheets if one line is of type delivery
if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda timesheet: timesheet.timesheet_invoice_id):
if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date']]):
raise UserError(_('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).'))
if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery" and aal.timesheet_invoice_id):
if not self._timesheet_check_invoiced_write(values):
raise UserError(_(
'You can not modify timesheets in a way that would affect'
' invoices since these timesheets were already invoiced.'
))
result = super(AccountAnalyticLine, self).write(values)
return result

@api.model
def _timesheet_check_invoiced_write(self, values):
return all([field_name not in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date', 'exclude_from_sale_order']])

@api.model
def _timesheet_preprocess(self, values):
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
# task implies so line (at create)
if 'task_id' in values and not values.get('so_line') and values.get('employee_id'):
task = self.env['project.task'].sudo().browse(values['task_id'])
employee = self.env['hr.employee'].sudo().browse(values['employee_id'])
values['so_line'] = self._timesheet_determine_sale_line(task, employee).id
if not values.get('so_line') and self._timesheet_should_evaluate_so_line(values, all):
values['so_line'] = self._timesheet_get_sale_line_from_values(values).id
return values

@api.multi
def _timesheet_postprocess_values(self, values):
result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values)
# (re)compute the sale line
if any([field_name in values for field_name in ['task_id', 'employee_id']]):
if self._timesheet_should_evaluate_so_line(values, any):
for timesheet in self:
so_line = timesheet._timesheet_get_sale_line()
result[timesheet.id].update({
'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id).id,
'so_line': so_line.id if so_line else False,
})
return result

@api.multi
def _timesheet_get_sale_line(self):
self.ensure_one()

if self.exclude_from_sale_order:
return False

return self._timesheet_determine_sale_line(
self.task_id,
self.employee_id
) or False

@api.model
def _timesheet_get_sale_line_from_values(self, values):
if values.get('exclude_from_sale_order'):
return self.env['sale.order.line']

task = self.env['project.task'].sudo().browse(values['task_id']) if 'task_id' in values else self.task_id
employee = self.env['hr.employee'].sudo().browse(values['employee_id']) if 'employee_id' in values else self.employee_id

return self._timesheet_determine_sale_line(task, employee)

@api.model
def _timesheet_should_evaluate_so_line(self, values, check):
return check([field_name in values for field_name in ['task_id', 'employee_id', 'exclude_from_sale_order']])

@api.model
def _timesheet_determine_sale_line(self, task, employee):
""" Deduce the SO line associated to the timesheet line:
@@ -358,10 +358,10 @@ def test_profitability_report(self):
self.assertEqual(float_compare(project_so_1_stat['amount_untaxed_invoiced'], self.so_line_deliver_project.price_unit * project_so_1_timesheet_sold_unit, precision_rounding=rounding), 0, "The invoiced amount of the project from SO1 should only include timesheet linked to task")
self.assertTrue(float_is_zero(project_so_1_stat['amount_untaxed_to_invoice'], precision_rounding=rounding), "The amount to invoice of the project from SO1 should be 0.0")
self.assertEqual(float_compare(project_so_1_stat['timesheet_unit_amount'], project_so_1_timesheet_sold_unit + timesheet1.unit_amount, precision_rounding=rounding), 0, "The timesheet unit amount of the project from SO1 should include all timesheet in project")
self.assertEqual(float_compare(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, precision_rounding=rounding), 0, "The timesheet cost of the project from SO1 should include all timesheet")
self.assertEqual(float_compare(project_so_1_stat['expense_amount_untaxed_to_invoice'], -1 * expense1.amount, precision_rounding=rounding), 0, "The expense cost to reinvoice of the project from SO1 should be 0.0")
self.assertTrue(float_is_zero(project_so_1_stat['expense_amount_untaxed_invoiced'], precision_rounding=rounding), "The expense invoiced amount of the project from SO1 should be 0.0")
self.assertEqual(float_compare(project_so_1_stat['expense_cost'], expense1.amount, precision_rounding=rounding), 0, "The expense cost of the project from SO1 should be 0.0")
self.assertAlmostEqual(project_so_1_stat['timesheet_cost'], project_so_1_timesheet_cost, delta=rounding, msg="The timesheet cost of the project from SO1 should include all timesheet")
self.assertAlmostEqual(project_so_1_stat['expense_amount_untaxed_to_invoice'], -1 * expense1.amount, delta=rounding, msg="The expense cost to reinvoice of the project from SO1 should not be 0.0")
self.assertAlmostEqual(project_so_1_stat['expense_amount_untaxed_invoiced'], 0, delta=rounding, msg="The expense invoiced amount of the project from SO1 should be 0.0")
self.assertAlmostEqual(project_so_1_stat['expense_cost'], expense1.amount, delta=rounding, msg="The expense cost of the project from SO1 should not be 0.0")

# order project is not impacted by the expenses
project_so_2_stat = self.env['project.profitability.report'].read_group([('project_id', 'in', project_so_2.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_unit_amount', 'timesheet_cost', 'expense_cost', 'expense_amount_untaxed_to_invoice', 'expense_amount_untaxed_invoiced'], ['project_id'])[0]
@@ -1,6 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>

<record id="timesheet_view_tree_user" model="ir.ui.view">
<field name="name">account.analytic.line.tree.user</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/>
<field name="arch" type="xml">
<field name="task_id" position="after">
<field name="exclude_from_sale_order"/>
</field>
</field>
</record>

<record id="timesheet_view_search" model="ir.ui.view">
<field name="name">account.analytic.line.search</field>
<field name="model">account.analytic.line</field>
@@ -9,9 +20,11 @@
<xpath expr="//filter[@name='month']" position="before">
<field name="timesheet_invoice_type"/>
<field name="timesheet_invoice_id"/>
<field name="exclude_from_sale_order"/>
<filter name="billable_time" string="Billable Hours" domain="[('timesheet_invoice_type', '=', 'billable_time')]"/>
<filter name="billable_fixed" string="Fixed Price Projects" domain="[('timesheet_invoice_type', '=', 'billable_fixed')]"/>
<filter name="non_billable" string="Non Billable Hours" domain="[('timesheet_invoice_type', '=', 'non_billable')]"/>
<filter name="excluded_from_billing" string="Excluded From Billing" domain="[('exclude_from_sale_order', '=', True)]"/>
<separator/>
</xpath>
</field>
@@ -100,6 +100,7 @@
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
<field name="timesheet_invoice_id" invisible="1"/>
<field name="exclude_from_sale_order"/>
<field name="so_line" readonly="1" attrs="{'column_invisible': ['|', ('parent.is_project_map_empty', '=', True), ('parent.billable_type', '!=', 'employee_rate')]}"/>
</xpath>
</field>

0 comments on commit ede6801

Please sign in to comment.
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.