[MERGE] sale_timesheet: improvements for service company

Impacted modules: hr_timesheet, sale_timesheet, sale
and project_timesheet_holidays

The main change is the product configuration: a service
can create a task or a project, even if the update of
delivered quantities is manual.

Some code cleaning were done to ease comprehension and
maintenability.

This merge also bring usability changes and fine tuning
to manage your service like a pro.

For details, please have a look at sub commits.

Thanks for Thibault for the review and Benjamin (and Indian
Testing Team) for testing this. And Martin Trigaux that
did nothing, since he was on a stupid mountain ...
This commit is contained in:
Jérome Maes
2017-09-13 08:42:40 +02:00
50 changed files with 1569 additions and 639 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ class HolidaysType(models.Model):
double_validation = fields.Boolean(string='Apply Double Validation',
help="When selected, the Allocation/Leave Requests for this type require a second validation to be approved.")
company_id = fields.Many2one('res.company', string='Company')
company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env.user.company_id)
@api.multi
def get_days(self, employee_id):
+18 -11
View File
@@ -30,25 +30,32 @@ class AccountAnalyticLine(models.Model):
@api.model
def create(self, vals):
vals = self._update_timesheet_values(vals)
if not vals.get('employee_id') and vals.get('project_id'): # compute employee only for timesheet lines, makes no sense for other lines
if vals.get('user_id'):
ts_user_id = vals['user_id']
else:
ts_user_id = self.env.user.id
vals['employee_id'] = self.env['hr.employee'].search([('user_id', '=', ts_user_id)], limit=1).id
vals = self._timesheet_preprocess(vals)
return super(AccountAnalyticLine, self).create(vals)
@api.multi
def write(self, vals):
vals = self._update_timesheet_values(vals)
vals = self._timesheet_preprocess(vals)
return super(AccountAnalyticLine, self).write(vals)
def _update_timesheet_values(self, vals):
if vals.get('project_id'):
def _timesheet_preprocess(self, vals):
""" Deduce other field values from the one given.
Overrride this to compute on the fly some field that can not be computed fields.
:param values: dict values for `create`or `write`.
"""
# project implies analytic account
if vals.get('project_id') and not vals.get('account_id'):
project = self.env['project.project'].browse(vals.get('project_id'))
vals['account_id'] = project.analytic_account_id.id
if vals.get('employee_id'):
# employee implies user
if vals.get('employee_id') and not vals.get('user_id'):
employee = self.env['hr.employee'].browse(vals['employee_id'])
vals['user_id'] = employee.user_id.id
# compute employee only for timesheet lines, makes no sense for other lines
if not vals.get('employee_id') and vals.get('project_id'):
if vals.get('user_id'):
ts_user_id = vals['user_id']
else:
ts_user_id = self._default_user()
vals['employee_id'] = self.env['hr.employee'].search([('user_id', '=', ts_user_id)], limit=1).id
return vals
+3 -2
View File
@@ -10,6 +10,7 @@ class Project(models.Model):
allow_timesheets = fields.Boolean("Allow timesheets", default=True)
class Task(models.Model):
_inherit = "project.task"
@@ -25,7 +26,7 @@ class Task(models.Model):
children_hours += max(child_task.planned_hours, child_task.effective_hours + child_task.children_hours)
task.children_hours = children_hours
task.effective_hours = sum(task.timesheet_ids.mapped('unit_amount'))
task.effective_hours = sum(task.sudo().timesheet_ids.mapped('unit_amount')) # use 'sudo' here to allow project user (without timesheet user right) to create task
task.remaining_hours = task.planned_hours - task.effective_hours - task.children_hours
task.total_hours = max(task.planned_hours, task.effective_hours)
task.total_hours_spent = task.effective_hours + task.children_hours
@@ -42,7 +43,7 @@ class Task(models.Model):
effective_hours = fields.Float(compute='_hours_get', store=True, string='Hours Spent', help="Computed using the sum of the task work done.")
total_hours = fields.Float(compute='_hours_get', store=True, string='Total', help="Computed as: Time Spent + Remaining Time.")
total_hours_spent = fields.Float(compute='_hours_get', store=True, string='Total Hours', help="Computed as: Time Spent + Sub-tasks Hours.")
progress = fields.Float(compute='_hours_get', store=True, string='Working Time Recorded', group_operator="avg")
progress = fields.Float(compute='_hours_get', store=True, string='Progress', group_operator="avg")
delay_hours = fields.Float(compute='_hours_get', store=True, string='Delay Hours', help="Computed as difference between planned hours by the project manager and the total hours of the task.")
children_hours = fields.Float(compute='_hours_get', store=True, string='Sub-tasks Hours', help="Sum of the planned hours of all sub-tasks (when a sub-task is closed or its spent hours exceed its planned hours, spent hours are counted instead)")
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
@@ -2,10 +2,10 @@
<odoo>
<data>
<record id="act_hr_timesheet_report" model="ir.actions.act_window">
<field name="name">Activities</field>
<field name="name">By Employee</field>
<field name="res_model">account.analytic.line</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{"search_default_today":1}</field>
<field name="context">{'search_default_groupby_employee':1,}</field>
<field name="search_view_id" ref="hr_timesheet_line_search"/>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
@@ -19,34 +19,131 @@
</record>
<record model="ir.actions.act_window.view" id="act_hr_timesheet_report_pivot">
<field name="sequence" eval="1" />
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="hr_timesheet.view_hr_timesheet_line_pivot"/>
<field name="act_window_id" ref="act_hr_timesheet_report"/>
</record>
<record model="ir.actions.act_window.view" id="act_hr_timesheet_report_tree">
<field name="sequence" eval="2" />
<field name="sequence" eval="10"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_tree"/>
<field name="act_window_id" ref="act_hr_timesheet_report"/>
</record>
<record model="ir.actions.act_window.view" id="act_hr_timesheet_report_form">
<field name="sequence" eval="3" />
<field name="sequence" eval="15"/>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_form"/>
<field name="act_window_id" ref="act_hr_timesheet_report"/>
</record>
<record id="timesheet_action_report_by_project" model="ir.actions.act_window">
<field name="name">By Project</field>
<field name="res_model">account.analytic.line</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{'search_default_groupby_project':1,}</field>
<field name="search_view_id" ref="hr_timesheet_line_search"/>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to record activities.
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
</field>
</record>
<record id="timesheet_action_view_report_by_project_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="hr_timesheet.view_hr_timesheet_line_pivot"/>
<field name="act_window_id" ref="timesheet_action_report_by_project"/>
</record>
<record id="timesheet_action_view_report_by_project_tree" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_tree"/>
<field name="act_window_id" ref="timesheet_action_report_by_project"/>
</record>
<record id="timesheet_action_view_report_by_project_form" model="ir.actions.act_window.view">
<field name="sequence" eval="15"/>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_form"/>
<field name="act_window_id" ref="timesheet_action_report_by_project"/>
</record>
<record id="timesheet_action_report_by_task" model="ir.actions.act_window">
<field name="name">By Task</field>
<field name="res_model">account.analytic.line</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{'search_default_groupby_task':1,}</field>
<field name="search_view_id" ref="hr_timesheet_line_search"/>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click to record activities.
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
</field>
</record>
<record id="timesheet_action_view_report_by_task_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="hr_timesheet.view_hr_timesheet_line_pivot"/>
<field name="act_window_id" ref="timesheet_action_report_by_task"/>
</record>
<record id="timesheet_action_view_report_by_task_tree" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_tree"/>
<field name="act_window_id" ref="timesheet_action_report_by_task"/>
</record>
<record id="timesheet_action_view_report_by_task_form" model="ir.actions.act_window.view">
<field name="sequence" eval="15"/>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet.hr_timesheet_line_form"/>
<field name="act_window_id" ref="timesheet_action_report_by_task"/>
</record>
<menuitem id="menu_timesheets_reports"
name="Reporting"
parent="timesheet_menu_root"
groups="hr_timesheet.group_timesheet_manager"
sequence="99"/>
<menuitem id="menu_hr_activity_analysis"
<menuitem id="menu_timesheets_reports_timesheet"
name="Timesheet"
parent="menu_timesheets_reports"
groups="hr_timesheet.group_timesheet_manager"
sequence="10"/>
<menuitem id="menu_hr_activity_analysis"
parent="menu_timesheets_reports_timesheet"
action="act_hr_timesheet_report"
name="Activity Analysis"/>
name="By Employee"
sequence="10"/>
<menuitem id="timesheet_menu_report_timesheet_by_project"
parent="menu_timesheets_reports_timesheet"
action="timesheet_action_report_by_project"
name="By Project"
sequence="15"/>
<menuitem id="timesheet_menu_report_timesheet_by_task"
parent="menu_timesheets_reports_timesheet"
action="timesheet_action_report_by_task"
name="By Task"
sequence="20"/>
</data>
</odoo>
@@ -243,7 +243,12 @@
<field name="res_model">account.analytic.line</field>
<field name="search_view_id" ref="hr_timesheet_line_search"/>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{'search_default_week':1, 'search_default_groupby_employee':1}</field>
<field name="context">{
'search_default_week':1,
'search_default_groupby_employee':1,
'search_default_groupby_project':1,
'search_default_groupby_task':1
}</field>
</record>
<record id="timesheet_action_view_all_tree" model="ir.actions.act_window.view">
@@ -272,8 +277,5 @@
parent="menu_hr_time_tracking"
action="timesheet_action_all"/>
<menuitem id="menu_hr_working_hours" parent="menu_hr_time_tracking" action="act_hr_timesheet_line"
name="Detailed Activities"/>
</data>
</odoo>
+17 -15
View File
@@ -40,7 +40,7 @@
<field name="priority">24</field>
<field name="arch" type="xml">
<button name="toggle_active" position="before">
<button class="oe_stat_button" name="%(act_hr_timesheet_line_by_project)d" type="action" icon="fa-calendar" string="Timesheets" attrs="{'invisible': [('allow_timesheets', '=', False)]}"/>
<button class="oe_stat_button" name="%(act_hr_timesheet_line_by_project)d" type="action" icon="fa-calendar" string="Timesheets" attrs="{'invisible': [('allow_timesheets', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user"/>
</button>
<xpath expr="//div[@name='options_active']" position="inside">
<div>
@@ -55,16 +55,18 @@
<field name="name">project.task.form.inherited</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2" />
<field name="groups_id" eval="[(4, ref('base.group_user'))]"/>
<field name="groups_id" eval="[(6,0, (ref('hr_timesheet.group_hr_timesheet_user'),))]"/>
<field name="arch" type="xml">
<field name="tag_ids" position="after">
<field name="progress" widget="progressbar"/>
</field>
<field name="user_id" position="after">
<field name="planned_hours" widget="float_time"/>
</field>
<xpath expr="//notebook/page[@name='description_page']" position="after">
<page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user">
<page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}">
<group>
<group>
<field name="planned_hours" widget="float_time"/>
</group>
<group>
<field name="progress" widget="progressbar"/>
</group>
</group>
<field name="timesheet_ids" context="{'default_project_id': project_id}">
<tree editable="bottom" string="Timesheet Activities" default_order="date">
<field name="date"/>
@@ -116,16 +118,16 @@
<field name="allow_timesheets" invisible="1"/>
</field>
<xpath expr="//div[hasclass('o_kanban_manage_reports')]/div[last()]" position="after">
<div attrs="{'invisible': [('allow_timesheets', '=', False)]}">
<div attrs="{'invisible': [('allow_timesheets', '=', False)]}" groups="hr_timesheet.group_hr_timesheet_user">
<a name="%(act_hr_timesheet_line_by_project)d" type="action">Timesheets</a>
</div>
</xpath>
<xpath expr="//div[hasclass('o_project_kanban_boxes')]/div[last()]" position="after">
<div attrs="{'invisible': [('allow_timesheets', '=', False)]}" class="o_project_kanban_box">
<a t-if="record.allow_timesheets.raw_value" name="%(act_hr_timesheet_line_by_project)d" type="action">
<xpath expr="//div[hasclass('o_project_kanban_boxes')]" position="inside">
<a t-if="record.allow_timesheets.raw_value" class="o_project_kanban_box" name="%(act_hr_timesheet_line_by_project)d" type="action" groups="hr_timesheet.group_hr_timesheet_user">
<div>
<span class="o_label">Timesheets</span>
</a>
</div>
</div>
</a>
</xpath>
</field>
</record>
@@ -59,19 +59,21 @@
</div>
</div>
</div>
<h2>Leaves</h2>
<div class="row mt16 o_settings_container" name="timesheet_control">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_project_timesheet_holidays"/>
</div>
<div class="o_setting_right_pane">
<label for="module_project_timesheet_holidays"/>
<div class="text-muted">
Validated leaves create timesheets automatically.
<div name="section_leaves" groups="base.group_no_one">
<h2>Leaves</h2>
<div class="row mt16 o_settings_container" name="timesheet_control">
<div class="col-xs-12 col-md-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="module_project_timesheet_holidays"/>
</div>
<div class="content-group">
<div id="module_project_timesheet_holidays"/>
<div class="o_setting_right_pane">
<label for="module_project_timesheet_holidays"/>
<div class="text-muted">
Validated leaves create timesheets automatically.
</div>
<div class="content-group">
<div id="module_project_timesheet_holidays"/>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -31,7 +31,7 @@ tour.register('project_tour', {
content: _t('Click on the card to <b>go to your project</b> and start organizing tasks.'),
position: 'right',
run: function (actions) {
actions.auto(this.$anchor.find(".o_project_kanban_box:first > a"));
actions.auto(this.$anchor.find(".o_project_kanban_box:first"));
},
}, {
trigger: ".o_kanban_project_tasks .o_column_quick_create",
+7 -7
View File
@@ -327,15 +327,15 @@
</div>
<div class="o_project_kanban_boxes">
<div class="o_project_kanban_box">
<a name="%(act_project_project_2_project_task_all)d" type="action">
<a class="o_project_kanban_box" name="%(act_project_project_2_project_task_all)d" type="action">
<div>
<span class="o_value"><t t-esc="record.task_count.value"/></span>
<span class="o_label"><t t-esc="record.label_tasks.value"/></span>
</a>
<a t-if="record.task_needaction_count.raw_value" class="o_needaction" name="%(act_project_project_2_project_task_all)d" type="action" context="{'search_default_message_needaction': 1}">
<t t-esc="record.task_needaction_count.raw_value"/>
</a>
</div>
<a t-if="record.task_needaction_count.raw_value" class="o_needaction" name="%(act_project_project_2_project_task_all)d" type="action" context="{'search_default_message_needaction': 1}">
<t t-esc="record.task_needaction_count.raw_value"/>
</a>
</div>
</a>
</div>
</div>
</t>
@@ -2,3 +2,19 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
def post_init(cr, registry):
""" Set the timesheet project and task on existing leave type. Do it in post_init to
be sure the internal project/task of res.company are set. (Since timesheet_generate field
is true by default, those 2 fields are required on the leave type).
"""
from odoo import api, SUPERUSER_ID
env = api.Environment(cr, SUPERUSER_ID, {})
for leave_type in env['hr.holidays.status'].search([('timesheet_generate', '=', True), ('timesheet_project_id', '=', False)]):
company = leave_type.company_id or env.user.company_id
leave_type.write({
'timesheet_project_id': company.leave_timesheet_project_id.id,
'timesheet_task_id': company.leave_timesheet_task_id.id,
})
@@ -17,8 +17,11 @@ on leaves. Project and task can be configured company-wide.
'data': [
'views/res_config_settings_views.xml',
'views/hr_holidays_views.xml',
'security/ir.model.access.csv',
],
'demo': [],
'installable': True,
'auto_install': True,
'post_init_hook': 'post_init',
}
@@ -1,13 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class HolidaysType(models.Model):
_inherit = "hr.holidays.status"
timesheet_generate = fields.Boolean('Generate Timesheet', help="If checked, when validating a leave, timesheet will be generated in the Vacation Project of the company.")
timesheet_generate = fields.Boolean('Generate Timesheet', default=True, help="If checked, when validating a leave, timesheet will be generated in the Vacation Project of the company.")
timesheet_project_id = fields.Many2one('project.project', string="Internal Project", help="The project will contain the timesheet generated when a leave is validated.")
timesheet_task_id = fields.Many2one('project.task', string="Internal Task for timesheet", domain="[('project_id', '=', timesheet_project_id)]")
@@ -21,6 +22,13 @@ class HolidaysType(models.Model):
self.timesheet_project_id = False
self.timesheet_task_id = False
@api.constrains('timesheet_generate')
def _check_timesheet_generate(self):
for holiday_status in self:
if holiday_status.timesheet_generate:
if not holiday_status.timesheet_project_id or not holiday_status.timesheet_task_id:
raise ValidationError(_('For the leaves to generate timesheet, the internal project and task are requried.'))
class Holidays(models.Model):
_inherit = "hr.holidays"
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_analytic_account_leaves_manager,account.analytic.account,analytic.model_account_analytic_account,hr_holidays.group_hr_holidays_manager,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_analytic_account_leaves_manager account.analytic.account analytic.model_account_analytic_account hr_holidays.group_hr_holidays_manager 1 0 0 0
+1 -1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic
from . import account_invoice
from . import product_pricelist
from . import product_product
@@ -8,7 +9,6 @@ from . import product_template
from . import res_company
from . import res_partner
from . import sale
from . import sale_analytic
from . import res_config_settings
from . import sale_layout
from . import sales_team
+132
View File
@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
so_line = fields.Many2one('sale.order.line', string='Sales Order Line')
@api.model
def create(self, values):
result = super(AccountAnalyticLine, self).create(values)
result._sale_postprocess(values)
return result
@api.multi
def write(self, values):
# get current so lines for which update qty wil be required
sale_order_lines = self.env['sale.order.line']
if 'so_line' in values:
sale_order_lines = self.sudo().mapped('so_line')
result = super(AccountAnalyticLine, self).write(values)
self._sale_postprocess(values, additional_so_lines=sale_order_lines)
return result
@api.multi
def unlink(self):
sale_order_lines = self.sudo().mapped('so_line')
res = super(AccountAnalyticLine, self).unlink()
sale_order_lines._analytic_compute_delivered_quantity()
return res
@api.model
def _sale_get_fields_delivered_qty(self):
""" Returns a list with the field impacting the delivered quantity on SO line. """
return ['so_line', 'unit_amount', 'product_uom_id']
@api.multi
def _sale_postprocess(self, values, additional_so_lines=None):
if 'so_line' not in values: # allow to force a False value for so_line
self.with_context(sale_analytic_norecompute=True)._sale_determine_order_line()
if any(field_name in values for field_name in self._sale_get_fields_delivered_qty()):
if not self._context.get('sale_analytic_norecompute'):
so_lines = self.sudo().filtered(lambda aal: aal.so_line).mapped('so_line')
if additional_so_lines:
so_lines |= additional_so_lines
so_lines.sudo()._analytic_compute_delivered_quantity()
# NOTE JEM: thoses method are used in vendor bills to reinvoice at cost (see test `test_cost_invoicing`)
# some cleaning are still necessary
@api.multi
def _sale_get_invoice_price(self, order):
self.ensure_one()
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id.id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
).price
if self.unit_amount == 0.0:
return 0.0
# Prevent unnecessary currency conversion that could be impacted by exchange rate
# fluctuations
if self.currency_id and self.amount_currency and self.currency_id == order.currency_id:
return abs(self.amount_currency / self.unit_amount)
price_unit = abs(self.amount / self.unit_amount)
currency_id = self.company_id.currency_id
if currency_id and currency_id != order.currency_id:
price_unit = currency_id.compute(price_unit, order.currency_id)
return price_unit
@api.multi
def _sale_prepare_sale_order_line_values(self, order, price):
self.ensure_one()
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
last_sequence = last_so_line.sequence + 1 if last_so_line else 100
fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)
return {
'order_id': order.id,
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [x.id for x in taxes],
'discount': 0.0,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': 0.0,
'qty_delivered': self.unit_amount,
}
@api.multi
def _sale_determine_order_line(self):
""" Automatically set the SO line on the analytic line, for the expense/vendor bills flow. It retrives
an existing line, or create a new one (upselling expenses).
"""
for analytic_line in self.sudo().filtered(lambda aal: not aal.so_line and aal.account_id and aal.product_id and aal.product_id.expense_policy != 'no'):
# determine SO : first SO open linked to AA
sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id), ('state', '=', 'sale')], limit=1)
if not sale_order:
sale_order = self.env['sale.order'].search([('analytic_account_id', '=', analytic_line.account_id.id)], limit=1)
if not sale_order:
continue
price = analytic_line._sale_get_invoice_price(sale_order)
so_line = self.env['sale.order.line'].search([
('order_id', '=', sale_order.id),
('price_unit', '=', price),
('product_id', '=', self.product_id.id)
], limit=1)
if not so_line:
# generate a new SO line
if sale_order.state != 'sale':
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % sale_order.name)
so_line_values = analytic_line._sale_prepare_sale_order_line_values(sale_order, price)
if so_line_values:
so_line = self.env['sale.order.line'].create(so_line_values)
so_line._compute_tax_id()
if so_line: # if so line found or created, then update AAL (this will trigger the recomputation of qty delivered on SO line)
analytic_line.write({'so_line': so_line.id})
+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(
+52 -8
View File
@@ -110,9 +110,6 @@ class SaleOrder(models.Model):
for order in self:
order.order_line._compute_tax_id()
def _inverse_project_id(self):
self.project_id = self.related_project_id
name = fields.Char(string='Order Reference', required=True, copy=False, readonly=True, states={'draft': [('readonly', False)]}, index=True, default=lambda self: _('New'))
origin = fields.Char(string='Source Document', help="Reference of the document that generated this sales order request.")
client_order_ref = fields.Char(string='Customer Reference', copy=False)
@@ -138,8 +135,7 @@ class SaleOrder(models.Model):
pricelist_id = fields.Many2one('product.pricelist', string='Pricelist', required=True, readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="Pricelist for current sales order.")
currency_id = fields.Many2one("res.currency", related='pricelist_id.currency_id', string="Currency", readonly=True, required=True)
project_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False)
related_project_id = fields.Many2one('account.analytic.account', inverse='_inverse_project_id', related='project_id', string='Analytic Account', help="The analytic account related to a sales order.")
analytic_account_id = fields.Many2one('account.analytic.account', 'Analytic Account', readonly=True, states={'draft': [('readonly', False)], 'sent': [('readonly', False)]}, help="The analytic account related to a sales order.", copy=False, oldname='project_id')
order_line = fields.One2many('sale.order.line', 'order_id', string='Order Lines', states={'cancel': [('readonly', True)], 'done': [('readonly', True)]}, copy=True, auto_join=True)
@@ -513,7 +509,7 @@ class SaleOrder(models.Model):
'company_id': order.company_id.id,
'partner_id': order.partner_id.id
})
order.project_id = analytic
order.analytic_account_id = analytic
@api.multi
def order_lines_layouted(self):
@@ -663,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',
@@ -895,7 +891,7 @@ class SaleOrderLine(models.Model):
'product_id': self.product_id.id or False,
'layout_category_id': self.layout_category_id and self.layout_category_id.id or False,
'invoice_line_tax_ids': [(6, 0, self.tax_id.ids)],
'account_analytic_id': self.order_id.project_id.id,
'account_analytic_id': self.order_id.analytic_account_id.id,
'analytic_tag_ids': [(6, 0, self.analytic_tag_ids.ids)],
}
return res
@@ -1087,3 +1083,51 @@ class SaleOrderLine(models.Model):
discount = (new_list_price - price) / new_list_price * 100
if discount > 0:
self.discount = discount
###########################
# Analytic Methods
###########################
@api.multi
def _analytic_compute_delivered_quantity_domain(self):
""" Return the domain of the analytic lines to use to recompute the delivered quantity
on SO lines. This method is a hook: since analytic line are used for timesheet,
expense, ... each use case should provide its part of the domain.
"""
return [('so_line', 'in', self.ids), ('amount', '<=', 0.0)]
@api.multi
def _analytic_compute_delivered_quantity(self):
""" Compute and write the delivered quantity of current SO lines, based on their related
analytic lines.
"""
# avoid recomputation if no SO lines concerned
if not self:
return False
# group anaytic lines by product uom and so line
domain = self._analytic_compute_delivered_quantity_domain()
data = self.env['account.analytic.line'].read_group(
domain,
['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
)
# convert uom and sum all unit_amount of analytic lines to get the delivered qty of SO lines
value_to_write = {}
for item in data:
if not item['product_uom_id']:
continue
so_line = self.browse(item['so_line'][0])
value_to_write.setdefault(so_line, 0.0)
uom = self.env['product.uom'].browse(item['product_uom_id'][0])
if so_line.product_uom.category_id == uom.category_id:
qty = uom._compute_quantity(item['unit_amount'], so_line.product_uom)
else:
qty = item['unit_amount']
value_to_write[so_line] += qty
# write the delivered quantity
for so_line, qty in value_to_write.items():
so_line.write({'qty_delivered': qty})
return True
-147
View File
@@ -1,147 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
@api.multi
def _compute_analytic(self, domain=None):
lines = {}
force_so_lines = self.env.context.get("force_so_lines")
if not domain:
if not self.ids and not force_so_lines:
return True
# To filter on analyic lines linked to an expense
expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False)
expense_type_id = expense_type_id and expense_type_id.id
domain = [('so_line', 'in', self.ids), ('amount', '<=', 0.0)]
data = self.env['account.analytic.line'].read_group(
domain,
['so_line', 'unit_amount', 'product_uom_id'], ['product_uom_id', 'so_line'], lazy=False
)
# If the unlinked analytic line was the last one on the SO line, the qty was not updated.
if force_so_lines:
for line in force_so_lines:
lines.setdefault(line, 0.0)
for d in data:
if not d['product_uom_id']:
continue
line = self.browse(d['so_line'][0])
lines.setdefault(line, 0.0)
uom = self.env['product.uom'].browse(d['product_uom_id'][0])
if line.product_uom.category_id == uom.category_id:
qty = uom._compute_quantity(d['unit_amount'], line.product_uom)
else:
qty = d['unit_amount']
lines[line] += qty
for line, qty in lines.items():
line.qty_delivered = qty
return True
class AccountAnalyticLine(models.Model):
_inherit = "account.analytic.line"
so_line = fields.Many2one('sale.order.line', string='Sales Order Line')
def _get_invoice_price(self, order):
if self.product_id.expense_policy == 'sales_price':
return self.product_id.with_context(
partner=order.partner_id.id,
date_order=order.date_order,
pricelist=order.pricelist_id.id,
uom=self.product_uom_id.id
).price
if self.unit_amount == 0.0:
return 0.0
# Prevent unnecessary currency conversion that could be impacted by exchange rate
# fluctuations
if self.currency_id and self.amount_currency and self.currency_id == order.currency_id:
return abs(self.amount_currency / self.unit_amount)
price_unit = abs(self.amount / self.unit_amount)
currency_id = self.company_id.currency_id
if currency_id and currency_id != order.currency_id:
price_unit = currency_id.compute(price_unit, order.currency_id)
return price_unit
def _get_sale_order_line_vals(self, order, price):
last_so_line = self.env['sale.order.line'].search([('order_id', '=', order.id)], order='sequence desc', limit=1)
last_sequence = last_so_line.sequence + 1 if last_so_line else 100
fpos = order.fiscal_position_id or order.partner_id.property_account_position_id
taxes = fpos.map_tax(self.product_id.taxes_id, self.product_id, order.partner_id)
return {
'order_id': order.id,
'name': self.name,
'sequence': last_sequence,
'price_unit': price,
'tax_id': [x.id for x in taxes],
'discount': 0.0,
'product_id': self.product_id.id,
'product_uom': self.product_uom_id.id,
'product_uom_qty': 0.0,
'qty_delivered': self.unit_amount,
}
def _get_sale_order_line(self, vals=None):
result = dict(vals or {})
so_line = result.get('so_line', False) or self.so_line
if not so_line and self.account_id and self.product_id and (self.product_id.expense_policy != 'no'):
order_in_sale = self.env['sale.order'].search([('project_id', '=', self.account_id.id), ('state', '=', 'sale')], limit=1)
order = order_in_sale or self.env['sale.order'].search([('project_id', '=', self.account_id.id)], limit=1)
if not order:
return result
price = self._get_invoice_price(order)
so_lines = self.env['sale.order.line'].search([
('order_id', '=', order.id),
('price_unit', '=', price),
('product_id', '=', self.product_id.id)])
if so_lines:
result.update({'so_line': so_lines[0].id})
else:
if order.state != 'sale':
raise UserError(_('The Sales Order %s linked to the Analytic Account must be validated before registering expenses.') % order.name)
order_line_vals = self._get_sale_order_line_vals(order, price)
if order_line_vals:
so_line = self.env['sale.order.line'].create(order_line_vals)
so_line._compute_tax_id()
result.update({'so_line': so_line.id})
return result
@api.multi
def write(self, values):
if self._context.get('create', False):
return super(AccountAnalyticLine, self).write(values)
lines = super(AccountAnalyticLine, self).write(values)
for line in self:
res = line.sudo()._get_sale_order_line(vals=values)
super(AccountAnalyticLine, line).write(res)
self.mapped('so_line').sudo()._compute_analytic()
return lines
@api.model
def create(self, values):
line = super(AccountAnalyticLine, self).create(values)
res = line.sudo()._get_sale_order_line(vals=values)
line.with_context(create=True).write(res)
line.mapped('so_line').sudo()._compute_analytic()
return line
@api.multi
def unlink(self):
so_lines = self.sudo().mapped('so_line')
res = super(AccountAnalyticLine, self).unlink()
so_lines.with_context(force_so_lines=so_lines)._compute_analytic()
return res
@@ -42,7 +42,7 @@ class PosSaleReport(models.Model):
rp.country_id AS country_id,
sol.price_subtotal / COALESCE (cr.rate, 1.0) AS price_subtotal,
(sol.product_uom_qty / u.factor * u2.factor) as product_qty,
so.project_id AS analytic_account_id,
so.analytic_account_id AS analytic_account_id,
so.team_id AS team_id
FROM sale_order_line sol
+2 -2
View File
@@ -71,7 +71,7 @@ class SaleReport(models.Model):
extract(epoch from avg(date_trunc('day',s.date_order)-date_trunc('day',s.create_date)))/(24*60*60)::decimal(16,2) as delay,
t.categ_id as categ_id,
s.pricelist_id as pricelist_id,
s.project_id as analytic_account_id,
s.analytic_account_id as analytic_account_id,
s.team_id as team_id,
p.product_tmpl_id,
partner.country_id as country_id,
@@ -112,7 +112,7 @@ class SaleReport(models.Model):
s.state,
s.company_id,
s.pricelist_id,
s.project_id,
s.analytic_account_id,
s.team_id,
p.product_tmpl_id,
partner.country_id,
+1 -1
View File
@@ -127,7 +127,7 @@ class TestSaleOrder(TestSale):
'name': '',
'type': 'in_invoice',
'partner_id': inv_partner.id,
'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.project_id.id, 'account_id': account_income.id})],
'invoice_line_ids': [(0, 0, {'name': serv_cost.name, 'product_id': serv_cost.id, 'quantity': 2, 'uom_id': serv_cost.uom_id.id, 'price_unit': serv_cost.standard_price, 'account_analytic_id': so.analytic_account_id.id, 'account_id': account_income.id})],
'account_id': account_payable.id,
'journal_id': journal.id,
'currency_id': company.currency_id.id,
+3 -7
View File
@@ -383,11 +383,7 @@
<field name="team_id" options="{'no_create': True}"/>
<field name="client_order_ref"/>
<field name="company_id" options="{'no_create': True}" groups="base.group_multi_company"/>
<field name="project_id" context="{'default_partner_id':partner_invoice_id, 'default_name':name}" groups="analytic.group_analytic_accounting"/>
<field name="related_project_id"
attrs="{'readonly': ['|',('project_id','!=',False),('invoice_count','!=',0),('state','=','sale')],'invisible':[('state','!=','sale')]}"
context="{'default_partner_id':partner_invoice_id, 'default_name':name}"
groups="analytic.group_analytic_accounting"/>
<field name="analytic_account_id" context="{'default_partner_id':partner_invoice_id, 'default_name':name}" attrs="{'readonly': ['|',('analytic_account_id','!=',False),('invoice_count','!=',0),('state','=','sale')]}" groups="analytic.group_analytic_accounting"/>
</group>
<group name="sale_pay" string="Invoicing">
<field name="date_order" attrs="{'invisible': [('state', 'in', ['sale', 'done', 'cancel'])]}"/>
@@ -433,7 +429,7 @@
<field name="partner_id" operator="child_of"/>
<field name="user_id"/>
<field name="team_id" string="Sales Channel"/>
<field name="project_id"/>
<field name="analytic_account_id"/>
<field name="product_id"/>
<filter string="My Orders" domain="[('user_id','=',uid)]" name="my_sale_orders_filter"/>
<separator/>
@@ -700,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>
@@ -107,7 +107,7 @@ class SaleAdvancePaymentInv(models.TransientModel):
'product_id': self.product_id.id,
'sale_line_ids': [(6, 0, [so_line.id])],
'invoice_line_tax_ids': [(6, 0, tax_ids)],
'account_analytic_id': order.project_id.id or False,
'account_analytic_id': order.analytic_account_id.id or False,
})],
'currency_id': order.pricelist_id.currency_id.id,
'payment_term_id': order.payment_term_id.id,
@@ -40,7 +40,7 @@ class TestSaleExpense(TestSale):
exp = self.env['hr.expense'].create({
'name': 'Air Travel',
'product_id': prod_exp_1.id,
'analytic_account_id': so.project_id.id,
'analytic_account_id': so.analytic_account_id.id,
'unit_amount': 621.54,
'employee_id': employee.id,
'sheet_id': sheet.id
@@ -67,7 +67,7 @@ class TestSaleExpense(TestSale):
exp = self.env['hr.expense'].create({
'name': 'Car Travel',
'product_id': prod_exp_2.id,
'analytic_account_id': so.project_id.id,
'analytic_account_id': so.analytic_account_id.id,
'product_uom_id': self.env.ref('product.product_uom_km').id,
'unit_amount': 0.15,
'quantity': 100,
+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>
-1
View File
@@ -16,7 +16,6 @@ have real delivered quantities in sales orders.
'website': 'https://www.odoo.com/page/warehouse',
'depends': ['sale_management', 'hr_timesheet'],
'data': [
'data/sale_timesheet_data.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
+67 -5
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo import http, _
from odoo.http import request
from odoo.tools import float_round
@@ -12,19 +12,30 @@ class SaleTimesheetController(http.Controller):
@http.route('/timesheet/plan', type='json', auth="user")
def plan(self, domain):
values = self._prepare_plan_values(domain)
view = request.env['ir.model.data'].get_object('sale_timesheet', 'timesheet_plan')
view = request.env.ref('sale_timesheet.timesheet_plan')
return {
'html_content': view.render(values)
}
def _prepare_plan_values(self, domain):
def float_to_time(hour_number):
hours, minutes = divmod(hour_number * 60, 60)
return _('%sh%s') % (hours, '%02d' % minutes)
timesheet_lines = request.env['account.analytic.line'].search(domain)
values = {
'currency': request.env.user.company_id.currency_id,
'timesheet_lines': request.env['account.analytic.line'].search(domain),
'timesheet_lines': timesheet_lines,
'domain': domain,
'float_to_time': float_to_time,
}
hour_rounding = request.env.ref('product.product_uom_hour').rounding
billable_types = ['non_billable', 'billable_time', 'billable_fixed']
billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed']
# -- Stat Buttons
values['stat_buttons'] = self._plan_get_stat_button(timesheet_lines)
# -- Dashboard (per billable type)
dashboard_values = {
@@ -69,11 +80,13 @@ class SaleTimesheetController(http.Controller):
repartition_employee.setdefault(employee_id, dict(
employee_id=data['employee_id'][0],
employee_name=data['employee_id'][1],
non_billable_project=0.0,
non_billable=0.0,
billable_time=0.0,
billable_fixed=0.0,
total=0.0
total=0.0,
))[data['timesheet_invoice_type']] = float_round(data.get('unit_amount', 0.0), precision_rounding=hour_rounding)
repartition_employee[employee_id]['__domain_'+data['timesheet_invoice_type']] = data['__domain']
# compute total
for employee_id, vals in repartition_employee.items():
@@ -84,3 +97,52 @@ class SaleTimesheetController(http.Controller):
values['repartition_employee'] = repartition_employee
return values
def _plan_get_stat_button(self, timesheet_lines):
stat_buttons = []
stat_buttons.append({
'name': _('Timesheets'),
'res_model': 'account.analytic.line',
'domain': [('id', 'in', timesheet_lines.ids)],
'icon': 'fa fa-calendar',
})
stat_project_ids = timesheet_lines.mapped('project_id').ids
stat_task_domain = [('project_id', 'in', stat_project_ids), '|', ('stage_id', '=', False), ('stage_id.fold', '=', False)]
stat_buttons.append({
'name': _('Tasks'),
'count': request.env['project.task'].search_count(stat_task_domain),
'res_model': 'project.task',
'domain': stat_task_domain,
'icon': 'fa fa-tasks',
})
return stat_buttons
@http.route('/timesheet/plan/action', type='json', auth="user")
def plan_stat_button(self, domain, res_model='account.analytic.line'):
action = {
'type': 'ir.actions.act_window',
'view_id': False,
'view_mode': 'tree,form',
'view_type': 'list',
'domain': domain,
}
if 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 = {
'name': _('Timesheets'),
'type': 'ir.actions.act_window',
'res_model': res_model,
'view_mode': 'tree,form',
'view_type': 'tree',
'views': [[ts_view_tree_id, 'list'], [ts_view_form_id, 'form']],
'domain': domain,
}
elif res_model == 'project.task':
action = request.env.ref('project.action_view_task').read()[0]
action.update({
'name': _('Tasks'),
'domain': domain,
'context': request.env.context, # erase original context to avoid default filter
})
return action
@@ -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,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- sale_timesheet and project define the same field without depending on each, which causes the field to be deleted when the module that created it
is deleted. To avoid this, we create xmlids manually for this field in both modules to prevent accidental deletion. To fix in saas-7 by moving the field definition-->
<record id="duplicate_field_xmlid" model="ir.model.data">
<field name="res_id" search="[('model','=','res.company'),('name','=','project_time_mode_id')]" model="ir.model.fields"/>
<field name="model">ir.model.fields</field>
<field name="module">sale_timesheet</field>
<field name="name">project_time_mode_id_duplicate_xmlid</field>
<field name="noupdate">True</field>
</record>
</data>
</odoo>
-1
View File
@@ -6,5 +6,4 @@ from . import account_invoice
from . import hr
from . import product
from . import project
from . import res_company
from . import sale_order
+86 -129
View File
@@ -11,78 +11,62 @@ class AccountAnalyticLine(models.Model):
timesheet_invoice_type = fields.Selection([
('billable_time', 'Billable Time'),
('billable_fixed', 'Billable Fixed'),
('non_billable', 'Non Billable')], string="Billable Type", readonly=True, copy=False)
('non_billable', 'Non Billable'),
('non_billable_project', 'No task found')], string="Billable Type", readonly=True, copy=False)
timesheet_invoice_id = fields.Many2one('account.invoice', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")
timesheet_revenue = fields.Monetary("Revenue", default=0.0, readonly=True, currency_field='company_currency_id', copy=False)
@api.model
def create(self, values):
if values.get('task_id'):
task = self.env['project.task'].browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
values.update(self._get_timesheet_values(values))
values.update(self._get_timesheet_billing_values(values))
return super(AccountAnalyticLine, self).create(values)
result = super(AccountAnalyticLine, self).create(values)
# applied only for timesheet
if result.project_id:
result._timesheet_postprocess(values)
return result
@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', 'timesheet_revenue', 'so_line', 'amount', 'date']]):
raise UserError(_('You can not modify already invoiced timesheets.'))
so_lines = self.mapped('so_line')
if values.get('task_id'):
task = self.env['project.task'].browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
for line in self:
values.update(line._get_timesheet_values(values))
values.update(line._get_timesheet_billing_values(values))
super(AccountAnalyticLine, line).write(values)
# Update delivered quantity on SO lines which are not linked to the analytic lines anymore
so_lines -= self.mapped('so_line')
if so_lines:
so_lines.with_context(force_so_lines=so_lines).sudo()._compute_analytic()
return True
def _get_timesheet_values(self, values):
result = {}
values = values if values is not None else {}
if values.get('project_id') or self.project_id:
if values.get('amount'):
return {}
unit_amount = values.get('unit_amount', 0.0) or self.unit_amount
# find the employee
employee_id = values.get('employee_id') or self.employee_id.id or (self.env.user.employee_ids and self.env.user.employee_ids[0].id or False)
employee = self.env['hr.employee'].browse(employee_id) if employee_id else self.env['hr.employee']
if not employee:
user_id = values.get('user_id') or self.user_id.id or self._default_user()
employee = self.env['hr.employee'].search([('user_id', '=', user_id)], limit=1)
# find the analytic account
account_id = values.get('account_id') or self.account_id.id
if not account_id and values.get('project_id'):
project = self.env['project.project'].browse(values['project_id'])
account_id = project.analytic_account_id.id
analytic_account = self.env['account.analytic.account'].browse(account_id) if account_id else self.env['account.analytic.account']
# convert employee cost into timesheet (analytic account) currency
uom = employee.company_id.project_time_mode_id
cost = employee.timesheet_cost or 0.0
amount = -unit_amount * cost
amount_converted = employee.currency_id.compute(amount, analytic_account.currency_id)
result.update({
'amount': amount_converted,
'product_uom_id': uom.id,
'account_id': account_id,
})
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)
# applied only for timesheet
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
def _get_timesheet_billing_values(self, values):
"""
@api.model
def _timesheet_preprocess(self, values):
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
# task implies so line
if 'task_id' in values:
task = self.env['project.task'].sudo().browse(values['task_id'])
values['so_line'] = task.sale_line_id.id or values.get('so_line', False)
return values
@api.multi
def _timesheet_postprocess(self, values):
sudo_self = self.sudo() # this creates only one env for all operation that required sudo()
# (re)compute the amount (depending on unit_amount, employee_id for the cost, and account_id for currency)
if any([field_name in values for field_name in ['unit_amount', 'employee_id', 'account_id']]):
for timesheet in sudo_self:
uom = timesheet.employee_id.company_id.project_time_mode_id
cost = timesheet.employee_id.timesheet_cost or 0.0
amount = -timesheet.unit_amount * cost
amount_converted = timesheet.employee_id.currency_id.compute(amount, timesheet.account_id.currency_id)
timesheet.write({
'amount': amount_converted,
'product_uom_id': uom.id,
})
# (re)compute the theorical revenue
if any([field_name in values for field_name in ['so_line', 'unit_amount', 'account_id']]):
sudo_self._timesheet_compute_theorical_revenue()
return values
@api.multi
def _timesheet_compute_theorical_revenue(self):
""" This method set the theorical revenue on the current timesheet lines.
If invoice on delivered quantity:
timesheet hours * (SO Line Price) * (1- discount),
elif invoice on ordered quantities & create task:
@@ -93,76 +77,49 @@ class AccountAnalyticLine(models.Model):
else:
0
"""
result = {}
if self.env.context.get('create'): # avoid bad loop
return result
for timesheet in self:
# find the timesheet UoM
timesheet_uom = timesheet.product_uom_id
if not timesheet_uom: # fallback on default company timesheet UoM
timesheet_uom = self.env.user.company_id.project_time_mode_id
# find the timesheet UoM
timesheet_uom = self.product_uom_id
if values.get('product_uom_id'):
timesheet_uom = self.env['product.uom'].browse(values['product_uom_id'])
if not timesheet_uom: # fallback on default company timesheet UoM
timesheet_uom = self.env.user.company_id.project_time_mode_id
# default values
unit_amount = timesheet.unit_amount
so_line = timesheet.so_line
values = {
'timesheet_revenue': 0.0,
'timesheet_invoice_type': 'non_billable_project' if not timesheet.task_id else 'non_billable',
}
# set the revenue and billable type according to the product and the SO line
if timesheet.task_id and so_line.product_id.type == 'service' and so_line.product_id.service_type == 'timesheet':
# find the analytic account to convert revenue into its currency
analytic_account = timesheet.account_id
# convert the unit of mesure into hours
sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom)
sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency
unit_amount = values.get('unit_amount', 0.0) or self.unit_amount
billable_type = 'non_billable'
revenue = 0.0
# set the revenue and billable type according to the product and the SO line
so_line_id = values.get('so_line') or self.so_line.id
so_line = self.env['sale.order.line'].browse(so_line_id).sudo() if so_line_id else self.env['sale.order.line'].browse().sudo()
if so_line.product_id.type == 'service':
# find the analytic account to convert revenue into its currency
account_id = values.get('account_id') or self.account_id.id
analytic_account = self.env['account.analytic.account'].browse(account_id)
# convert the unit of mesure into hours
sale_price_hour = so_line.product_uom._compute_price(so_line.price_unit, timesheet_uom)
sale_price = so_line.currency_id.compute(sale_price_hour, analytic_account.currency_id) # amount from SO should be convert into analytic account currency
# calculate the revenue on the timesheet
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':
quantity_hour = unit_amount
if so_line.product_uom.category_id == timesheet_uom.category_id:
# calculate the revenue on the timesheet
if so_line.product_id.invoice_policy == 'delivery':
values['timesheet_revenue'] = analytic_account.currency_id.round(unit_amount * sale_price * (1-(so_line.discount/100)))
values['timesheet_invoice_type'] = 'billable_time'
elif so_line.product_id.invoice_policy == 'order':
quantity_hour = so_line.product_uom._compute_quantity(so_line.product_uom_qty, timesheet_uom)
# compute the total revenue the SO since we are in fixed price
total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100)))
# compute the total revenue already existing (without the current timesheet line)
domain = [('so_line', '=', so_line.id)]
if self.ids:
domain += [('id', 'not in', self.ids)]
analytic_lines = self.sudo().search(domain)
total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue'))
# compute (new) revenue of current timesheet line
revenue = min(
analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-(so_line.discount/100))),
total_revenue_so - total_revenue_invoiced
)
billable_type = 'billable_fixed'
result['timesheet_revenue'] = revenue
result['timesheet_invoice_type'] = billable_type
return result
def _get_sale_order_line(self, vals=None):
result = dict(vals or {})
if self.project_id:
if result.get('so_line'):
sol = self.env['sale.order.line'].browse([result['so_line']])
else:
sol = self.so_line
if not sol:
sol = self.env['sale.order.line'].search([
('order_id.project_id', '=', self.account_id.id),
('state', 'in', ('sale', 'done')),
('product_id.track_service', '=', 'timesheet'),
('product_id.type', '=', 'service')],
limit=1)
if sol:
result.update({
'so_line': sol.id,
'product_id': sol.product_id.id,
})
result.update(self._get_timesheet_values(result))
return super(AccountAnalyticLine, self)._get_sale_order_line(vals=result)
# compute the total revenue the SO since we are in fixed price
total_revenue_so = analytic_account.currency_id.round(quantity_hour * sale_price * (1-(so_line.discount/100)))
# compute the total revenue already existing (without the current timesheet line)
domain = [('so_line', '=', so_line.id)]
if timesheet.ids:
domain += [('id', 'not in', timesheet.ids)]
analytic_lines = timesheet.search(domain)
total_revenue_invoiced = sum(analytic_lines.mapped('timesheet_revenue'))
# compute (new) revenue of current timesheet line
values['timesheet_revenue'] = min(
analytic_account.currency_id.round(unit_amount * so_line.currency_id.compute(so_line.price_unit, analytic_account.currency_id) * (1-so_line.discount)),
total_revenue_so - total_revenue_invoiced
)
values['timesheet_invoice_type'] = 'billable_fixed'
# if the so line is already invoiced, and the delivered qty is still smaller than the ordered, then link the timesheet to the invoice
if so_line.invoice_status == 'invoiced':
values['timesheet_invoice_id'] = so_line.invoice_lines[0].invoice_id.id
timesheet.write(values)
return True
@@ -59,7 +59,7 @@ class AccountInvoice(models.Model):
('project_id', '!=', False),
('timesheet_invoice_id', '=', False),
('timesheet_invoice_type', 'in', ['billable_time', 'billable_fixed'])
]).with_context(create=True) # context key required to avoid loop
])
# NOTE JEM : changing quantity (or unit price) of invoice line does not impact the revenue calculation. (FP specs)
if uninvoiced_timesheet_lines:
+36 -10
View File
@@ -7,17 +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
+14 -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()
@@ -40,7 +42,7 @@ class Project(models.Model):
@api.multi
def action_view_timesheet_plan(self):
return {
'name': _('Overview of %s') % self.name,
'name': _('Overview'),
'type': 'ir.actions.client',
'tag': 'timesheet.plan',
'context': {
@@ -54,7 +56,17 @@ class Project(models.Model):
class ProjectTask(models.Model):
_inherit = "project.task"
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Line')
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain=[('is_service', '=', True)])
@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
@api.multi
def unlink(self):
@@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, api, fields
class ResCompany(models.Model):
_inherit = 'res.company'
@api.model
def _get_default_uom_id(self):
return self.env.ref("product.product_uom_hour", raise_if_not_found=False)
project_time_mode_id = fields.Many2one('product.uom', 'Timesheet UoM', default=_get_default_uom_id)
+139 -60
View File
@@ -4,24 +4,27 @@
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
class SaleOrder(models.Model):
_inherit = 'sale.order'
timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale')
timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids')
timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user")
tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale')
tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids')
tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user")
project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale')
project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.")
@api.multi
@api.depends('project_id.line_ids')
@api.depends('analytic_account_id.line_ids')
def _compute_timesheet_ids(self):
for order in self:
if order.project_id:
if order.analytic_account_id:
order.timesheet_ids = self.env['account.analytic.line'].search(
[('so_line', 'in', order.order_line.ids),
('amount', '<=', 0.0),
@@ -38,35 +41,25 @@ class SaleOrder(models.Model):
order.tasks_count = len(order.tasks_ids)
@api.multi
@api.depends('project_id.project_ids')
@api.depends('analytic_account_id.project_ids')
def _compute_project_project_id(self):
for order in self:
order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.project_id.id)])
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):
""" On SO confirmation, some lines should generate a task or a project. """
result = super(SaleOrder, self).action_confirm()
for order in self:
if not order.project_project_id:
for line in order.order_line:
if line.product_id.track_service == 'timesheet':
if not order.project_id:
order._create_analytic_account(prefix=line.product_id.default_code or None)
order.project_id.project_create({'name': order.project_id.name})
break
order.order_line.filtered(lambda line: line._is_task())._create_task()
self.order_line._timesheet_service_generation()
return result
@api.multi
@@ -95,15 +88,25 @@ class SaleOrder(models.Model):
return result
@api.multi
def action_view_project_project(self):
def action_view_project_ids(self):
self.ensure_one()
action = self.env.ref('project.open_view_project_all').read()[0]
form_view_id = self.env.ref('project.edit_project').id
action['views'] = [(form_view_id, 'form')]
action['res_id'] = self.project_project_id.id
action.pop('target', None)
if len(self.project_ids) == 1:
if self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"):
action = self.project_ids.action_view_timesheet_plan()
else:
action = self.env.ref("project.act_project_project_2_project_task_all").read()[0]
action['context'] = safe_eval(action.get('context', '{}'), {'active_id': self.project_ids.id, 'active_ids': self.project_ids.ids})
else:
view_form_id = self.env.ref('project.edit_project').id
view_kanban_id = self.env.ref('project.view_project_kanban').id
action = {
'type': 'ir.actions.act_window',
'domain': [('id', 'in', self.project_ids.ids)],
'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')],
'view_mode': 'kanban,form',
'name': _('Projects'),
'res_model': 'project.project',
}
return action
@api.multi
@@ -132,24 +135,41 @@ class SaleOrder(models.Model):
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
task_id = fields.Many2one('project.task', 'Task')
task_id = fields.Many2one('project.task', 'Task', help="Task generated by the sales order item")
is_service = fields.Boolean("Is service", compute='_compute_is_service', store=True, 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'
@api.model
def create(self, values):
line = super(SaleOrderLine, self).create(values)
if line.state == 'sale' and not line.order_id.project_id and line.product_id.track_service in ['timesheet', 'task']:
line.order_id._create_analytic_account()
line._create_task()
if line.state == 'sale':
line._timesheet_service_generation()
return line
###########################################
### Analytic : auto recompute delivered quantity
###########################################
def _timesheet_compute_delivered_quantity_domain(self):
# TODO JEM: avoid increment delivered for all AAL or just timesheet ?
# see nim commit https://github.com/odoo/odoo/commit/21fbb9776a5fbd1838b189f1f7cf8c5d40663e14
so_line_ids = self.filtered(lambda sol: sol.product_id.service_type != 'manual').ids
return ['&', ('so_line', 'in', so_line_ids), ('project_id', '!=', False)]
@api.multi
def _compute_analytic(self, domain=None):
if not domain and self.ids:
# To filter on analyic lines linked to an expense
expense_type_id = self.env.ref('account.data_account_type_expenses', raise_if_not_found=False)
expense_type_id = expense_type_id and expense_type_id.id
domain = [('so_line', 'in', self.ids), '|', ('amount', '<=', 0.0), ('project_id', '!=', False)]
return super(SaleOrderLine, self)._compute_analytic(domain=domain)
def _analytic_compute_delivered_quantity_domain(self):
domain = super(SaleOrderLine, self)._analytic_compute_delivered_quantity_domain()
timesheet_domain = self._timesheet_compute_delivered_quantity_domain()
return expression.OR([domain, timesheet_domain])
###########################################
## Service : Project and task generation
###########################################
def _convert_qty_company_hours(self):
company_time_uom_id = self.env.user.company_id.project_time_mode_id
@@ -159,27 +179,35 @@ class SaleOrderLine(models.Model):
planned_hours = self.product_uom_qty
return planned_hours
def _get_project(self):
def _timesheet_find_project(self):
self.ensure_one()
Project = self.env['project.project']
project = self.product_id.with_context(force_company=self.company_id.id).project_id
if not project:
# find the project corresponding to the analytic account of the sales order
account = self.order_id.project_id
account = self.order_id.analytic_account_id
if not account:
self.order_id._create_analytic_account()
account = self.order_id.project_id
self.order_id._create_analytic_account(prefix=self.product_id.default_code or None)
account = self.order_id.analytic_account_id
project = Project.search([('analytic_account_id', '=', account.id)], limit=1)
if not project:
project_id = account.project_create({'name': account.name, 'use_tasks': True})
project = Project.browse(project_id)
project_name = '%s (%s)' % (account.name, self.sale_line_id.order_partner_id.ref) if self.sale_line_id.order_partner_id.ref else account.name
project_id = account.sudo().project_create({
'name': project_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 _prepare_service_task_values(self):
def _timesheet_create_task_prepare_values(self):
self.ensure_one()
project = self._get_project()
project = self._timesheet_find_project()
planned_hours = self._convert_qty_company_hours()
return {
'name': '%s:%s' % (self.order_id.name or '', self.product_id.name),
'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name),
'planned_hours': planned_hours,
'remaining_hours': planned_hours,
'partner_id': self.order_id.partner_id.id,
@@ -189,12 +217,63 @@ class SaleOrderLine(models.Model):
'company_id': self.company_id.id,
}
def _create_task(self):
for line in self:
task_values = line._prepare_service_task_values()
task = self.env['project.task'].create(task_values)
self.write({'task_id': task.id})
@api.multi
def _timesheet_create_task(self):
""" Generate task for the given so line, and link it.
def _is_task(self):
self.ensure_one()
return self.product_id.type == 'service' and self.product_id.track_service == 'task'
:return a mapping with the so line id and its linked task
:rtype dict
"""
result = {}
for so_line in self:
# create task
values = so_line._timesheet_create_task_prepare_values()
task = self.env['project.task'].sudo().create(values)
so_line.write({'task_id': task.id})
# post message on SO
msg_body = _("Task Created (%s): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (so_line.product_id.name, task.id, task.name)
so_line.order_id.message_post(body=msg_body)
# post message on task
task_msg = _("This task has been created from: <a href=# data-oe-model=sale.order data-oe-id=%d>%s</a> (%s)") % (so_line.order_id.id, so_line.order_id.name, so_line.product_id.name)
task.message_post(body=task_msg)
result[so_line.id] = task
return result
@api.multi
def _timesheet_find_task(self):
""" Find the task generated by the so lines. If no task linked, it will be
created automatically.
:return a mapping with the so line id and its linked task
:rtype dict
"""
# one search for all so lines
tasks = self.env['project.task'].search([('sale_line_id', 'in', self.ids)])
task_sol_mapping = {task.sale_line_id.id: task for task in tasks}
result = {}
for so_line in self:
# If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a new task.
task = task_sol_mapping.get(so_line.id)
# If not found, create one task for the so line
if not task:
task = so_line._timesheet_create_task()[so_line.id]
result[so_line.id] = task
return result
@api.multi
def _timesheet_service_generation(self):
""" For service lines, create the task or the project. If already exists, it simply links
the existing one to the line.
"""
for so_line in self.filtered(lambda sol: sol.is_service):
# create task
if so_line.product_id.service_tracking == 'task_global_project':
so_line._timesheet_find_task()
# 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()
@@ -18,6 +18,8 @@ var PlanAction = Widget.extend(ControlPanelMixin, {
events: {
"click a[type='action']": "_onClickAction",
"click .o_timesheet_plan_redirect": '_onRedirect',
"click .oe_stat_button": "_onClickStatButton",
"click .o_timesheet_plan_sale_timesheet_people_time .progress-bar": '_onClickEmployeeProgressbar',
},
init: function(parent, action, options) {
this._super.apply(this, arguments);
@@ -186,6 +188,31 @@ var PlanAction = Widget.extend(ControlPanelMixin, {
res_id: $target.data('oe-id'),
});
},
_onClickStatButton: function(event){
var self = this;
var data = $(event.currentTarget).data();
return this._rpc({
route:"/timesheet/plan/action",
params: {
domain: data['domain'],
res_model: data['resModel'],
},
}).then(function(action){
self.do_action(action);
});
},
_onClickEmployeeProgressbar: function(event){
var domain = $(event.currentTarget).data('domain');
this.do_action({
name: 'Timesheets',
type: 'ir.actions.act_window',
res_model: 'account.analytic.line',
views: [[false, 'list'], [false, 'form']],
view_type: 'list',
view_mode: 'form',
domain: domain,
});
},
_onSearch: function (search_event) {
var session = this.getSession();
// group by are disabled, so we don't take care of them
@@ -6,6 +6,12 @@
cursor: pointer;
}
.o_timesheet_plan_stat_buttons {
text-align: right;
float: none;
width: auto;
}
.o_timesheet_plan_sale_timesheet_dashboard {
.table {
@@ -30,20 +36,31 @@
.o_progress_billable_time {
background-color: #f0ad4e;
color: white;
cursor: pointer;
}
.o_progress_billable_fixed {
background-color: #5bc0de;
color: white;
cursor: pointer;
}
.o_progress_non_billable_project {
background-color: @brand-primary;
color: white;
cursor: pointer;
}
.o_progress_non_billable {
background-color: gray;
color: white;
cursor: pointer;
}
>.o_timesheet_plan_badge {
margin-bottom: 10px;
> .badge {
border: none;
a {
color: white;
}
}
}
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_sale_timesheet
from . import test_sale_service
from . import test_timesheet_revenue
+265
View File
@@ -0,0 +1,265 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import common
class CommonTest(common.SavepointCase):
@classmethod
def setUpClass(cls):
super(CommonTest, cls).setUpClass()
# Create accounts
cls.account_debit = cls.env['account.account'].create({
'code': 'X1012',
'name': 'Debtors - (test)',
'reconcile': True,
'user_type_id': cls.env.ref('account.data_account_type_receivable').id,
})
cls.account_credit = cls.env['account.account'].create({
'code': 'X1111',
'name': 'Creditors - (test)',
'reconcile': True,
'user_type_id': cls.env.ref('account.data_account_type_payable').id,
})
cls.account_sale = cls.env['account.account'].create({
'code': 'X2020',
'name': 'Product Sales - (test)',
'reconcile': True,
'user_type_id': cls.env.ref('account.data_account_type_revenue').id,
})
# Create project
cls.project_global = cls.env['project.project'].create({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
})
# Create service products
# -- ordered quantities (ordered, timesheet)
cls.product_order_timesheet1 = cls.env['product.product'].create({
'name': "Service Ordered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDERED1',
'service_type': 'timesheet',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_order_timesheet2 = cls.env['product.product'].create({
'name': "Service Ordered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDERED2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_order_timesheet3 = cls.env['product.product'].create({
'name': "Service Ordered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDERED3',
'service_type': 'timesheet',
'service_tracking': 'task_new_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_order_timesheet4 = cls.env['product.product'].create({
'name': "Service Ordered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# -- timesheet on tasks (delivered, timesheet)
cls.product_delivery_timesheet1 = cls.env['product.product'].create({
'name': "Service delivered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI1',
'service_type': 'timesheet',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_timesheet2 = cls.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_timesheet3 = cls.env['product.product'].create({
'name': "Service delivered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI3',
'service_type': 'timesheet',
'service_tracking': 'task_new_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_timesheet4 = cls.env['product.product'].create({
'name': "Service delivered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# -- milestons (delivered, manual)
cls.product_delivery_manual1 = cls.env['product.product'].create({
'name': "Service delivered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI1',
'service_type': 'manual',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual2 = cls.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI2',
'service_type': 'manual',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual3 = cls.env['product.product'].create({
'name': "Service delivered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI3',
'service_type': 'manual',
'service_tracking': 'task_new_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual4 = cls.env['product.product'].create({
'name': "Service delivered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('product.product_uom_hour').id,
'uom_po_id': cls.env.ref('product.product_uom_hour').id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# Create pricelists
cls.pricelist_usd = cls.env['product.pricelist'].create({
'name': 'USD pricelist',
'active': True,
'currency_id': cls.env.ref('base.USD').id,
'company_id': cls.env.user.company_id.id,
})
cls.pricelist_eur = cls.env['product.pricelist'].create({
'name': 'EUR pricelist',
'active': True,
'currency_id': cls.env.ref('base.EUR').id,
'company_id': cls.env.user.company_id.id,
})
# Create partners
cls.partner_usd = cls.env['res.partner'].create({
'name': 'Cool Partner in USD',
'email': 'partner.usd@test.com',
'property_product_pricelist': cls.pricelist_usd.id,
'property_account_payable_id': cls.account_credit.id,
'property_account_receivable_id': cls.account_debit.id,
})
cls.partner_eur = cls.env['res.partner'].create({
'name': 'Cool partner in EUR',
'email': 'partner.eur@test.com',
'property_product_pricelist': cls.pricelist_eur.id,
'property_account_payable_id': cls.account_credit.id,
'property_account_receivable_id': cls.account_debit.id,
})
# Create employees
cls.employee_user = cls.env['hr.employee'].create({
'name': 'Employee User',
'timesheet_cost': 15,
})
cls.employee_manager = cls.env['hr.employee'].create({
'name': 'Employee Manager',
'timesheet_cost': 45,
})
+133 -25
View File
@@ -1,43 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.test_sale_common import TestSale
from odoo.addons.sale_timesheet.tests.common import CommonTest
from odoo.exceptions import UserError
class TestSaleService(TestSale):
class TestSaleService(CommonTest):
""" This test suite provide checks for miscellaneous small things. """
def test_sale_service(self):
""" Test task creation when confirming a so with the corresponding product """
prod_task = self.env.ref('product.product_product_1')
so_vals = {
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
'order_line': [(0, 0, {'name': prod_task.name, 'product_id': prod_task.id, 'product_uom_qty': 50, 'product_uom': prod_task.uom_id.id, 'price_unit': prod_task.list_price})],
'pricelist_id': self.env.ref('product.list0').id,
""" Test task creation when confirming a sale_order with the corresponding product """
sale_order_vals = {
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'order_line': [(0, 0, {
'name': self.product_delivery_timesheet2.name,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'product_uom': self.product_delivery_timesheet2.uom_id.id,
'price_unit': self.product_delivery_timesheet2.list_price
}),
],
'pricelist_id': self.pricelist_usd.id,
}
so = self.env['sale.order'].create(so_vals)
so.action_confirm()
self.assertEqual(so.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
sale_order = self.env['sale.order'].create(sale_order_vals)
sale_order.action_confirm()
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
# check task creation
project = self.env.ref('sale_timesheet.project_GAP')
task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (so.name, prod_task.name))
project = self.project_global
task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (sale_order.name, self.product_delivery_timesheet2.name))
self.assertTrue(task, 'Sale Service: task is not created')
self.assertEqual(task.partner_id, so.partner_id, 'Sale Service: customer should be the same on task and on SO')
self.assertEqual(task.partner_id, sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO')
# register timesheet on task
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': project.id,
'task_id': task.id,
'unit_amount': 50,
'user_id': self.manager.id,
'employee_id': self.employee_manager.id,
})
self.assertEqual(so.invoice_status, 'to invoice', 'Sale Service: there should be something to invoice after registering timesheets')
so.action_invoice_create()
line = so.order_line
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Service: there should be sale_ordermething to invoice after registering timesheets')
sale_order.action_invoice_create()
line = sale_order.order_line
self.assertTrue(line.product_uom_qty == line.qty_delivered == line.qty_invoiced, 'Sale Service: line should be invoiced completely')
self.assertEqual(so.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
self.assertEqual(so.tasks_count, 1, "A task should have been created on SO confirmation.")
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Service: SO should be invoiced')
self.assertEqual(sale_order.tasks_count, 1, "A task should have been created on SO confirmation.")
# Add a line on the confirmed SO, and it should generate a new task directly
product_service_task = self.env['product.product'].create({
@@ -49,7 +58,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
})
@@ -59,7 +69,105 @@ class TestSaleService(TestSale):
'product_uom_qty': 10,
'product_uom': product_service_task.uom_id.id,
'price_unit': product_service_task.list_price,
'order_id': so.id,
'order_id': sale_order.id,
})
self.assertEqual(so.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
self.assertEqual(sale_order.tasks_count, 2, "Adding a new service line on a confirmer SO should create a new task.")
def test_timesheet_uom(self):
""" Test timesheet invoicing and uom conversion """
# create SO and confirm it
uom_days = self.env.ref('product.product_uom_day')
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'order_line': [
(0, 0, {
'name': self.product_delivery_timesheet3.name,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 5,
'product_uom': uom_days.id,
'price_unit': self.product_delivery_timesheet3.list_price
})
],
'pricelist_id': self.pricelist_usd.id,
})
sale_order.action_confirm()
task = self.env['project.task'].search([('sale_line_id', '=', sale_order.order_line.id)])
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id,
'task_id': task.id,
'unit_amount': 16,
'employee_id': self.employee_manager.id,
})
self.assertEqual(sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id,
'task_id': task.id,
'unit_amount': 24,
'employee_id': self.employee_user.id,
})
sale_order.action_invoice_create()
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
def test_task_so_line_assignation(self):
# create SO and confirm it
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
so_line_deliver_global_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet2.name,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 10,
'product_uom': self.product_delivery_timesheet2.uom_id.id,
'price_unit': self.product_delivery_timesheet2.list_price,
'order_id': sale_order.id,
})
so_line_deliver_global_project.product_id_change()
sale_order.action_confirm()
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
# let's log some timesheets (on the project created by so_line_ordered_project_only)
timesheets = self.env['account.analytic.line']
timesheets |= self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 4,
'employee_id': self.employee_user.id,
})
timesheets |= self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 1,
'employee_id': self.employee_manager.id,
})
self.assertTrue(all([billing_type == 'billable_time' for billing_type in timesheets.mapped('timesheet_invoice_type')]), "All timesheets linked to the task should be on 'billable time'")
self.assertEqual(so_line_deliver_global_project.qty_to_invoice, 5, "Quantity to invoice should have been increased when logging timesheet on delivered quantities task")
# 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")
# invoice SO, and validate invoice
invoice_id = sale_order.action_invoice_create()[0]
invoice = self.env['account.invoice'].browse(invoice_id)
invoice.action_invoice_open()
# try to update timesheets, catch error 'You cannot modify invoiced timesheet'
with self.assertRaises(UserError):
task_serv2.write({'sale_line_id': False})
@@ -1,140 +1,271 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tools import float_is_zero
from odoo.tools import float_is_zero, float_compare
from odoo.addons.sale.tests.test_sale_common import TestSale
from odoo.addons.sale_timesheet.tests.common import CommonTest
class TestSaleTimesheet(TestSale):
def setUp(self):
super(TestSaleTimesheet, self).setUp()
# link hr employee to res.users of sale tests
self.employee_user = self.env['hr.employee'].create({
'name': 'Employee User',
'user_id': self.user.id,
})
self.employee_manager = self.env['hr.employee'].create({
'name': 'Employee Manager',
'user_id': self.manager.id,
})
class TestSaleTimesheet(CommonTest):
""" This test suite provide tests for the 3 main flows of selling services:
- Selling services based on ordered quantities
- Selling timesheet based on delivered quantities
- Selling milestones, based on manual delivered quantities
For that, we check the task/project created, the invoiced amounts, the delivered
quantities changes, ...
"""
def test_timesheet_order(self):
""" Test timesheet invoicing with 'invoice on order' timetracked products """
# intial so
prod_ts = self.env.ref('product.service_order_01')
so_vals = {
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})],
'pricelist_id': self.env.ref('product.list0').id,
}
so = self.env['sale.order'].create(so_vals)
so.action_confirm()
so.action_invoice_create()
""" Test timesheet invoicing with 'invoice on order' timetracked products
1. create SO with 2 ordered product and confirm
2. create invoice
3. log timesheet
4. add new SO line (ordered service)
5. create new invoice
"""
# create SO and confirm it
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
so_line_ordered_project_only = self.env['sale.order.line'].create({
'name': self.product_order_timesheet4.name,
'product_id': self.product_order_timesheet4.id,
'product_uom_qty': 10,
'product_uom': self.product_order_timesheet4.uom_id.id,
'price_unit': self.product_order_timesheet4.list_price,
'order_id': sale_order.id,
})
so_line_ordered_global_project = self.env['sale.order.line'].create({
'name': self.product_order_timesheet2.name,
'product_id': self.product_order_timesheet2.id,
'product_uom_qty': 50,
'product_uom': self.product_order_timesheet2.uom_id.id,
'price_unit': self.product_order_timesheet2.list_price,
'order_id': sale_order.id,
})
so_line_ordered_project_only.product_id_change()
so_line_ordered_global_project.product_id_change()
sale_order.action_confirm()
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_global_project.id)])
# let's log some timesheets
self.assertEqual(sale_order.tasks_count, 1, "One task should have been created on SO confirmation")
self.assertTrue(sale_order.project_project_id, "A project should have been created by the SO, when confirmed.")
# create invoice
sale_order.action_invoice_create()
# let's log some timesheets (on the project created by so_line_ordered_project_only)
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'project_id': sale_order.project_project_id.id,
'task_id': task_serv2.id,
'unit_amount': 10.5,
'employee_id': self.employee_user.id,
})
self.assertEqual(so.order_line.qty_delivered, 10.5, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.assertEqual(so_line_ordered_global_project.qty_delivered, 10.5, 'Timesheet directly on project does not increase delivered quantity on so line')
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'project_id': sale_order.project_project_id.id,
'task_id': task_serv2.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
self.assertEqual(so.order_line.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.assertEqual(so_line_ordered_global_project.qty_delivered, 50, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
self.assertEqual(sale_order.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'project_id': sale_order.project_project_id.id,
'unit_amount': 10,
'employee_id': self.employee_user.id,
})
self.assertEqual(so.order_line.qty_delivered, 60, 'Sale Timesheet: timesheet does not increase delivered quantity on so line')
self.assertEqual(so.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
self.assertEqual(so_line_ordered_project_only.qty_delivered, 0.0, 'Timesheet directly on project does not increase delivered quantity on so line')
# log timesheet on task in global project (higher than the initial ordrered qty)
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id,
'task_id': task_serv2.id,
'unit_amount': 5,
'employee_id': self.employee_user.id,
})
self.assertEqual(sale_order.invoice_status, 'upselling', 'Sale Timesheet: "invoice on order" timesheets should not modify the invoice_status of the so')
# add so line with produdct "create task in new project". (project will be the one from SO)
so_line_ordered_task_new_project = self.env['sale.order.line'].create({
'name': self.product_order_timesheet3.name,
'product_id': self.product_order_timesheet3.id,
'product_uom_qty': 3,
'product_uom': self.product_order_timesheet3.uom_id.id,
'price_unit': self.product_order_timesheet3.list_price,
'order_id': sale_order.id,
})
task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_ordered_task_new_project.id)])
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: Adding a new service line (so line) should put the SO in "to invocie" state.')
self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
# create invoice
invoice_id = sale_order.action_invoice_create()
invoice = self.env['account.invoice'].browse(invoice_id)
self.assertEqual(len(sale_order.invoice_ids), 2, "A second invoice should have been created from the SO")
self.assertTrue(float_is_zero(invoice.amount_total - so_line_ordered_task_new_project.price_unit * 3, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "When creating task in new project, the task should be in SO project (if already exists), otherwise it created one.")
def test_timesheet_delivery(self):
""" Test timesheet invoicing with 'invoice on delivery' timetracked products """
inv_obj = self.env['account.invoice']
# intial so
prod_ts = self.env.ref('product.product_product_2')
so_vals = {
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 50, 'product_uom': prod_ts.uom_id.id, 'price_unit': prod_ts.list_price})],
'pricelist_id': self.env.ref('product.list0').id,
}
so = self.env['sale.order'].create(so_vals)
so.action_confirm()
self.assertEqual(so.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
""" Test timesheet invoicing with 'invoice on delivery' timetracked products
1. Create SO and confirm it
2. log timesheet
3. create invoice
4. log other timesheet
5. create a second invoice
6. add new SO line (delivered service)
"""
# create SO and confirm it
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
so_line_deliver_global_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet2.name,
'product_id': self.product_delivery_timesheet2.id,
'product_uom_qty': 50,
'product_uom': self.product_delivery_timesheet2.uom_id.id,
'price_unit': self.product_delivery_timesheet2.list_price,
'order_id': sale_order.id,
})
so_line_deliver_task_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet3.name,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 20,
'product_uom': self.product_delivery_timesheet3.uom_id.id,
'price_unit': self.product_delivery_timesheet3.list_price,
'order_id': sale_order.id,
})
so_line_deliver_global_project.product_id_change()
so_line_deliver_task_project.product_id_change()
# confirm SO
sale_order.action_confirm()
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
task_serv3 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
self.assertEqual(task_serv2.project_id, self.project_global, "Sale Timesheet: task should be created in global project")
self.assertTrue(task_serv2, "Sale Timesheet: on SO confirmation, a task should have been created in global project")
self.assertTrue(task_serv3, "Sale Timesheet: on SO confirmation, a task should have been created in a new project")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
self.assertEqual(sale_order.analytic_account_id, task_serv3.project_id.analytic_account_id, "SO should have create a project")
self.assertEqual(sale_order.tasks_count, 2, "Two tasks (1 per SO line) should have been created on SO confirmation")
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'project_id': task_serv2.project_id.id, # global project
'task_id': task_serv2.id,
'unit_amount': 10.5,
'employee_id': self.employee_manager.id,
})
self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged')
inv_id = so.action_invoice_create()
inv = inv_obj.browse(inv_id)
self.assertTrue(float_is_zero(inv.amount_total - so.order_line.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line')
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so in "to invoice" status when logged')
# invoice SO
invoice_id = sale_order.action_invoice_create()
invoice = self.env['account.invoice'].browse(invoice_id)
self.assertTrue(float_is_zero(invoice.amount_total - so_line_deliver_global_project.price_unit * 10.5, precision_digits=2), 'Sale: invoice generation on timesheets product is wrong')
# log some timesheets again
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'project_id': task_serv2.project_id.id, # global project
'task_id': task_serv2.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
so.action_invoice_create()
self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
self.assertEqual(so_line_deliver_global_project.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
self.assertEqual(so_line_deliver_task_project.invoice_status, 'no', 'Sale Timesheet: so line invoice status should not change when no timesheet linked to the line')
self.assertEqual(sale_order.invoice_status, 'to invoice', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
# create a second invoice
sale_order.action_invoice_create()
self.assertEqual(so_line_deliver_global_project.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should set the so line in "to invoice" status when logged')
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
# add a line on SO
so_line_deliver_only_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet4.name,
'product_id': self.product_delivery_timesheet4.id,
'product_uom_qty': 5,
'product_uom': self.product_delivery_timesheet4.uom_id.id,
'price_unit': self.product_delivery_timesheet4.list_price,
'order_id': sale_order.id,
})
self.assertEqual(sale_order.project_project_id, task_serv3.project_id, "SO should not have create a second project, since so line 3 already create one project for the SO")
# let's log some timesheets on the project
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'unit_amount': 10,
'project_id': sale_order.project_project_id.id, # global project
'unit_amount': 7,
'employee_id': self.employee_user.id,
})
self.assertEqual(so.invoice_status, 'to invoice', 'Sale Timesheet: supplementary timesheets do not change the status of the SO')
# import pdb;pdb.set_trace()
self.assertTrue(float_is_zero(so_line_deliver_only_project.qty_delivered, precision_digits=2), "Timesheeting on project should not incremented the delivered quantity on the SO line")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" timesheets should be invoiced completely by now')
def test_timesheet_uom(self):
""" Test timesheet invoicing and uom conversion """
# intial so
prod_ts = self.env.ref('product.product_product_2')
uom_days = self.env.ref('product.product_uom_day')
so_vals = {
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
'order_line': [(0, 0, {'name': prod_ts.name, 'product_id': prod_ts.id, 'product_uom_qty': 5, 'product_uom': uom_days.id, 'price_unit': prod_ts.list_price})],
'pricelist_id': self.env.ref('product.list0').id,
}
so = self.env['sale.order'].create(so_vals)
so.action_confirm()
# let's log some timesheets
def test_timesheet_manual(self):
""" Test timesheet invoicing with 'invoice on delivery' timetracked products
"""
# create SO and confirm it
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_usd.id,
'partner_invoice_id': self.partner_usd.id,
'partner_shipping_id': self.partner_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
so_line_manual_global_project = self.env['sale.order.line'].create({
'name': self.product_delivery_manual2.name,
'product_id': self.product_delivery_manual2.id,
'product_uom_qty': 50,
'product_uom': self.product_delivery_manual2.uom_id.id,
'price_unit': self.product_delivery_manual2.list_price,
'order_id': sale_order.id,
})
so_line_manual_only_project = self.env['sale.order.line'].create({
'name': self.product_delivery_manual4.name,
'product_id': self.product_delivery_manual4.id,
'product_uom_qty': 20,
'product_uom': self.product_delivery_manual4.uom_id.id,
'price_unit': self.product_delivery_manual4.list_price,
'order_id': sale_order.id,
})
# confirm SO
sale_order.action_confirm()
self.assertTrue(sale_order.project_project_id, "Sales Order should have create a project")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: manually product should not need to be invoiced on so confirmation')
# let's log some timesheets (on task and project)
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'unit_amount': 16,
'project_id': self.project_global.id, # global project
'task_id': so_line_manual_global_project.task_id.id,
'unit_amount': 6,
'employee_id': self.employee_manager.id,
})
self.assertEqual(so.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': so.project_project_id.id,
'unit_amount': 24,
'employee_id': self.employee_user.id,
'project_id': sale_order.project_project_id.id, # global project
'unit_amount': 3,
'employee_id': self.employee_manager.id,
})
so.action_invoice_create()
self.assertEqual(so.invoice_status, 'invoiced', 'Sale Timesheet: "invoice on delivery" timesheets should not modify the invoice_status of the so')
self.assertEqual(so_line_manual_global_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created task.")
self.assertEqual(so_line_manual_only_project.qty_to_invoice, 0.0, "Manual service should not be affected by timesheet on their created project.")
self.assertEqual(sale_order.invoice_status, 'no', 'Sale Timesheet: "invoice on delivery" should not need to be invoiced on so confirmation')
@@ -33,8 +33,9 @@ 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',
'project_id': self.project.id
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': self.project.id,
})
self.product_order = self.env['product.product'].create({
@@ -46,8 +47,9 @@ 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',
'project_id': self.project.id
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
'project_id': self.project.id,
})
# pricelists
@@ -63,7 +65,6 @@ class TestSaleTimesheet(TestSale):
'currency_id': self.env.ref('base.EUR').id,
'company_id': self.env.user.company_id.id,
})
# partners
self.partner_usd = self.env['res.partner'].create({
'name': 'Cool Partner in USD',
@@ -15,25 +15,40 @@
<div class="o_timesheet_plan_sale_timesheet">
<div class="o_timesheet_plan_sale_timesheet_dashboard">
<h3>Overview</h3>
<div class="o_timesheet_plan_stat_buttons oe_button_box">
<t t-foreach="stat_buttons" t-as="stat_button">
<button class="btn btn-sm oe_stat_button" t-att-data-domain="stat_button['domain']" t-att-data-res-model="stat_button['res_model']">
<div t-attf-class="fa fa-fw o_button_icon #{stat_button['icon']}"></div>
<div name="task_count" 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>
<span class="o_stat_text">
<t t-esc="stat_button['name']"/>
</span>
</div>
</button>
</t>
</div>
<table class="table">
<tbody>
<tr>
<th rowspan="4">
<th rowspan="5">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_groupby': ['date:month'],'pivot_column_groupby': ['timesheet_invoice_type'], 'pivot_measures': ['unit_amount']}">Hours</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['billable_time']"/> h
<t t-esc="float_to_time(dashboard['hours']['billable_time'])"/>
</td>
<td>Time and material</td>
<th rowspan="4">
<th rowspan="5">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_groupby': ['date:month', 'employee_id'], 'pivot_column_groupby': ['timesheet_invoice_type'],'pivot_measures': ['unit_amount']}">Rates</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['billable_time']"/> %
</td>
<td>Time and material</td>
<th rowspan="4">
<th rowspan="5">
<a type="action" t-att-domain="json.dumps(domain)" context="{'pivot_row_groupby': ['timesheet_invoice_id'], 'pivot_column_groupby': ['date:month'],'pivot_measures': ['amount', 'timesheet_revenue']}">Profitability</a>
</th>
<td class="o_timesheet_plan_dashboard_cell">
@@ -45,7 +60,7 @@
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['billable_fixed']"/> h
<t t-esc="float_to_time(dashboard['hours']['billable_fixed'])"/>
</td>
<td>Fixed</td>
<td class="o_timesheet_plan_dashboard_cell">
@@ -61,13 +76,13 @@
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['non_billable']"/> h
<t t-esc="float_to_time(dashboard['hours']['non_billable_project'])"/>
</td>
<td>Non Billable</td>
<td>No task found</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['non_billable']"/> %
<t t-esc="dashboard['rates']['non_billable_project']"/> %
</td>
<td>Non Billable</td>
<td>No task found</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-if="currency.position == 'before'" t-esc="currency.symbol"/>
<t t-esc="dashboard['money_amount']['cost']"/>
@@ -75,9 +90,21 @@
</td>
<td>Cost</td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="float_to_time(dashboard['hours']['non_billable'])"/>
</td>
<td>Non Billable</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['rates']['non_billable']"/> %
</td>
<td>Non Billable</td>
<td class="o_timesheet_plan_dashboard_cell"></td>
<td></td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_total">
<t t-esc="dashboard['hours']['total']"/> h
<t t-esc="float_to_time(dashboard['hours']['total'])"/>
</td>
<td><b>Total</b></td>
<td class="o_timesheet_plan_dashboard_total">
@@ -102,11 +129,20 @@
</t>
<t t-if="repartition_employee">
<div class="pull-right o_timesheet_plan_badge">
<span class="badge o_progress_billable_fixed">Billable fixed</span>
<span class="badge o_progress_billable_time">Billable time</span>
<span class="badge o_progress_non_billable">Non billable</span>
<span class="badge o_progress_billable_fixed">
<a type="action" t-att-domain="json.dumps(domain + [('timesheet_invoice_type','=','billable_fixed')])">Billable fixed</a>
</span>
<span class="badge o_progress_billable_time">
<a type="action" t-att-domain="json.dumps(domain + [('timesheet_invoice_type','=','billable_time')])">Billable time</a>
</span>
<span class="badge o_progress_non_billable_project">
<a type="action" t-att-domain="json.dumps(domain + [('timesheet_invoice_type','=','non_billable_project')])">No task found</a>
</span>
<span class="badge o_progress_non_billable">
<a type="action" t-att-domain="json.dumps(domain + [('timesheet_invoice_type','=','non_billable')])">Non billable</a>
</span>
</div>
<table class="table ">
<table class="table">
<tbody>
<t t-foreach="repartition_employee" t-as="employee_id">
<tr>
@@ -116,15 +152,17 @@
</a>
</td>
<td style="width: 10%">
<t t-esc="repartition_employee[employee_id]['total']"/> hours
<t t-esc="float_to_time(repartition_employee[employee_id]['total'])"/>
</td>
<td>
<div class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
<div t-if="repartition_employee[employee_id]['billable_fixed']" class="progress-bar o_progress_billable_fixed" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_fixed'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_fixed']) + ' hours'">
<div t-if="repartition_employee[employee_id]['billable_fixed']" class="progress-bar o_progress_billable_fixed" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_fixed'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="float_to_time(repartition_employee[employee_id]['billable_fixed'])" t-att-data-domain="repartition_employee[employee_id]['__domain_billable_fixed']">
</div>
<div t-if="repartition_employee[employee_id]['billable_time']" class="progress-bar o_progress_billable_time" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_time'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['billable_time']) + ' hours'">
<div t-if="repartition_employee[employee_id]['billable_time']" class="progress-bar o_progress_billable_time" t-att-style="'width: ' + str(repartition_employee[employee_id]['billable_time'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="float_to_time(repartition_employee[employee_id]['billable_time'])" t-att-data-domain="repartition_employee[employee_id]['__domain_billable_time']">
</div>
<div t-if="repartition_employee[employee_id]['non_billable']" class="progress-bar o_progress_non_billable" t-att-style="'width: ' + str(repartition_employee[employee_id]['non_billable'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="str(repartition_employee[employee_id]['non_billable']) + ' hours'">
<div t-if="repartition_employee[employee_id]['non_billable_project']" class="progress-bar o_progress_non_billable_project" t-att-style="'width: ' + str(repartition_employee[employee_id]['non_billable_project'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="float_to_time(repartition_employee[employee_id]['non_billable_project'])" t-att-data-domain="repartition_employee[employee_id]['__domain_non_billable_project']">
</div>
<div t-if="repartition_employee[employee_id]['non_billable']" class="progress-bar o_progress_non_billable" t-att-style="'width: ' + str(repartition_employee[employee_id]['non_billable'] / repartition_employee[employee_id]['total'] * 100) + '%'" t-att-title="float_to_time(repartition_employee[employee_id]['non_billable'])" t-att-data-domain="json.dumps(repartition_employee[employee_id]['__domain_non_billable'])">
</div>
</div>
</td>
@@ -17,11 +17,23 @@
</field>
</record>
<record id="timesheet_view_pivot_revenue" model="ir.ui.view">
<field name="name">account.analytic.line.pivot.revenue</field>
<field name="model">account.analytic.line</field>
<field name="arch" type="xml">
<pivot string="Timesheet">
<field name="employee_id" type="row"/>
<field name="date" interval="month" type="col"/>
<field name="timesheet_revenue" type="measure"/>
</pivot>
</field>
</record>
<!--
Reporting
-->
<record id="timesheet_action_billing_report" model="ir.actions.act_window">
<field name="name">Billing Rate Analysis</field>
<field name="name">By Billing Rate</field>
<field name="res_model">account.analytic.line</field>
<field name="view_mode">pivot,graph</field>
<field name="domain">[('project_id', '!=', False)]</field>
@@ -44,9 +56,48 @@
</record>
<menuitem id="menu_timesheet_billing_analysis"
parent="hr_timesheet.menu_timesheets_reports"
parent="hr_timesheet.menu_timesheets_reports_timesheet"
action="timesheet_action_billing_report"
name="Billing Rate Analysis"/>
name="By Billing Rate"
sequence="40"/>
<record id="timesheet_action_report_cost_revenue" model="ir.actions.act_window">
<field name="name">Costs and Revenues</field>
<field name="res_model">account.analytic.line</field>
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_line_search"/>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="context">{
'search_default_groupby_project':1,
'search_default_groupby_employee':2,
}</field>
</record>
<record id="timesheet_action_report_cost_revenue_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="timesheet_view_pivot_revenue"/>
<field name="act_window_id" ref="timesheet_action_report_cost_revenue"/>
</record>
<record id="timesheet_action_report_cost_revenue_tree" model="ir.actions.act_window.view">
<field name="sequence" eval="10"/>
<field name="view_mode">tree</field>
<field name="view_id" ref="hr_timesheet.timesheet_view_tree_user"/>
<field name="act_window_id" ref="timesheet_action_report_cost_revenue"/>
</record>
<record id="timesheet_action_report_cost_revenue_form" model="ir.actions.act_window.view">
<field name="sequence" eval="15"/>
<field name="view_mode">form</field>
<field name="view_id" ref="hr_timesheet.timesheet_view_form_user"/>
<field name="act_window_id" ref="timesheet_action_report_cost_revenue"/>
</record>
<menuitem id="menu_timesheet_report_cost_revenue"
parent="hr_timesheet.menu_timesheets_reports"
action="timesheet_action_report_cost_revenue"
name="Costs and Revenues"
sequence="50"/>
<!--
Plan
+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>
@@ -7,8 +7,10 @@
<field name="inherit_id" ref="hr_timesheet.view_project_kanban_inherited"/>
<field name="arch" type="xml">
<xpath expr="//a[@t-if='record.allow_timesheets.raw_value']" position="replace">
<a t-if="record.allow_timesheets.raw_value" name="action_view_timesheet" type="object">
<span class="o_label">Overview</span>
<a t-if="record.allow_timesheets.raw_value" name="action_view_timesheet" type="object" class="o_project_kanban_box" groups="hr_timesheet.group_hr_timesheet_user">
<div>
<span class="o_label">Overview</span>
</div>
</a>
</xpath>
</field>
@@ -26,8 +28,8 @@
attrs="{'invisible': [('sale_line_id', '=', False)]}"
string="Sales Order"/>
</xpath>
<field name="company_id" position="before">
<field name="sale_line_id" string="Order Line" invisible="1"/>
<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>
</field>
</record>
@@ -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>
@@ -11,27 +11,29 @@
name="action_view_timesheet"
class="oe_stat_button"
icon="fa-clock-o"
attrs="{'invisible': [('timesheet_count', '=', 0)]}" groups="base.group_user">
attrs="{'invisible': [('timesheet_count', '=', 0)]}"
groups="hr_timesheet.group_hr_timesheet_user">
<field name="timesheet_count" widget="statinfo" string="Timesheets"/>
</button>
</xpath>
<xpath expr="//button[@name='action_view_invoice']" position="before">
<field name="project_project_id" invisible="1"/>
<button type="object"
name="action_view_project_project"
name="action_view_project_ids"
class="oe_stat_button"
icon="fa-puzzle-piece"
string="Project"
attrs="{'invisible': [('project_project_id', '=', False)]}" groups="base.group_user">
string="Project(s) Overview"
attrs="{'invisible': [('project_ids', '=', [])]}"
groups="project.group_project_user">
<field name="project_ids" invisible="1"/>
</button>
</xpath>
<xpath expr="//button[@name='action_view_invoice']" position="before">
<field name="tasks_ids" invisible="1"/>
<button type="object"
name="action_view_task"
class="oe_stat_button"
icon="fa-tasks"
attrs="{'invisible': [('tasks_count', '=', 0)]}" groups="base.group_user">
attrs="{'invisible': [('tasks_count', '=', 0)]}"
groups="project.group_project_user">
<field name="tasks_count" widget="statinfo" string="Tasks"/>
</button>
</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',
}, {