[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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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})
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
}, {
|
||||
|
||||
Reference in New Issue
Block a user