[IMP] sale_timesheet: product config for service

The purpose is to simplify the product configuration.
Adding detailled options about the service tracking
(create task and/or project) make the setup less
confusing for users.

The invoice policy is replaced by "Invoice based"
(only for services). We don't support "ordered
quantities" with "manual" tracking anymore. The
supported use cases are :
- Ordered quantities (using timesheet)
- Timesheet on task (delivered with timesheet)
- Milestones (delivered manually)

Also, even when selecting 'Milestones', the SO line
can generate a task/project, but timesheeting on
thoses objects will not change the delivered
quantities on the SOL. So, a task can always be created
whatever the service invoice policy of the product.

Every generated task or project are linked to their SO
line. But this SO line is only incremented (on delivered
quantity) if its product 'service_type' is timesheet.
This commit is contained in:
Jérome Maes
2017-09-13 08:41:30 +02:00
parent 0d50bf5d5b
commit 538e62e53a
14 changed files with 106 additions and 74 deletions
+2 -2
View File
@@ -8,11 +8,11 @@ from odoo.addons.base.res.res_partner import WARNING_MESSAGE, WARNING_HELP
class ProductTemplate(models.Model):
_inherit = 'product.template'
track_service = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
service_type = fields.Selection([('manual', 'Manually set quantities on order')], string='Track Service',
help="Manually set quantities on order: Invoice based on the manually entered quantity, without creating an analytic account.\n"
"Timesheets on contract: Invoice based on the tracked hours on the related timesheet.\n"
"Create a task and track hours: Create a task on the sales order validation and track the work hours.",
default='manual')
default='manual', oldname='track_service')
sale_line_warn = fields.Selection(WARNING_MESSAGE, 'Sales Order Line', help=WARNING_HELP, required=True, default="no-message")
sale_line_warn_msg = fields.Text('Message for Sales Order Line')
expense_policy = fields.Selection(
+1 -1
View File
@@ -659,7 +659,7 @@ class SaleOrderLine(models.Model):
@api.depends('product_id.invoice_policy', 'order_id.state')
def _compute_qty_delivered_updateable(self):
for line in self:
line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.track_service == 'manual') and (line.product_id.expense_policy == 'no')
line.qty_delivered_updateable = (line.order_id.state == 'sale') and (line.product_id.service_type == 'manual') and (line.product_id.expense_policy == 'no')
@api.depends('invoice_lines',
'invoice_lines.price_total',
+1 -1
View File
@@ -696,7 +696,7 @@
<group name="invoicing" position="inside">
<group string="Invoicing" name="invoicing" attrs="{'invisible': [('sale_ok','=',False)]}">
<field name="invoice_policy" widget="radio"/>
<field name="track_service" widget="radio" invisible="True"/>
<field name="service_type" widget="radio" invisible="True"/>
</group>
</group>
</field>
+1 -1
View File
@@ -5,7 +5,7 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="sale.product_template_form_view_invoice_policy"/>
<field name="arch" type="xml">
<field name="track_service" position="after">
<field name="service_type" position="after">
<field name="expense_policy" widget="radio"
attrs="{'invisible': [('can_be_expensed','=',False)]}"/>
</field>
@@ -2,11 +2,13 @@
<odoo>
<data noupdate="1">
<record id="product.service_order_01" model="product.product">
<field name="track_service">timesheet</field>
<field name="service_type">timesheet</field>
<field name="service_tracking">project_only</field>
</record>
<record id="product.product_product_2" model="product.product">
<field name="name">Support Contract (on timesheet)</field>
<field name="track_service">timesheet</field>
<field name="service_type">timesheet</field>
<field name="service_tracking">project_only</field>
</record>
<record id="line_services" model="sale.order.line">
@@ -27,7 +29,8 @@
</record>
<record id="product.product_product_1" model="product.product">
<field name="track_service">task</field>
<field name="service_type">timesheet</field>
<field name="service_tracking">task_global_project</field>
<field name="project_id" ref="project_GAP"/>
</record>
</data>
+1 -28
View File
@@ -41,33 +41,6 @@ class AccountAnalyticLine(models.Model):
return True
@api.multi
def _sale_postprocess(self, additionnal_so_lines=None):
""" Set the SO line before recomputed delivered quantity on SO line.
Only applied for timesheet lines, having no SO line set yet.
"""
self.filtered(lambda aal: not aal.so_line and aal.project_id)._timesheet_set_sale_order_line()
return super(AccountAnalyticLine, self)._sale_postprocess(additionnal_so_lines=additionnal_so_lines)
@api.multi
def _timesheet_set_sale_order_line(self):
""" Automatically set the SO line on the analytic line, for the timesheet flow. It retrives
the SO line which has create the project (track_service='timesheet'). There is only one line
per SO like this (See constraint).
"""
for timesheet in self.filtered(lambda aal: not aal.so_line):
sol = self.env['sale.order.line'].search([
('order_id.analytic_account_id', '=', timesheet.account_id.id),
('state', 'in', ('sale', 'done')),
('product_id.track_service', '=', 'timesheet'),
('product_id.type', '=', 'service')
], limit=1)
if sol:
timesheet.write({
'so_line': sol.id,
'product_id': sol.product_id.id,
})
def _get_timesheet_values(self, values):
result = {}
values = values if values is not None else {}
@@ -144,7 +117,7 @@ class AccountAnalyticLine(models.Model):
if so_line.product_id.invoice_policy == 'delivery':
revenue = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100)))
billable_type = 'billable_time'
elif so_line.product_id.invoice_policy == 'order' and so_line.product_id.track_service == 'task':
elif so_line.product_id.invoice_policy == 'order':
quantity_hour = unit_amount
if so_line.product_uom.category_id == timesheet_uom.category_id:
quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom)
+37 -10
View File
@@ -7,16 +7,43 @@ from odoo import api, fields, models
class ProductTemplate(models.Model):
_inherit = 'product.template'
track_service = fields.Selection(selection_add=[
service_policy = fields.Selection([
('ordered_timesheet', 'Ordered quantities'),
('delivered_timesheet', 'Timesheets on tasks'),
('delivered_manual', 'Milestones (manually set quantities on order)')
], string="Invoice based on", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_type = fields.Selection(selection_add=[
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
('task', 'Create a task per order line to track hours')])
])
service_tracking = fields.Selection([
('no', 'Don\'t create task'),
('task_global_project', 'Create a task in an existing project'),
('task_new_project', 'Create a task in a new project'),
('project_only', 'Create a new project but no task'),
], string="Service Tracking", default="no", help="On Sales order confirmation, this product can generate project and/or task. From thoses, you can track the service you are selling.")
project_id = fields.Many2one(
'project.project', 'Project', company_dependent=True,
help='Create a task under this project on sales order validation. This setting must be set for each company.')
'project.project', 'Project', company_dependent=True, domain=[('sale_line_id', '=', False)],
help='Select a non billable project on which tasks can be created. This setting must be set for each company.')
@api.onchange('type')
def _onchange_type(self):
if self.type == 'service':
self.track_service = 'timesheet'
else:
self.track_service = 'manual'
@api.depends('invoice_policy', 'service_type')
def _compute_service_policy(self):
for product in self:
policy = 'ordered_timesheet'
if product.invoice_policy == 'delivery':
policy = 'delivered_manual' if product.service_type == 'manual' else 'delivered_timesheet'
product.service_policy = policy
def _inverse_service_policy(self):
for product in self:
policy = product.service_policy
if product.service_policy == 'ordered_timesheet':
product.invoice_policy = 'order'
product.service_type = 'timesheet'
else:
product.invoice_policy = 'delivery'
product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet'
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking != 'task_global_project':
self.project_id = False
+2
View File
@@ -8,6 +8,8 @@ from odoo.exceptions import ValidationError
class Project(models.Model):
_inherit = 'project.project'
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line', readonly=True, help="Sale order line from which the project has been created. Used for tracability.")
@api.multi
def action_view_timesheet(self):
self.ensure_one()
+22 -13
View File
@@ -45,16 +45,13 @@ class SaleOrder(models.Model):
order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)])
@api.multi
@api.constrains('order_line')
def _check_multi_timesheet(self):
@api.depends('order_line.product_id', 'project_project_id')
def _compute_project_ids(self):
for order in self:
count = 0
for line in order.order_line:
if line.product_id.track_service == 'timesheet':
count += 1
if count > 1:
raise ValidationError(_("You can use only one product on timesheet within the same sales order. You should split your order to include only one contract based on time and material."))
return {}
projects = order.order_line.mapped('product_id.project_id')
if order.project_project_id:
projects |= order.project_project_id
order.project_ids = projects
@api.multi
def action_confirm(self):
@@ -130,9 +127,10 @@ class SaleOrderLine(models.Model):
is_service = fields.Boolean("Is service", compute='_compute_is_service', help="Sales Order item should generate a task and/or a project, depending on the product settings.")
@api.multi
@api.depends('product_id.type')
def _compute_is_service(self):
for so_line in self:
so_line.is_service = so_line.product_id.type == 'service' and so_line.product_id.track_service in ['task', 'timesheet']
so_line.is_service = so_line.product_id.type == 'service'
@api.model
def create(self, values):
@@ -181,8 +179,14 @@ class SaleOrderLine(models.Model):
account = self.order_id.analytic_account_id
project = Project.search([('analytic_account_id', '=', account.id)], limit=1)
if not project:
project_id = account.sudo().project_create({'name': account.name})
project_id = account.sudo().project_create({
'name': account.name,
'allow_timesheets': self.product_id.service_type == 'timesheet',
})
project = Project.sudo().browse(project_id)
# set the SO line origin if product should create project
if not project.sale_line_id and self.product_id.service_tracking in ['task_new_project', 'project_only']:
project.write({'sale_line_id': self.id})
return project
def _timesheet_create_task_prepare_values(self):
@@ -251,7 +255,12 @@ class SaleOrderLine(models.Model):
the existing one to the line.
"""
for so_line in self.filtered(lambda sol: sol.is_service):
if so_line.product_id.track_service == 'task':
# create task
if so_line.product_id.service_tracking == 'task_global_project':
so_line._timesheet_find_task()
if so_line.product_id.track_service == 'timesheet':
# create project
if so_line.product_id.service_tracking == 'project_only':
so_line._timesheet_find_project()
# create project and task
if so_line.product_id.service_tracking == 'task_new_project':
so_line._timesheet_find_task()
@@ -49,7 +49,8 @@ class TestSaleService(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI',
'track_service': 'task',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': project.id
})
@@ -33,7 +33,8 @@ class TestSaleTimesheet(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI',
'track_service': 'task',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': self.project.id
})
@@ -46,7 +47,8 @@ class TestSaleTimesheet(TestSale):
'uom_id': self.env.ref('product.product_uom_hour').id,
'uom_po_id': self.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDER',
'track_service': 'task',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': self.project.id
})
+21 -9
View File
@@ -5,14 +5,17 @@
<field name="model">product.template</field>
<field name="inherit_id" ref="sale.product_template_form_view_invoice_policy"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='track_service']" position="attributes">
<xpath expr="//field[@name='invoice_policy']" position="attributes">
<attribute name="invisible">False</attribute>
<attribute name="attrs">{'invisible': [('type','!=','service')]}</attribute>
<attribute name="attrs">{'invisible': [('type','==','service')]}</attribute>
</xpath>
<field name="track_service" position="after">
<field name="project_id" attrs="{'invisible':['|', ('type','!=','service'), ('track_service', '!=', 'task')]}"
placeholder="Keep empty to get a new project per order..."
<xpath expr="//field[@name='service_type']" position="after">
<field name="service_policy" widget="radio" attrs="{'invisible': [('type','!=','service')]}"/>
<field name="service_tracking" widget="radio" attrs="{'invisible': [('type','!=','service')]}"/>
<field name="project_id" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"
/>
</xpath>
<field name="service_type" position="after">
</field>
</field>
</record>
@@ -24,8 +27,9 @@
<field name="arch" type="xml">
<xpath expr="//filter[@name='consumable']" position="after">
<separator/>
<filter string="Time-based services" name="product_time_based" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery')]"/>
<filter string="Fixed price services" name="product_service_fixed" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'order')]"/>
<filter string="Time-based services" name="product_time_based" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'timesheet')]"/>
<filter string="Fixed price services" name="product_service_fixed" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'order'), ('service_type', '=', 'timesheet')]"/>
<filter string="Milestone services" name="product_service_milestone" domain="[('type', '=', 'service'), ('invoice_policy', '=', 'delivery'), ('service_type', '=', 'manual')]"/>
</xpath>
</field>
</record>
@@ -35,7 +39,7 @@
<field name="res_model">product.template</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery'}</field>
<field name="context">{'search_default_product_time_based':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'timesheet'}</field>
</record>
<record id="product_template_action_fixed" model="ir.actions.act_window">
@@ -43,7 +47,15 @@
<field name="res_model">product.template</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order'}</field>
<field name="context">{'search_default_product_service_fixed':1, 'default_type': 'service', 'default_invoice_policy': 'order', 'default_service_type': 'timesheet'}</field>
</record>
<record id="product_template_action_milestone" model="ir.actions.act_window">
<field name="name">Products</field>
<field name="res_model">product.template</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_product_service_milestone':1, 'default_type': 'service', 'default_invoice_policy': 'delivery', 'default_service_type': 'manual'}</field>
</record>
</odoo>
@@ -15,6 +15,9 @@
<div>
<button name="%(sale_timesheet.product_template_action_fixed)d" string="Setup your fixed price services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
<div>
<button name="%(sale_timesheet.product_template_action_milestone)d" string="Setup your milestone services" type="action" class="btn-link" icon="fa-arrow-right"/>
</div>
</div>
</div>
</xpath>
@@ -212,11 +212,11 @@ tour.register('main_flow_tour', {
content: _t('Go to invoicing tab'),
position: 'bottom',
}, {
trigger: '.o_field_widget[name=invoice_policy] .o_radio_input[data-value="delivery"]',
content: _t('Change invoicing policy'),
trigger: '.o_field_widget[name=service_policy] .o_radio_input[data-value="delivered_timesheet"]',
content: _t('Change service policy'),
position: 'left',
}, {
trigger: '.o_field_widget[name=track_service] input[data-value="task"]',
trigger: '.o_field_widget[name=service_tracking] input[data-value="task_global_project"]',
content: _t('Change track service'),
position: 'left',
}, {