[MERGE] sale_timesheet: billing time at employee rate
When a project become billable, the employee can now timesheet on a same task, at different rate. The project should be linked to a SO, and the mapping between the employee and the SO line will be done using 2 mecanisms: 1/ task rate: the SO line set on the task is chosen for any employee timesheeting on the task. 2/ employee rate: a map exists on the project, matching employee with an SO line from the SO of the project. If the employee is not in the map, the fallback will be the SO from the task, or the one from the project. This makes the SO line on the task always visible. See subcommit for more details. Task #34703 Closes #26111
This commit is contained in:
@@ -135,22 +135,16 @@ class Task(models.Model):
|
||||
for task in self:
|
||||
task.subtask_effective_hours = sum(child_task.effective_hours + child_task.subtask_effective_hours for child_task in task.child_ids)
|
||||
|
||||
# ---------------------------------------------------------
|
||||
# ORM
|
||||
# ---------------------------------------------------------
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
result = super(Task, self).write(values)
|
||||
# reassign project_id on related timesheet lines
|
||||
if 'project_id' in values:
|
||||
project_id = values.get('project_id')
|
||||
# a timesheet must have an analytic account (and a project)
|
||||
if self and not project_id:
|
||||
# a timesheet must have an analytic account (and a project)
|
||||
if 'project_id' in values and self and not values.get('project_id'):
|
||||
raise UserError(_('This task must be part of a project because they some timesheets are linked to it.'))
|
||||
account_id = self.env['project.project'].browse(project_id).sudo().analytic_account_id
|
||||
self.sudo().mapped('timesheet_ids').write({
|
||||
'project_id': project_id,
|
||||
'account_id': account_id.id,
|
||||
'company_id': account_id.company_id.id,
|
||||
})
|
||||
return result
|
||||
return super(Task, self).write(values)
|
||||
|
||||
@api.model
|
||||
def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False):
|
||||
|
||||
@@ -146,7 +146,7 @@ class TestTimesheet(TestCommonTimesheet):
|
||||
self.assertEquals(timesheet1.user_id, self.user_employee2, 'Changing timesheet employee should change the related user')
|
||||
|
||||
def test_transfert_project(self):
|
||||
""" Test transfert task with timesheet to another project """
|
||||
""" Transfert task with timesheet to another project should not modified past timesheets (they are still linked to old project. """
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
# create a second project
|
||||
self.project_customer2 = self.env['project.project'].create({
|
||||
@@ -174,9 +174,9 @@ class TestTimesheet(TestCommonTimesheet):
|
||||
|
||||
timesheet_count1 = Timesheet.search_count([('project_id', '=', self.project_customer.id)])
|
||||
timesheet_count2 = Timesheet.search_count([('project_id', '=', self.project_customer2.id)])
|
||||
self.assertEquals(timesheet_count1, 0, "No timesheet in project 1")
|
||||
self.assertEquals(timesheet_count2, 1, "One timesheet in project 2")
|
||||
self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet should be linked to task 1")
|
||||
self.assertEquals(timesheet_count1, 1, "Still one timesheet in project 1")
|
||||
self.assertEquals(timesheet_count2, 0, "No timesheet in project 2")
|
||||
self.assertEquals(len(self.task1.timesheet_ids), 1, "The timesheet still should be linked to task 1")
|
||||
|
||||
# it is forbidden to set a task with timesheet without project
|
||||
with self.assertRaises(UserError):
|
||||
|
||||
@@ -410,6 +410,7 @@ class Task(models.Model):
|
||||
result.update(self._subtask_values_from_parent(result['parent_id']))
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _get_default_partner(self):
|
||||
if 'default_project_id' in self.env.context:
|
||||
default_project_id = self.env['project.project'].browse(self.env.context['default_project_id'])
|
||||
@@ -478,7 +479,7 @@ class Task(models.Model):
|
||||
index=True, track_visibility='always')
|
||||
partner_id = fields.Many2one('res.partner',
|
||||
string='Customer',
|
||||
default=_get_default_partner)
|
||||
default=lambda self: self._get_default_partner())
|
||||
manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True, related_sudo=False)
|
||||
company_id = fields.Many2one('res.company',
|
||||
string='Company',
|
||||
|
||||
@@ -8,5 +8,6 @@ class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
module_project_forecast = fields.Boolean(string="Forecasts")
|
||||
module_hr_timesheet = fields.Boolean(string="Task Logs")
|
||||
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
|
||||
group_project_rating = fields.Boolean("Use Rating on Project", implied_group='project.group_project_rating')
|
||||
|
||||
@@ -48,6 +48,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_hr_timesheet"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="module_hr_timesheet"/>
|
||||
<div class="text-muted">
|
||||
Log time on tasks
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2>Time Management</h2>
|
||||
<div class="row mt16 o_settings_container" name="project_time">
|
||||
|
||||
@@ -362,6 +362,13 @@ class SaleTimesheetController(http.Controller):
|
||||
|
||||
def _plan_get_stat_button(self, projects):
|
||||
stat_buttons = []
|
||||
if len(projects) == 1:
|
||||
stat_buttons.append({
|
||||
'name': _('Project'),
|
||||
'res_model': 'project.project',
|
||||
'res_id': projects.id,
|
||||
'icon': 'fa fa-puzzle-piece',
|
||||
})
|
||||
stat_buttons.append({
|
||||
'name': _('Timesheets'),
|
||||
'res_model': 'account.analytic.line',
|
||||
@@ -397,7 +404,7 @@ class SaleTimesheetController(http.Controller):
|
||||
return stat_buttons
|
||||
|
||||
@http.route('/timesheet/plan/action', type='json', auth="user")
|
||||
def plan_stat_button(self, domain, res_model='account.analytic.line'):
|
||||
def plan_stat_button(self, domain=[], res_model='account.analytic.line', res_id=False):
|
||||
action = {
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_id': False,
|
||||
@@ -405,7 +412,18 @@ class SaleTimesheetController(http.Controller):
|
||||
'view_type': 'list',
|
||||
'domain': domain,
|
||||
}
|
||||
if res_model == 'account.analytic.line':
|
||||
if res_model == 'project.project':
|
||||
view_form_id = request.env.ref('project.edit_project').id
|
||||
action = {
|
||||
'name': _('Project'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': res_model,
|
||||
'view_mode': 'form',
|
||||
'view_type': 'form',
|
||||
'views': [[view_form_id, 'form']],
|
||||
'res_id': res_id,
|
||||
}
|
||||
elif res_model == 'account.analytic.line':
|
||||
ts_view_tree_id = request.env.ref('hr_timesheet.hr_timesheet_line_tree').id
|
||||
ts_view_form_id = request.env.ref('hr_timesheet.hr_timesheet_line_form').id
|
||||
action = {
|
||||
|
||||
@@ -6,3 +6,4 @@ from . import account_invoice
|
||||
from . import product
|
||||
from . import project
|
||||
from . import sale_order
|
||||
from . import project_sale_line_employee_map
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.osv import expression
|
||||
|
||||
@@ -36,11 +37,28 @@ class AccountAnalyticLine(models.Model):
|
||||
invoice_type = 'billable_fixed'
|
||||
timesheet.timesheet_invoice_type = invoice_type
|
||||
|
||||
@api.onchange('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
|
||||
|
||||
@api.constrains('so_line', 'project_id')
|
||||
def _check_sale_line_in_project_map(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."))
|
||||
|
||||
@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', 'task_id', 'so_line', 'amount', 'date']]):
|
||||
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).'))
|
||||
result = super(AccountAnalyticLine, self).write(values)
|
||||
return result
|
||||
@@ -48,8 +66,40 @@ class AccountAnalyticLine(models.Model):
|
||||
@api.model
|
||||
def _timesheet_preprocess(self, values):
|
||||
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
|
||||
# task implies so line
|
||||
if 'task_id' in 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'])
|
||||
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
|
||||
employee = self.env['hr.employee'].sudo().browse(values['employee_id'])
|
||||
values['so_line'] = self._timesheet_determine_sale_line(task, employee).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']]):
|
||||
for timesheet in self:
|
||||
result[timesheet.id].update({
|
||||
'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id).id,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _timesheet_determine_sale_line(self, task, employee):
|
||||
""" 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
|
||||
NOTE: this have to be consistent with `_compute_billable_type` on project.task.
|
||||
"""
|
||||
if task.billable_type != 'no':
|
||||
if task.billable_type == 'employee_rate':
|
||||
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)])
|
||||
if map_entry:
|
||||
return map_entry.sale_line_id
|
||||
if task.sale_line_id:
|
||||
return task.sale_line_id
|
||||
return task.project_id.sale_line_id
|
||||
elif task.billable_type == 'task_rate':
|
||||
return task.sale_line_id
|
||||
return self.env['sale.order.line']
|
||||
|
||||
@@ -24,10 +24,10 @@ class ProductTemplate(models.Model):
|
||||
], string="Service Tracking", default="no",
|
||||
help="On Sales order confirmation, this product can generate a project and/or task. From those, you can track the service you are selling.")
|
||||
project_id = fields.Many2one(
|
||||
'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)],
|
||||
'project.project', 'Project', company_dependent=True, domain=[('billable_type', '=', 'no')],
|
||||
help='Select a non billable project on which tasks can be created. This setting must be set for each company.')
|
||||
project_template_id = fields.Many2one(
|
||||
'project.project', 'Project Template', company_dependent=True, domain=[('sale_line_id', '=', False)], copy=True,
|
||||
'project.project', 'Project Template', company_dependent=True, domain=[('billable_type', '=', 'no')], copy=True,
|
||||
help='Select a non billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.')
|
||||
|
||||
@api.depends('invoice_policy', 'service_type')
|
||||
|
||||
@@ -8,7 +8,51 @@ from odoo.exceptions import ValidationError
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', domain=[('is_expense', '=', False)], readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done'])]", copy=False, help="Sale order line from which the project has been created. Used for tracability.")
|
||||
sale_order_id = fields.Many2one('sale.order', 'Sales Order', domain="[('partner_id', '=', partner_id)]", readonly=True, copy=False)
|
||||
billable_type = fields.Selection([
|
||||
('task_rate', 'At Task Rate'),
|
||||
('employee_rate', 'At Employee Rate'),
|
||||
('no', 'No Billable')
|
||||
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True,
|
||||
help='Billable type implies:\n'
|
||||
' - At task rate: each time spend on a task is billed at task rate.\n'
|
||||
' - At employee rate: each employee log time billed at his rate.\n'
|
||||
' - No Billable: track time without invoicing it')
|
||||
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False)
|
||||
|
||||
_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 an Sale Order Items.'),
|
||||
]
|
||||
|
||||
@api.depends('sale_order_id', 'sale_line_id', 'sale_line_employee_ids')
|
||||
def _compute_billable_type(self):
|
||||
for project in self:
|
||||
billable_type = 'no'
|
||||
if project.sale_order_id:
|
||||
if project.sale_line_employee_ids:
|
||||
billable_type = 'employee_rate'
|
||||
else:
|
||||
billable_type = 'task_rate'
|
||||
project.billable_type = billable_type
|
||||
|
||||
@api.onchange('sale_line_employee_ids', 'billable_type')
|
||||
def _onchange_sale_line_employee_ids(self):
|
||||
if self.billable_type == 'task_rate':
|
||||
if self.sale_line_employee_ids:
|
||||
self.billable_type = 'employee_rate'
|
||||
else:
|
||||
if self.billable_type == 'no':
|
||||
self.sale_line_employee_ids = False
|
||||
|
||||
@api.constrains('sale_line_id', 'billable_type')
|
||||
def _check_sale_line_type(self):
|
||||
for project in self:
|
||||
if project.billable_type == 'task_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."))
|
||||
|
||||
@api.multi
|
||||
def action_view_timesheet(self):
|
||||
@@ -52,36 +96,100 @@ class Project(models.Model):
|
||||
}
|
||||
return action
|
||||
|
||||
@api.multi
|
||||
def action_make_billable(self):
|
||||
return {
|
||||
"type": 'ir.actions.act_window',
|
||||
"res_model": 'project.create.sale.order',
|
||||
"views": [[False, "form"]],
|
||||
"target": 'new',
|
||||
"context": {
|
||||
'active_id': self.id,
|
||||
'active_model': 'project.project',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
@api.model
|
||||
def _get_default_partner(self):
|
||||
partner = super(ProjectTask, self)._get_default_partner()
|
||||
partner = False
|
||||
if 'default_project_id' in self.env.context: # partner from SO line is prior on one from project
|
||||
project = self.env['project.project'].browse(self.env.context['default_project_id'])
|
||||
partner = project.sale_line_id.order_partner_id
|
||||
if not partner:
|
||||
partner = super(ProjectTask, self)._get_default_partner()
|
||||
return partner
|
||||
|
||||
@api.model
|
||||
def _default_sale_line_id(self):
|
||||
sale_line_id = False
|
||||
if self._context.get('default_parent_id'):
|
||||
sale_line_id = self.env['project.task'].browse(self._context['default_parent_id']).sale_line_id.id
|
||||
parent_task = self.env['project.task'].browse(self._context['default_parent_id'])
|
||||
sale_line_id = parent_task.sale_line_id.id
|
||||
if not sale_line_id and self._context.get('default_project_id'):
|
||||
sale_line_id = self.env['project.project'].browse(self._context['default_project_id']).sale_line_id.id
|
||||
project = self.env['project.project'].browse(self.env.context['default_project_id'])
|
||||
if project.billable_type != 'no':
|
||||
sale_line_id = project.sale_line_id.id
|
||||
return sale_line_id
|
||||
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]")
|
||||
sale_order_id = fields.Many2one('sale.order', 'Sales Order', related='sale_line_id.order_id', store=True, readonly=True)
|
||||
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]")
|
||||
sale_order_id = fields.Many2one('sale.order', 'Sales Order', compute='_compute_sale_order_id', compute_sudo=True, store=True, readonly=True)
|
||||
billable_type = fields.Selection([
|
||||
('task_rate', 'At Task Rate'),
|
||||
('employee_rate', 'At Employee Rate'),
|
||||
('no', 'No Billable')
|
||||
], string="Billable Type", compute='_compute_billable_type', compute_sudo=True, store=True)
|
||||
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
|
||||
|
||||
@api.multi
|
||||
@api.depends('sale_line_id', 'project_id', 'billable_type')
|
||||
def _compute_sale_order_id(self):
|
||||
for task in self:
|
||||
if task.billable_type == 'task_rate':
|
||||
task.sale_order_id = task.sale_line_id.order_id or task.project_id.sale_order_id
|
||||
elif task.billable_type == 'employee_rate':
|
||||
task.sale_order_id = task.project_id.sale_order_id
|
||||
elif task.billable_type == 'no':
|
||||
task.sale_order_id = False
|
||||
|
||||
@api.multi
|
||||
@api.depends('project_id.billable_type', 'sale_line_id')
|
||||
def _compute_billable_type(self):
|
||||
for task in self:
|
||||
billable_type = 'no'
|
||||
if task.project_id.billable_type == 'employee_rate':
|
||||
billable_type = task.project_id.billable_type
|
||||
elif (task.project_id.billable_type in ['task_rate', 'no'] and task.sale_line_id): # create a task in global project (non billable)
|
||||
billable_type = 'task_rate'
|
||||
task.billable_type = billable_type
|
||||
|
||||
@api.depends('project_id.sale_line_employee_ids')
|
||||
def _compute_is_project_map_empty(self):
|
||||
for task in self:
|
||||
task.is_project_map_empty = not bool(task.project_id.sale_line_employee_ids)
|
||||
|
||||
@api.onchange('project_id')
|
||||
def _onchange_project(self):
|
||||
result = super(ProjectTask, self)._onchange_project()
|
||||
if self.project_id:
|
||||
self.sale_line_id = self.project_id.sale_line_id
|
||||
if not self.partner_id:
|
||||
self.partner_id = self.sale_line_id.order_partner_id
|
||||
self.sale_line_id = self.project_id.sale_line_id
|
||||
if not self.parent_id and not self.partner_id:
|
||||
self.partner_id = self.sale_line_id.order_partner_id
|
||||
# set domain on SO: on non billable project, all SOL of customer, otherwise the one from the SO
|
||||
result = result or {}
|
||||
domain = [('is_service', '=', True), ('is_expense', '=', False), ('order_partner_id', '=', self.partner_id.id), ('state', 'in', ['sale', 'done'])]
|
||||
if self.project_id.sale_order_id:
|
||||
domain += [('sale_order_id', '=', self.project_id.sale_order_id.id)]
|
||||
result.setdefault('domain', {})['sale_line_id'] = domain
|
||||
return result
|
||||
|
||||
@api.onchange('partner_id')
|
||||
def _onchange_partner_id(self):
|
||||
result = super(ProjectTask, self)._onchange_partner_id()
|
||||
if self.sale_line_id.order_partner_id != self.partner_id:
|
||||
self.sale_line_id = False
|
||||
return result
|
||||
|
||||
@api.multi
|
||||
@@ -94,13 +202,11 @@ class ProjectTask(models.Model):
|
||||
|
||||
@api.multi
|
||||
def write(self, values):
|
||||
result = super(ProjectTask, self).write(values)
|
||||
# reassign SO line on related timesheet lines
|
||||
if 'sale_line_id' in values:
|
||||
self.sudo().mapped('timesheet_ids').write({
|
||||
'so_line': values['sale_line_id']
|
||||
})
|
||||
return result
|
||||
if values.get('project_id'):
|
||||
project_dest = self.env['project.project'].browse(values['project_id'])
|
||||
if project_dest.billable_type == 'employee_rate':
|
||||
values['sale_line_id'] = False
|
||||
return super(ProjectTask, self).write(values)
|
||||
|
||||
@api.multi
|
||||
def unlink(self):
|
||||
@@ -108,6 +214,28 @@ class ProjectTask(models.Model):
|
||||
raise ValidationError(_('You have to unlink the task from the sale order item in order to delete it.'))
|
||||
return super(ProjectTask, self).unlink()
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Subtasks
|
||||
# ---------------------------------------------------
|
||||
|
||||
@api.model
|
||||
def _subtask_implied_fields(self):
|
||||
result = super(ProjectTask, self)._subtask_implied_fields()
|
||||
return result + ['sale_line_id']
|
||||
|
||||
def _subtask_write_values(self, values):
|
||||
result = super(ProjectTask, self)._subtask_write_values(values)
|
||||
# changing the partner on a task will reset the sale line of its subtasks
|
||||
if 'partner_id' in result:
|
||||
result['sale_line_id'] = False
|
||||
elif 'sale_line_id' in result:
|
||||
result.pop('sale_line_id')
|
||||
return result
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Actions
|
||||
# ---------------------------------------------------
|
||||
|
||||
@api.multi
|
||||
def action_view_so(self):
|
||||
self.ensure_one()
|
||||
@@ -115,7 +243,7 @@ class ProjectTask(models.Model):
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "sale.order",
|
||||
"views": [[False, "form"]],
|
||||
"res_id": self.sale_line_id.order_id.id,
|
||||
"res_id": self.sale_order_id.id,
|
||||
"context": {"create": False, "show_sale": True},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ProjectProductEmployeeMap(models.Model):
|
||||
_name = 'project.sale.line.employee.map'
|
||||
|
||||
@api.model
|
||||
def _default_project_id(self):
|
||||
if self._context.get('active_id'):
|
||||
return self._context['active_id']
|
||||
return False
|
||||
|
||||
project_id = fields.Many2one('project.project', "Project", domain=[('billable_type', '!=', 'no')], required=True, default=_default_project_id)
|
||||
employee_id = fields.Many2one('hr.employee', "Employee", required=True)
|
||||
sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", domain=[('is_service', '=', True)], required=True)
|
||||
price_unit = fields.Float(related='sale_line_id.price_unit', readonly=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('uniq_map_sale_line_employee_per_project', 'UNIQUE(project_id,employee_id)', 'You can only map one employee with sale order item per project.'),
|
||||
]
|
||||
@@ -222,6 +222,7 @@ 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,
|
||||
}
|
||||
if self.product_id.project_template_id:
|
||||
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_project_profitability_report_analysis_manager,project.profitability.report.analysis,model_project_profitability_report,project.group_project_manager,1,1,1,1
|
||||
access_project_sale_line_employee_map,access_project_sale_line_employee_map,model_project_sale_line_employee_map,base.group_user,1,0,0,0
|
||||
|
||||
|
@@ -232,7 +232,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
|
||||
views: [[false, 'list'], [false, 'form']],
|
||||
view_type: 'list',
|
||||
view_mode: 'form',
|
||||
domain: domain,
|
||||
domain: domain || [],
|
||||
});
|
||||
},
|
||||
/**
|
||||
@@ -242,12 +242,16 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
|
||||
_onClickStatButton: function (event) {
|
||||
var self = this;
|
||||
var data = $(event.currentTarget).data();
|
||||
var parameters = {
|
||||
domain: data.domain || [],
|
||||
res_model: data.resModel,
|
||||
}
|
||||
if (data.resId) {
|
||||
parameters['res_id'] = data.resId;
|
||||
}
|
||||
return this._rpc({
|
||||
route:"/timesheet/plan/action",
|
||||
params: {
|
||||
domain: data.domain,
|
||||
res_model: data.resModel,
|
||||
},
|
||||
params: parameters,
|
||||
}).then(function(action){
|
||||
self.do_action(action);
|
||||
});
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
from . import common
|
||||
from . import test_sale_timesheet
|
||||
from . import test_sale_service
|
||||
from . import test_project_billing
|
||||
from . import test_reinvoice
|
||||
from . import test_reporting
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.sale_timesheet.tests.common import TestCommonSaleTimesheetNoChart
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestProjectBilling(TestCommonSaleTimesheetNoChart):
|
||||
""" This test suite provide checks for miscellaneous small things. """
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestProjectBilling, cls).setUpClass()
|
||||
# set up
|
||||
cls.setUpServiceProducts()
|
||||
cls.setUpEmployees()
|
||||
cls.employee_tde = cls.env['hr.employee'].create({
|
||||
'name': 'Employee TDE',
|
||||
'timesheet_cost': 42,
|
||||
})
|
||||
|
||||
cls.partner_2 = cls.env['res.partner'].create({
|
||||
'name': 'Customer from the South',
|
||||
'email': 'customer.usd@south.com',
|
||||
'customer': True,
|
||||
'property_account_payable_id': cls.account_payable.id,
|
||||
'property_account_receivable_id': cls.account_receivable.id,
|
||||
})
|
||||
|
||||
# Sale Order 1, no project/task created, used to timesheet at employee rate
|
||||
SaleOrder = cls.env['sale.order'].with_context(tracking_disable=True)
|
||||
SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True)
|
||||
cls.sale_order_1 = SaleOrder.create({
|
||||
'partner_id': cls.partner_customer_usd.id,
|
||||
'partner_invoice_id': cls.partner_customer_usd.id,
|
||||
'partner_shipping_id': cls.partner_customer_usd.id,
|
||||
})
|
||||
|
||||
cls.so1_line_order_no_task = SaleOrderLine.create({
|
||||
'name': cls.product_order_timesheet1.name,
|
||||
'product_id': cls.product_order_timesheet1.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': cls.product_order_timesheet1.uom_id.id,
|
||||
'price_unit': cls.product_order_timesheet1.list_price,
|
||||
'order_id': cls.sale_order_1.id,
|
||||
})
|
||||
|
||||
cls.so1_line_deliver_no_task = SaleOrderLine.create({
|
||||
'name': cls.product_delivery_timesheet1.name,
|
||||
'product_id': cls.product_delivery_timesheet1.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': cls.product_delivery_timesheet1.uom_id.id,
|
||||
'price_unit': cls.product_delivery_timesheet1.list_price,
|
||||
'order_id': cls.sale_order_1.id,
|
||||
})
|
||||
# Sale Order 2, creates 2 project billed at task rate
|
||||
cls.sale_order_2 = SaleOrder.create({
|
||||
'partner_id': cls.partner_2.id,
|
||||
'partner_invoice_id': cls.partner_2.id,
|
||||
'partner_shipping_id': cls.partner_2.id,
|
||||
})
|
||||
cls.so2_line_deliver_project_task = SaleOrderLine.create({
|
||||
'order_id': cls.sale_order_2.id,
|
||||
'name': cls.product_delivery_timesheet3.name,
|
||||
'product_id': cls.product_delivery_timesheet3.id,
|
||||
'product_uom_qty': 5,
|
||||
'product_uom': cls.product_delivery_timesheet3.uom_id.id,
|
||||
'price_unit': cls.product_delivery_timesheet3.list_price
|
||||
})
|
||||
cls.so2_line_deliver_project_template = SaleOrderLine.create({
|
||||
'order_id': cls.sale_order_2.id,
|
||||
'name': cls.product_delivery_timesheet5.name,
|
||||
'product_id': cls.product_delivery_timesheet5.id,
|
||||
'product_uom_qty': 7,
|
||||
'product_uom': cls.product_delivery_timesheet5.uom_id.id,
|
||||
'price_unit': cls.product_delivery_timesheet5.list_price
|
||||
})
|
||||
cls.sale_order_2.action_confirm()
|
||||
|
||||
# Projects: at least one per billable type
|
||||
Project = cls.env['project.project'].with_context(tracking_disable=True)
|
||||
cls.project_subtask = Project.create({
|
||||
'name': "Sub Task Project (non billable)",
|
||||
'allow_timesheets': True,
|
||||
'billable_type': 'no',
|
||||
'partner_id': False,
|
||||
})
|
||||
cls.project_non_billable = Project.create({
|
||||
'name': "Non Billable Project",
|
||||
'allow_timesheets': True,
|
||||
'billable_type': 'no',
|
||||
'partner_id': False,
|
||||
'subtask_project_id': cls.project_subtask.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)
|
||||
|
||||
cls.project_employee_rate = Project.create({
|
||||
'name': "Project billed at Employee Rate",
|
||||
'allow_timesheets': True,
|
||||
'billable_type': 'employee_rate',
|
||||
'sale_order_id': cls.sale_order_1.id,
|
||||
'partner_id': cls.sale_order_1.partner_id.id,
|
||||
'subtask_project_id': cls.project_subtask.id,
|
||||
})
|
||||
cls.project_employee_rate_manager = cls.env['project.sale.line.employee.map'].create({
|
||||
'project_id': cls.project_employee_rate.id,
|
||||
'sale_line_id': cls.so1_line_order_no_task.id,
|
||||
'employee_id': cls.employee_manager.id,
|
||||
})
|
||||
cls.project_employee_rate_user = cls.env['project.sale.line.employee.map'].create({
|
||||
'project_id': cls.project_employee_rate.id,
|
||||
'sale_line_id': cls.so1_line_deliver_no_task.id,
|
||||
'employee_id': cls.employee_user.id,
|
||||
})
|
||||
|
||||
def test_make_billable_at_task_rate(self):
|
||||
""" Starting from a non billable project, make it billable at task rate, using the wizard """
|
||||
# set a customer on the project
|
||||
self.project_non_billable.write({
|
||||
'partner_id': self.partner_2.id
|
||||
})
|
||||
|
||||
# create wizard
|
||||
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({
|
||||
'line_ids': [
|
||||
(0, 0, {'product_id': self.product_delivery_timesheet3.id, 'quantity': 10, 'price_unit': self.product_delivery_timesheet3.list_price}) # product creates new T in new P
|
||||
]
|
||||
})
|
||||
|
||||
self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable")
|
||||
self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project")
|
||||
|
||||
# create the SO from the project
|
||||
action = wizard.action_create_sale_order()
|
||||
sale_order = self.env['sale.order'].browse(action['res_id'])
|
||||
|
||||
self.assertEqual(self.project_non_billable.billable_type, 'task_rate', "The project should be 'task rate' billable")
|
||||
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), 1, "The SO should have 1 line")
|
||||
self.assertEqual(sale_order.order_line.product_id, wizard.line_ids.product_id, "The product of the only SOL should be the selected on the wizard")
|
||||
self.assertEqual(sale_order.order_line.project_id, self.project_non_billable, "SOL should be linked to the project")
|
||||
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")
|
||||
|
||||
def test_make_billable_at_employee_rate(self):
|
||||
""" Starting from a non billable project, make it billable at employee rate, using the wizard """
|
||||
# set a customer on the project
|
||||
self.project_non_billable.write({
|
||||
'partner_id': self.partner_2.id
|
||||
})
|
||||
|
||||
# create wizard
|
||||
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({
|
||||
'line_ids': [
|
||||
(0, 0, {'product_id': self.product_delivery_timesheet1.id, 'quantity': 4, 'price_unit': self.product_delivery_timesheet1.list_price}), # product creates no T
|
||||
(0, 0, {'product_id': self.product_delivery_timesheet2.id, 'quantity': 5, 'price_unit': self.product_delivery_timesheet2.list_price}), # product creates T in global project
|
||||
(0, 0, {'product_id': self.product_delivery_timesheet3.id, 'quantity': 10, 'price_unit': self.product_delivery_timesheet3.list_price}), # product creates new T in new P
|
||||
]
|
||||
})
|
||||
|
||||
self.assertEqual(self.project_non_billable.billable_type, 'no', "The project should still be non billable")
|
||||
self.assertEqual(wizard.partner_id, self.project_non_billable.partner_id, "The wizard should have the same partner as the project")
|
||||
self.assertEqual(wizard.project_id, self.project_non_billable, "The wizard'project should be the non billable project")
|
||||
|
||||
# create the SO from the project
|
||||
action = wizard.action_create_sale_order()
|
||||
sale_order = self.env['sale.order'].browse(action['res_id'])
|
||||
|
||||
self.assertEqual(self.project_non_billable.billable_type, 'task_rate', "The project should be 'employee rate' billable")
|
||||
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), 3, "The SO should have 3 lines")
|
||||
|
||||
line1 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet1)
|
||||
line2 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet2)
|
||||
line3 = sale_order.order_line.filtered(lambda sol: sol.product_id == self.product_delivery_timesheet3)
|
||||
|
||||
self.assertTrue(line1, "Sale line with product 1 should exists")
|
||||
self.assertTrue(line2, "Sale line with product 2 should exists")
|
||||
self.assertTrue(line3, "Sale line with product 3 should exists")
|
||||
|
||||
self.assertFalse(line1.project_id, "Sale line 1 should be linked to the 'non billable' project")
|
||||
self.assertFalse(line2.project_id, "Sale line 2 should not be linked to a project, as it creates a task in a global one")
|
||||
self.assertEqual(line3.project_id, self.project_non_billable, "Sale line 3 should be linked to the 'non billable' project")
|
||||
|
||||
# filling the map will set billable type to emlpoyee rate
|
||||
self.env['project.sale.line.employee.map'].create({
|
||||
'project_id': self.project_non_billable.id,
|
||||
'sale_line_id': line1.id,
|
||||
'employee_id': self.employee_manager.id,
|
||||
})
|
||||
self.assertEqual(self.project_non_billable.billable_type, 'employee_rate', "The project should be 'employee rate' billable")
|
||||
|
||||
def test_billing_employee_rate(self):
|
||||
""" Check task and subtask creation, and timesheeting in a project billed at 'employee rate'. Then move the task into a 'task rate' project. """
|
||||
Task = self.env['project.task'].with_context(tracking_disable=True)
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
|
||||
# create a task
|
||||
task = Task.with_context(default_project_id=self.project_employee_rate.id).create({
|
||||
'name': 'first task',
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
})
|
||||
|
||||
self.assertEqual(task.billable_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")
|
||||
|
||||
# log timesheet on task
|
||||
timesheet1 = Timesheet.create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task.project_id.id,
|
||||
'task_id': task.id,
|
||||
'unit_amount': 50,
|
||||
'employee_id': self.employee_manager.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.project_employee_rate_manager.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the Employee manager in the map")
|
||||
self.assertEqual(self.project_employee_rate_manager.project_id, timesheet1.project_id, "The timesheet should be linked to the project of the map entry")
|
||||
|
||||
# create a subtask
|
||||
subtask = Task.with_context(default_project_id=self.project_employee_rate.subtask_project_id.id).create({
|
||||
'name': 'first subtask task',
|
||||
'parent_id': task.id,
|
||||
})
|
||||
|
||||
self.assertEqual(subtask.billable_type, 'no', "Subtask in non billable project should be non billable too")
|
||||
self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is")
|
||||
self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother")
|
||||
|
||||
# log timesheet on subtask
|
||||
timesheet2 = Timesheet.create({
|
||||
'name': 'Test Line on subtask',
|
||||
'project_id': subtask.project_id.id,
|
||||
'task_id': subtask.id,
|
||||
'unit_amount': 50,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
|
||||
self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
|
||||
self.assertNotEqual(self.project_employee_rate_user.project_id, timesheet2.project_id, "The timesheet should not be linked to the billing project for the map")
|
||||
self.assertFalse(timesheet2.so_line, "The timesheet should not be linked to SOL as the task is in a non billable project")
|
||||
|
||||
# move task into task rate project
|
||||
task.write({
|
||||
'project_id': self.project_task_rate.id,
|
||||
})
|
||||
task._onchange_project()
|
||||
|
||||
self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate")
|
||||
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task moved in a task rate billable 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")
|
||||
|
||||
# move subtask into task rate project
|
||||
subtask.write({
|
||||
'project_id': self.project_task_rate2.id,
|
||||
})
|
||||
|
||||
self.assertEqual(task.billable_type, 'task_rate', "Subtask should keep the billable type from its parent, even when they are moved into another project")
|
||||
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Subtask should keep the same sale order line than their mother, even when they are moved into another project")
|
||||
|
||||
def test_billing_task_rate(self):
|
||||
""" Check task and subtask creation, and timesheeting in a project billed at 'task rate'. Then move the task into a 'employee rate' project then, 'non billable'. """
|
||||
Task = self.env['project.task'].with_context(tracking_disable=True)
|
||||
Timesheet = self.env['account.analytic.line']
|
||||
|
||||
# set subtask project on task rate project
|
||||
self.project_task_rate.write({'subtask_project_id': self.project_subtask.id})
|
||||
|
||||
# create a task
|
||||
task = Task.with_context(default_project_id=self.project_task_rate.id).create({
|
||||
'name': 'first task',
|
||||
'partner_id': self.partner_customer_usd.id,
|
||||
})
|
||||
task._onchange_project()
|
||||
|
||||
self.assertEqual(task.billable_type, 'task_rate', "Task in project 'task rate' should be billed at task rate")
|
||||
self.assertEqual(task.sale_line_id, self.project_task_rate.sale_line_id, "Task created in a project billed on 'task rate' should be linked to a 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")
|
||||
|
||||
# log timesheet on task
|
||||
timesheet1 = Timesheet.create({
|
||||
'name': 'Test Line',
|
||||
'project_id': task.project_id.id,
|
||||
'task_id': task.id,
|
||||
'unit_amount': 50,
|
||||
'employee_id': self.employee_manager.id,
|
||||
})
|
||||
|
||||
self.assertEqual(self.project_task_rate.sale_line_id, timesheet1.so_line, "The timesheet should be linked to the SOL associated to the Employee manager in the map")
|
||||
|
||||
# create a subtask
|
||||
subtask = Task.with_context(default_project_id=self.project_task_rate.subtask_project_id.id).create({
|
||||
'name': 'first subtask task',
|
||||
'parent_id': task.id,
|
||||
})
|
||||
|
||||
self.assertEqual(subtask.billable_type, 'task_rate', "Subtask in a non billable project with a so line set is task rate billable")
|
||||
self.assertEqual(subtask.project_id.billable_type, 'no', "The subtask project is non billable even if the subtask is")
|
||||
self.assertEqual(subtask.partner_id, subtask.parent_id.partner_id, "Subtask should have the same customer as the one from their mother")
|
||||
|
||||
# log timesheet on subtask
|
||||
timesheet2 = Timesheet.create({
|
||||
'name': 'Test Line on subtask',
|
||||
'project_id': subtask.project_id.id,
|
||||
'task_id': subtask.id,
|
||||
'unit_amount': 50,
|
||||
'employee_id': self.employee_user.id,
|
||||
})
|
||||
|
||||
self.assertEqual(subtask.project_id, timesheet2.project_id, "The timesheet is in the subtask project")
|
||||
self.assertEqual(timesheet2.so_line, subtask.sale_line_id, "The timesheet should be linked to SOL as the task even in a non billable project")
|
||||
|
||||
# move task and subtask into task rate project
|
||||
task.write({
|
||||
'project_id': self.project_employee_rate.id,
|
||||
})
|
||||
task._onchange_project()
|
||||
subtask.write({
|
||||
'project_id': self.project_employee_rate.id,
|
||||
})
|
||||
subtask._onchange_project()
|
||||
|
||||
self.assertEqual(task.billable_type, 'employee_rate', "Task moved in project 'employee rate' should be billed at employee rate")
|
||||
self.assertFalse(task.sale_line_id, "Task moved in a employee rate billable project have empty so line")
|
||||
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.assertEqual(subtask.billable_type, 'employee_rate', "subtask moved in project 'employee rate' should be billed at employee rate")
|
||||
self.assertFalse(subtask.sale_line_id, "Subask moved in a employee rate billable project have empty so line")
|
||||
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")
|
||||
@@ -160,11 +160,8 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
|
||||
|
||||
# make task non billable
|
||||
task_serv2.write({'sale_line_id': False})
|
||||
self.assertTrue(all([billing_type == 'non_billable' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a non billable task should be non billable too")
|
||||
|
||||
# make task billable again
|
||||
task_serv2.write({'sale_line_id': so_line_deliver_global_project.id})
|
||||
self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "Timesheet to a billable time task should be billable")
|
||||
self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "billable type of timesheet should not change when tranfering task into another project")
|
||||
self.assertEqual(task_serv2.timesheet_ids.mapped('so_line'), so_line_deliver_global_project, "Old timesheet are not modified when changing the task SO line")
|
||||
|
||||
# invoice SO, and validate invoice
|
||||
invoice_id = self.sale_order.action_invoice_create()[0]
|
||||
@@ -173,7 +170,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
|
||||
|
||||
# try to update timesheets, catch error 'You cannot modify invoiced timesheet'
|
||||
with self.assertRaises(UserError):
|
||||
task_serv2.write({'sale_line_id': False})
|
||||
timesheets.write({'so_line': False})
|
||||
|
||||
def test_delivered_quantity(self):
|
||||
# create SO line and confirm it
|
||||
|
||||
@@ -25,9 +25,9 @@
|
||||
|
||||
<div class="o_timesheet_plan_stat_buttons oe_button_box">
|
||||
<t t-foreach="stat_buttons" t-as="stat_button">
|
||||
<button class="btn oe_stat_button" t-att-data-domain="stat_button['domain']" t-att-data-res-model="stat_button['res_model']">
|
||||
<button class="btn oe_stat_button" t-att-data-domain="stat_button.get('domain')" t-att-data-res-model="stat_button['res_model']" t-att-data-res-id="stat_button.get('res_id')">
|
||||
<div t-attf-class="fa fa-fw o_button_icon #{stat_button['icon']}" role="img" aria-label="Statistics" title="Statistics"></div>
|
||||
<div name="task_count" class="o_field_widget o_stat_info o_readonly_modifier" t-att-title="stat_button['name']">
|
||||
<div class="o_field_widget o_stat_info o_readonly_modifier" t-att-title="stat_button['name']">
|
||||
<span class="o_stat_value" t-if="stat_button.get('count')">
|
||||
<t t-esc="stat_button['count']"/>
|
||||
</span>
|
||||
|
||||
@@ -1,6 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="project_project_view_form" model="ir.ui.view">
|
||||
<field name="name">project.project.form.inherit</field>
|
||||
<field name="model">project.project</field>
|
||||
<field name="inherit_id" ref="project.edit_project"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('billable_type', '!=', 'no')]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//page[@name='emails']" position="after">
|
||||
<page name="billing_employee_rate" string="Invoicing" attrs="{'invisible': [('billable_type', '=', 'no')]}">
|
||||
<group>
|
||||
<field name="billable_type" invisible="1"/>
|
||||
<field name="sale_order_id" attrs="{'invisible': [('billable_type', '=', 'no')]}"/>
|
||||
<field name="sale_line_id" attrs="{'invisible': [('billable_type', '=', 'no')]}"/>
|
||||
</group>
|
||||
<field name="sale_line_employee_ids">
|
||||
<tree editable="top">
|
||||
<field name="employee_id" options="{'no_create': True}"/>
|
||||
<field name="sale_line_id" options="{'no_create': True}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
|
||||
<field name="price_unit"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_project_view_kanban_inherit_sale_timesheet" model="ir.ui.view">
|
||||
<field name="name">project.project.kanban.inherit.sale.timesheet</field>
|
||||
<field name="model">project.project</field>
|
||||
@@ -37,11 +64,13 @@
|
||||
<xpath expr="//button[@name='toggle_active']" position="before">
|
||||
<button type="object" name="action_view_so"
|
||||
class="oe_stat_button" icon="fa-dollar"
|
||||
attrs="{'invisible': [('sale_line_id', '=', False)]}"
|
||||
attrs="{'invisible': [('sale_order_id', '=', False)]}"
|
||||
string="Sales Order"/>
|
||||
<field name="sale_order_id" invisible="1"/>
|
||||
</xpath>
|
||||
<field name="user_id" position="after">
|
||||
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True, "no_create": True}'/>
|
||||
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': ['|', ('partner_id', '=', False)]}" options='{"no_open": True, "no_create": True}'/>
|
||||
<field name="billable_type" invisible="1"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
@@ -66,8 +95,12 @@
|
||||
<xpath expr="//field[@name='timesheet_ids']/tree" position="attributes">
|
||||
<attribute name="decoration-muted">timesheet_invoice_id != False</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='user_id']" position="after">
|
||||
<field name="is_project_map_empty" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
|
||||
<field name="timesheet_invoice_id" invisible="1"/>
|
||||
<field name="so_line" readonly="1" attrs="{'column_invisible': ['&', ('parent.is_project_map_empty', '=', True), ('parent.sale_line_id', '!=', False)]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.addons import decimal_precision as dp
|
||||
|
||||
|
||||
class ProjectCreateSalesOrder(models.TransientModel):
|
||||
@@ -15,31 +16,30 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
|
||||
active_model = self._context.get('active_model')
|
||||
if active_model != 'project.project':
|
||||
raise UserError(_('You can only apply this action from a project.'))
|
||||
raise UserError(_("You can only apply this action from a project."))
|
||||
|
||||
active_id = self._context.get('active_id')
|
||||
if 'project_id' in fields and active_id:
|
||||
project = self.env['project.project'].browse(active_id)
|
||||
if project.billable_type != 'no':
|
||||
raise UserError(_("The project is already billable."))
|
||||
result['project_id'] = active_id
|
||||
result['partner_id'] = self.env['project.project'].browse(active_id).partner_id.id
|
||||
result['partner_id'] = project.partner_id.id
|
||||
return result
|
||||
|
||||
project_id = fields.Many2one('project.project', "Project", domain=[('sale_line_id', '=', False)], help="Project to make billable", required=True)
|
||||
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service')], string="Service", required=True)
|
||||
partner_id = fields.Many2one('res.partner', string="Customer", domain=[('customer', '=', True)], required=True)
|
||||
price_unit = fields.Float("Price", help="Price unit of the selected product for the generated Sales Order.")
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id')
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.price_unit = self.product_id.lst_price
|
||||
line_ids = fields.One2many('project.create.sale.order.line', 'wizard_id', string='Lines')
|
||||
|
||||
@api.multi
|
||||
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 or self.project_id.tasks.mapped('sale_line_id'):
|
||||
if self.project_id.sale_line_id:
|
||||
raise UserError(_("The project is already linked to a sales order item."))
|
||||
|
||||
if not self.line_ids:
|
||||
raise UserError(_("At least one Sale Order Items should be filled."))
|
||||
|
||||
# create SO
|
||||
sale_order = self.env['sale.order'].create({
|
||||
'partner_id': self.partner_id.id,
|
||||
@@ -49,36 +49,46 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
sale_order.onchange_partner_id()
|
||||
sale_order.onchange_partner_shipping_id()
|
||||
|
||||
planned_hours = sum(self.project_id.tasks.mapped('planned_hours'))
|
||||
|
||||
# trying to simulate the SO line created a task, according to the product configuration
|
||||
# To avoid, generating a task when confirming the SO
|
||||
task_id = False
|
||||
if self.product_id.service_tracking in ['task_new_project', 'task_global_project']:
|
||||
task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id
|
||||
task_id = self.env['project.task'].search([('project_id', '=', self.project_id.id)], order='create_date DESC', limit=1).id
|
||||
project_id = self.project_id.id
|
||||
|
||||
# create SO line
|
||||
sale_order_line = self.env['sale.order.line'].create({
|
||||
'order_id': sale_order.id,
|
||||
'product_id': self.product_id.id,
|
||||
'price_unit': self.price_unit,
|
||||
'project_id': self.project_id.id, # prevent to re-create a project on confirmation
|
||||
'task_id': task_id,
|
||||
'product_uom_qty': sale_order.company_id.project_time_mode_id._compute_quantity(planned_hours, self.product_id.uom_id, raise_if_failure=False),
|
||||
})
|
||||
# create SO lines
|
||||
for wizard_line in self.line_ids:
|
||||
values = {
|
||||
'order_id': sale_order.id,
|
||||
'product_id': wizard_line.product_id.id,
|
||||
'price_unit': wizard_line.price_unit,
|
||||
'product_uom_qty': wizard_line.quantity,
|
||||
}
|
||||
if wizard_line.product_id.service_tracking in ['task_new_project', 'task_global_project']:
|
||||
values['task_id'] = task_id
|
||||
if wizard_line.product_id.service_tracking in ['task_new_project', 'project_only']:
|
||||
values['project_id'] = project_id
|
||||
|
||||
sale_order_line = self.env['sale.order.line'].create(values)
|
||||
|
||||
# confirm SO
|
||||
sale_order.action_confirm()
|
||||
|
||||
# link project and tasks to SO line
|
||||
self.project_id.write({
|
||||
'sale_line_id': sale_order_line.id,
|
||||
})
|
||||
self.project_id.tasks.filtered(lambda t: not t.sale_line_id).write({
|
||||
'sale_line_id': sale_order_line.id,
|
||||
'partner_id': sale_order.partner_id.id,
|
||||
'email_from': sale_order.partner_id.email,
|
||||
})
|
||||
# link project and tasks to SO line according to billable type
|
||||
if len(self.line_ids) == 1:
|
||||
self.project_id.write({
|
||||
'sale_order_id': sale_order.id,
|
||||
'sale_line_id': sale_order_line.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
})
|
||||
self.project_id.tasks.filtered(lambda task: task.billable_type == 'no').write({
|
||||
'sale_line_id': sale_order_line.id,
|
||||
'partner_id': sale_order.partner_id.id,
|
||||
'email_from': sale_order.partner_id.email,
|
||||
})
|
||||
else:
|
||||
self.project_id.write({
|
||||
'sale_order_id': sale_order.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
})
|
||||
|
||||
view_form_id = self.env.ref('sale.view_order_form').id
|
||||
action = self.env.ref('sale.action_orders').read()[0]
|
||||
@@ -89,3 +99,19 @@ class ProjectCreateSalesOrder(models.TransientModel):
|
||||
'res_id': sale_order.id,
|
||||
})
|
||||
return action
|
||||
|
||||
|
||||
class ProjectCreateSalesOrderLine(models.TransientModel):
|
||||
_name = 'project.create.sale.order.line'
|
||||
_order = 'id,create_date'
|
||||
|
||||
wizard_id = fields.Many2one('project.create.sale.order', required=True)
|
||||
product_id = fields.Many2one('product.product', domain=[('type', '=', 'service')], string="Service", required=True)
|
||||
price_unit = fields.Float("Price", default=1.0)
|
||||
quantity = fields.Float("Quantity", digits=dp.get_precision('Product Unit of Measure'), required=True, default=1.0)
|
||||
currency_id = fields.Many2one('res.currency', string="Currency", related='product_id.currency_id')
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.price_unit = self.product_id.lst_price
|
||||
|
||||
@@ -5,18 +5,23 @@
|
||||
<field name="name">project.create.sale.order.wizard.form</field>
|
||||
<field name="model">project.create.sale.order</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Sales Order from Project">
|
||||
<form string="Make Project Billable">
|
||||
<group>
|
||||
<group>
|
||||
<field name="project_id" readonly="1"/>
|
||||
<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="partner_id"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<field name="project_id" readonly="1"/>
|
||||
<field name="partner_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="line_ids" nolabel="1">
|
||||
<tree editable="bottom">
|
||||
<field name="product_id" options="{'no_create_edit': True, 'no_create': True}"/>
|
||||
<field name="quantity"/>
|
||||
<field name="price_unit" widget='monetary' options="{'currency_field': 'currency_id', 'field_digits': True}"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Create Sales Order" type="object" name="action_create_sale_order" class="oe_highlight"/>
|
||||
<button string="Create 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>
|
||||
@@ -32,7 +37,6 @@
|
||||
<field name="view_id" ref="project_create_sale_order_view_form"/>
|
||||
<field name="target">new</field>
|
||||
<field name="multi" eval="False"/>
|
||||
<field name="binding_model_id" ref="project.model_project_project"/>
|
||||
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
|
||||
</record>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user