[MERGE] *_timesheet: selling services, phase 3

Impacted modules: hr_timesheet, sale_timesheet and project.

This merge commit provides feature for service flow (selling
timesheet). The most important points are

1/ selling project based on template: on product configuration,
user can now set a template for the Sales Order to create a
project by duplicating this ones (stages, tasks, ... are duplicated
from the template). If several Sale Lines are based on the same
template, only one will be created. Same mecanism if multiple product
creating a project without template: only one project per SO.

2/ Project Overview Revamp: before this, it was based on a filter
domain on analytic line (aka timesheet). This causes many problem
as if you are selling prepaid services, you project plan was empty
as long as no timesheet was created. Moreover, this kind of domain
is not very usefull for the user (nobody want to see project plan of
timesheet containing "analysis" for instance). The project plan is
now a filter on project, to ease fetching data from the project (even
if no timesheet or forecast), and allow cross project analysis.
The overview table part is now available even when forecast is not
installed.

3/ Assign Tasks Massively: a wizard is provided to ease linking
task to the same Sale Order Line, to ease billing their related
timesheets.

4/ Usability Details: filter task per SO or SO line, stat button
on Sale Order now redirect to kanban project instead of global
project plan, generated task/project names are cleaner, ...

5/ Installing only timesheet module, user will be able to track
cost without selling time. Fields of cost and employee hourly cost
are moved from sale_timesheet to hr_timesheet.

To make this feature works, some technical efforts was required:

1/ Project and Analytic Account: To allow SO to generate multiple
project, we wanted them to share the same analytic account (the one
of the Sale Order). To do so, we had to remove the inheritS between
project.project and account.analytic.account models.
To allow timesheet on a project, an analytic need to be set manually,
or it will be automatically generated (like before with the inheritS),
if allow_timesheet is checked.

2/ Refactoring of SOL service generation: to allow SOL to generate
only one project (per template) for the SO, the part of service creation
on sale.order.line needed a refactoring.

3/ Revamping the overview also improve its performances: some part
are now based on SQL queries (table part), or on report (profitability
report). Doing a report allow the user to create its own filters, and
give him a more accurate tools to extract statistics then project plan.
The idea is to get only one source of information for reporting and
analysis.

Please see sub commits for more details.

Thanks for @tde-banana-odoo for his review and advices during
development,
and thanks to @bst-odoo for his functionnal ideas, testing and support.

Tasks #34702
Closes #22268
This commit is contained in:
jem-odoo
2018-04-06 15:38:48 +02:00
50 changed files with 1323 additions and 433 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ It is completely integrated with the cost accounting module. It allows you to se
up a management by affair.
""",
'website': 'https://www.odoo.com/page/employees',
'depends': ['hr', 'project'],
'depends': ['hr', 'analytic', 'project'],
'data': [
'security/hr_timesheet_security.xml',
'security/ir.model.access.csv',
+16 -2
View File
@@ -1,6 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="account_analytic_account_project_2" model="account.analytic.account">
<field name="name">Research &amp; Development Project</field>
<field name="code">RD</field>
<field name="active" eval="True"/>
</record>
<record id="project.project_project_2" model="project.project">
<field name="analytic_account_id" ref="account_analytic_account_project_2"/>
</record>
<record id="project.project_project_4" model="project.project">
<field name="analytic_account_id" ref="analytic.analytic_deltapc"/>
</record>
<record id="working_hours_requirements" model="account.analytic.line">
<field name="name">Requirements analysis and specification</field>
<field name="user_id" ref='base.user_root'/>
@@ -35,7 +49,7 @@
<field name="unit_amount">1.00</field>
<field name="project_id" ref='project.project_project_2'/>
<field name="amount">-30.00</field>
</record>
</record>
<record id="working_hours_maintenance" model="account.analytic.line">
<field name="name">Delivery and maintenance</field>
@@ -44,6 +58,6 @@
<field name="unit_amount">1.00</field>
<field name="project_id" ref='project.project_project_2'/>
<field name="amount">-30.00</field>
</record>
</record>
</odoo>
+2
View File
@@ -1,6 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic_account
from . import hr
from . import hr_timesheet
from . import res_config_settings
from . import project
@@ -13,9 +13,12 @@ class AccountAnalyticAccount(models.Model):
project_ids = fields.One2many('project.project', 'analytic_account_id', string='Projects')
project_count = fields.Integer("Project Count", compute='_compute_project_count')
@api.multi
def _compute_project_count(self):
project_data = self.env['project.project'].read_group([('analytic_account_id', 'in', self.ids)], ['analytic_account_id'], ['analytic_account_id'])
mapping = {m['analytic_account_id'][0]: m['analytic_account_id_count'] for m in project_data}
for account in self:
account.project_count = len(account.with_context(active_test=False).project_ids)
account.project_count = mapping.get(account.id, 0)
@api.multi
def unlink(self):
@@ -25,18 +28,9 @@ class AccountAnalyticAccount(models.Model):
raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.'))
return super(AccountAnalyticAccount, self).unlink()
@api.model
def name_search(self, name, args=None, operator='ilike', limit=100):
if args is None:
args = []
if self.env.context.get('current_model') == 'project.project':
return self.search(args + [('name', operator, name)], limit=limit).name_get()
return super(AccountAnalyticAccount, self).name_search(name, args=args, operator=operator, limit=limit)
@api.multi
def projects_action(self):
projects = self.with_context(active_test=False).mapped('project_ids')
def action_view_projects(self):
projects = self.mapped('project_ids')
result = {
"type": "ir.actions.act_window",
"res_model": "project.project",
@@ -7,6 +7,5 @@ from odoo import fields, models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
# FIXME: this field should be in module hr_timesheet, not sale_timesheet
timesheet_cost = fields.Monetary('Timesheet Cost', currency_field='currency_id', default=0.0)
currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True)
+47 -7
View File
@@ -1,7 +1,8 @@
# -*- 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 UserError
class AccountAnalyticLine(models.Model):
@@ -40,14 +41,20 @@ class AccountAnalyticLine(models.Model):
line.department_id = line.employee_id.department_id
@api.model
def create(self, vals):
vals = self._timesheet_preprocess(vals)
return super(AccountAnalyticLine, self).create(vals)
def create(self, values):
values = self._timesheet_preprocess(values)
result = super(AccountAnalyticLine, self).create(values)
if result.project_id: # applied only for timesheet
result._timesheet_postprocess(values)
return result
@api.multi
def write(self, vals):
vals = self._timesheet_preprocess(vals)
return super(AccountAnalyticLine, self).write(vals)
def write(self, values):
values = self._timesheet_preprocess(values)
result = super(AccountAnalyticLine, self).write(values)
# applied only for timesheet
self.filtered(lambda t: t.project_id)._timesheet_postprocess(values)
return result
def _timesheet_preprocess(self, vals):
""" Deduce other field values from the one given.
@@ -58,6 +65,8 @@ class AccountAnalyticLine(models.Model):
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 not project.analytic_account_id.active:
raise UserError(_('The project you are timesheeting on is not linked to a active analytic account. Please the project configuration.'))
# employee implies user
if vals.get('employee_id') and not vals.get('user_id'):
employee = self.env['hr.employee'].browse(vals['employee_id'])
@@ -79,3 +88,34 @@ class AccountAnalyticLine(models.Model):
if partner_id:
vals['partner_id'] = partner_id
return vals
@api.multi
def _timesheet_postprocess(self, values):
""" Hook to update record one by one according to the values of a `write` or a `create`. """
sudo_self = self.sudo() # this creates only one env for all operation that required sudo() in `_timesheet_postprocess_values`override
values_to_write = self._timesheet_postprocess_values(values)
for timesheet in sudo_self:
if values_to_write[timesheet.id]:
timesheet.write(values_to_write[timesheet.id])
return values
@api.multi
def _timesheet_postprocess_values(self, values):
""" Get the addionnal values to write on record
:param dict values: values for the model's fields, as a dictionary::
{'field_name': field_value, ...}
:return: a dictionary mapping each record id to its corresponding
dictionnary values to write (may be empty).
"""
result = dict.fromkeys(self.ids, dict())
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:
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)
result[timesheet.id].update({
'amount': amount_converted,
})
return result
+89 -8
View File
@@ -1,31 +1,100 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields, api
from odoo.exceptions import UserError
from odoo.tools.translate import _
from odoo import models, fields, api, _
from odoo.exceptions import UserError, ValidationError
class Project(models.Model):
_inherit = "project.project"
allow_timesheets = fields.Boolean("Allow timesheets", default=True)
analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", ondelete='set null',
help="Link this project to an analytic account if you need financial management on projects. "
"It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.")
@api.onchange('partner_id')
def _onchange_partner_id(self):
domain = []
if self.partner_id:
domain = [('partner_id', '=', self.partner_id.id)]
return {'domain': {'analytic_account_id': domain}}
@api.constrains('allow_timesheets', 'analytic_account_id')
def _check_allow_timesheet(self):
for project in self:
if project.allow_timesheets and not project.analytic_account_id:
raise ValidationError(_('To allow timesheet, your project %s should have an analytic account set.' % (project.name,)))
@api.model
def name_create(self, name):
""" Create a project with name_create should generate analytic account creation """
values = {
'name': name,
'allow_timesheets': True,
}
return self.create(values).name_get()[0]
@api.model
def create(self, values):
""" Create an analytic account if project allow timesheet and don't provide one """
allow_timesheets = values['allow_timesheets'] if 'allow_timesheets' in values else self.default_get(['allow_timesheets'])['allow_timesheets']
if allow_timesheets and not values.get('analytic_account_id'):
analytic_account = self._create_analytic_account(values)
values['analytic_account_id'] = analytic_account.id
return super(Project, self).create(values)
@api.multi
def write(self, values):
result = super(Project, self).write(values)
if values.get('allow_timesheets'):
for project in self:
if not project.analytic_account_id:
analytic_account = project._create_analytic_account({
'name': project.name,
'company_id': project.company_id.id,
'partner_id': project.partner_id.id,
})
project.write({'analytic_account_id': analytic_account.id})
return result
@api.multi
def unlink(self):
""" Delete the empty related analytic account """
analytic_accounts_to_delete = self.env['account.analytic.account']
for project in self:
if project.analytic_account_id and not project.analytic_account_id.line_ids:
analytic_accounts_to_delete |= project.analytic_account_id
result = super(Project, self).unlink()
analytic_accounts_to_delete.unlink()
return result
def _create_analytic_account(self, values):
analytic_account = self.env['account.analytic.account'].create({
'name': values.get('name', _('Unkwon Analytic Account')),
'company_id': values.get('company_id', self.env.user.company_id.id),
'partner_id': values.get('partner_id'),
'active': True,
})
return analytic_account
class Task(models.Model):
_inherit = "project.task"
remaining_hours = fields.Float("Remaining Hours", compute='_compute_progress_hours', inverse='_inverse_remaining_hours', store=True, help="Total remaining time, can be re-estimated periodically by the assignee of the task.")
analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", related='project_id.analytic_account_id', readonly=True)
allow_timesheets = fields.Boolean("Allow timesheets", compute='_compute_allow_timesheets', help="Timesheets can be logged on this task.")
remaining_hours = fields.Float("Remaining Hours", compute='_compute_remaining_hours', inverse='_inverse_remaining_hours', help="Total remaining time, can be re-estimated periodically by the assignee of the task.")
effective_hours = fields.Float("Hours Spent", compute='_compute_effective_hours', compute_sudo=True, store=True, help="Computed using the sum of the task work done.")
total_hours_spent = fields.Float("Total Hours", compute='_compute_progress_hours', store=True, help="Computed as: Time Spent + Sub-tasks Hours.")
total_hours_spent = fields.Float("Total Hours", compute='_compute_total_hours_spent', store=True, help="Computed as: Time Spent + Sub-tasks Hours.")
progress = fields.Float("Progress", compute='_compute_progress_hours', store=True, group_operator="avg", help="Display progress of current task.")
subtask_effective_hours = fields.Float("Sub-tasks Hours Spent", compute='_compute_subtask_effective_hours', store=True, help="Sum of actually spent hours on the subtask(s)", oldname='children_hours')
timesheet_ids = fields.One2many('account.analytic.line', 'task_id', 'Timesheets')
@api.onchange('remaining_hours')
def _inverse_remaining_hours(self):
@api.depends('project_id.allow_timesheets', 'project_id.analytic_account_id')
def _compute_allow_timesheets(self):
for task in self:
task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours
task.allow_timesheets = task.project_id.allow_timesheets and task.project_id.analytic_account_id.active
@api.depends('timesheet_ids.unit_amount')
def _compute_effective_hours(self):
@@ -44,7 +113,19 @@ class Task(models.Model):
else:
task.progress = 0.0
@api.depends('effective_hours', 'subtask_effective_hours', 'planned_hours')
def _compute_remaining_hours(self):
for task in self:
task.remaining_hours = task.planned_hours - task.effective_hours - task.subtask_effective_hours
@api.onchange('remaining_hours')
def _inverse_remaining_hours(self):
for task in self:
task.planned_hours = task.remaining_hours + task.effective_hours + task.subtask_effective_hours
@api.depends('effective_hours', 'subtask_effective_hours')
def _compute_total_hours_spent(self):
for task in self:
task.total_hours_spent = task.effective_hours + task.subtask_effective_hours
@api.depends('child_ids.effective_hours', 'child_ids.subtask_effective_hours')
+1 -1
View File
@@ -16,7 +16,7 @@ class ReportProjectTaskUser(models.Model):
return super(ReportProjectTaskUser, self)._select() + """,
progress as progress,
t.effective_hours as hours_effective,
remaining_hours as remaining_hours,
t.planned_hours - t.effective_hours - t.subtask_effective_hours as remaining_hours,
planned_hours as hours_planned"""
def _group_by(self):
@@ -2,6 +2,10 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_account_analytic_line_user,analytic.account.analytic.line.timesheet.user,analytic.model_account_analytic_line,hr_timesheet.group_hr_timesheet_user,1,1,1,1
access_account_analytic_line_portal,access_timesheets_portal,analytic.model_account_analytic_line,base.group_portal,1,0,0,0
access_account_analytic_user,analytic.account.analytic.timesheet.user,analytic.model_account_analytic_account,hr_timesheet.group_hr_timesheet_user,1,1,0,0
access_account_analytic_account_user,account.analytic.account,analytic.model_account_analytic_account,project.group_project_user,1,0,0,0
access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1
access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0
access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1
access_product_product_user,product.product.timesheet.user,product.model_product_product,hr_timesheet.group_hr_timesheet_user,1,1,1,0
access_product_template_timesheet_user,product.template.timesheet.user,product.model_product_template,hr_timesheet.group_hr_timesheet_user,1,1,1,0
access_uom_uom_hr_timesheet,uom.uom.timesheet.user,uom.model_uom_uom,hr_timesheet.group_hr_timesheet_user,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_analytic_line_user analytic.account.analytic.line.timesheet.user analytic.model_account_analytic_line hr_timesheet.group_hr_timesheet_user 1 1 1 1
3 access_account_analytic_line_portal access_timesheets_portal analytic.model_account_analytic_line base.group_portal 1 0 0 0
4 access_account_analytic_user analytic.account.analytic.timesheet.user analytic.model_account_analytic_account hr_timesheet.group_hr_timesheet_user 1 1 0 0
5 access_account_analytic_account_user account.analytic.account analytic.model_account_analytic_account project.group_project_user 1 0 0 0
6 access_account_analytic_account_manager account.analytic.account analytic.model_account_analytic_account project.group_project_manager 1 1 1 1
7 access_account_analytic_account_portal account_analytic_account analytic.model_account_analytic_account base.group_portal 1 0 0 0
8 access_account_analytic_line_project account.analytic.line project analytic.model_account_analytic_line project.group_project_manager 1 1 1 1
9 access_product_product_user product.product.timesheet.user product.model_product_product hr_timesheet.group_hr_timesheet_user 1 1 1 0
10 access_product_template_timesheet_user product.template.timesheet.user product.model_product_template hr_timesheet.group_hr_timesheet_user 1 1 1 0
11 access_uom_uom_hr_timesheet uom.uom.timesheet.user uom.model_uom_uom hr_timesheet.group_hr_timesheet_user 1 0 0 0
@@ -17,11 +17,18 @@ class TestCommonTimesheet(TransactionCase):
'customer': True,
})
self.analytic_account = self.env['account.analytic.account'].create({
'name': 'Analytic Account for Test Customer',
'partner_id': self.partner.id,
'code': 'TEST'
})
# project and tasks
self.project_customer = self.env['project.project'].create({
'name': 'Project X',
'allow_timesheets': True,
'partner_id': self.partner.id,
'analytic_account_id': self.analytic_account.id,
})
self.task1 = self.env['project.task'].create({
'name': 'Task One',
@@ -1,6 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!--
Analytic Accounts with project
-->
<record id="account_analytic_account_view_form_inherit" model="ir.ui.view">
<field name="name">account.analytic.account.form.inherit</field>
<field name="model">account.analytic.account</field>
<field name="inherit_id" ref="analytic.view_account_analytic_account_form"/>
<field eval="18" name="priority"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='toggle_active']" position="before">
<button class="oe_stat_button" type="object" name="action_view_projects"
icon="fa-puzzle-piece" attrs="{'invisible': [('project_count', '=', 0)]}">
<field string="Projects" name="project_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
<!-- Timesheet root menus -->
<menuitem id="timesheet_menu_root"
name="Timesheets"
sequence="55"
+6 -2
View File
@@ -19,8 +19,12 @@
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='active_group']" position="before">
<!-- Empty invisible group to be filled by timesheet_grid and sale_timesheet -->
<group string="Timesheets" name="timesheet" invisible="1" groups="hr_timesheet.group_timesheet_manager">
<group string="Timesheets" name="timesheet" groups="hr_timesheet.group_timesheet_manager">
<label for="timesheet_cost"/>
<div>
<field name="timesheet_cost" class="oe_inline"/> per hour
<field name="currency_id" invisible="1"/>
</div>
</group>
</xpath>
<xpath expr="//button[@name='toggle_active']" position="before">
+21 -12
View File
@@ -48,6 +48,9 @@
<label for="allow_timesheets"/>
</div>
</xpath>
<xpath expr="//field[@name='partner_id']" position="after">
<field name="analytic_account_id"/>
</xpath>
</field>
</record>
@@ -58,7 +61,9 @@
<field name="groups_id" eval="[(6,0, (ref('hr_timesheet.group_hr_timesheet_user'),))]"/>
<field name="arch" type="xml">
<xpath expr="//notebook/page[@name='description_page']" position="after">
<page string="Timesheets" attrs="{'invisible': [('project_id', '=', False)]}">
<field name="analytic_account_id" invisible="1"/>
<field name="allow_timesheets" invisible="1"/>
<page string="Timesheets" attrs="{'invisible': [('analytic_account_id', '=', False)]}">
<group>
<group>
<field name="planned_hours" widget="float_time"/>
@@ -71,7 +76,12 @@
<field name="progress" widget="progressbar"/>
</group>
</group>
<field name="timesheet_ids" context="{'default_project_id': project_id, 'default_name':''}">
<group name="timesheet_error" attrs="{'invisible': [('allow_timesheets', '!=', False)]}">
<div class="alert alert-warning">
Your project is linked to an inactive analytic account. Change it, or reactivate the current one to timesheet on the project.
</div>
</group>
<field name="timesheet_ids" attrs="{'invisible': [('allow_timesheets', '=', False)]}" context="{'default_project_id': project_id, 'default_name':'', 'default_analytic_account_id': analytic_account_id}">
<tree editable="bottom" string="Timesheet Activities" default_order="date">
<field name="date"/>
<field name="user_id" invisible="1"/>
@@ -81,13 +91,13 @@
<field name="project_id" invisible="1"/>
</tree>
</field>
<group>
<group class="oe_subtotal_footer oe_right" name="project_hours">
<field name="effective_hours" widget="float_time" />
<field name="subtask_effective_hours" widget="float_time" groups="project.group_subtask_project" attrs="{'invisible' : [('subtask_count', '=', 0)]}" />
<field name="total_hours_spent" widget="float_time" class="oe_subtotal_footer_separator" attrs="{'invisible' : [('subtask_effective_hours', '=', 0.0)]}" />
<field name="remaining_hours" widget="float_time" class="oe_subtotal_footer_separator"/>
</group>
<group attrs="{'invisible': [('allow_timesheets', '=', False)]}">
<group class="oe_subtotal_footer oe_right" name="project_hours">
<field name="effective_hours" widget="float_time" />
<field name="subtask_effective_hours" widget="float_time" attrs="{'invisible' : [('subtask_effective_hours', '=', 0.0)]}" />
<field name="total_hours_spent" widget="float_time" class="oe_subtotal_footer_separator" attrs="{'invisible' : [('subtask_effective_hours', '=', 0.0)]}" />
<field name="remaining_hours" widget="float_time" class="oe_subtotal_footer_separator"/>
</group>
</group>
</page>
</xpath>
@@ -141,9 +151,8 @@
</templates>
<div class="oe_kanban_bottom_left" position="inside">
<t t-if="record.progress.raw_value &gt; 80 and record.progress.raw_value &lt; 100">
<div t-att-class="'oe_kanban_align label label-' + (record.progress.raw_value &gt;= 99 ? 'danger': 'warning')"
groups="project.group_time_work_estimation_tasks" title="Remaining hours">
<field name="remaining_hours" widget="float_time" />
<div t-att-class="'oe_kanban_align label label-' + (record.progress.raw_value &gt;= 99 ? 'danger': 'warning')" title="Remaining hours">
<field name="remaining_hours" widget="float_time" />
</div>
</t>
</div>
-1
View File
@@ -11,7 +11,6 @@
'depends': [
'base_setup',
'product',
'analytic',
'mail',
'portal',
'rating',
-26
View File
@@ -122,7 +122,6 @@
<!-- Tasks -->
<record id="project_task_1" model="project.task">
<field name="planned_hours" eval="40.0"/>
<field name="remaining_hours" eval="40.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">0</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -132,7 +131,6 @@
</record>
<record id="project_task_2" model="project.task">
<field name="planned_hours" eval="32.0"/>
<field name="remaining_hours" eval="32.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">0</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -141,7 +139,6 @@
</record>
<record id="project_task_3" model="project.task">
<field name="planned_hours" eval="10.0"/>
<field name="remaining_hours" eval="10.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">0</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -152,7 +149,6 @@
</record>
<record id="project_task_4" model="project.task">
<field name="planned_hours" eval="60.0"/>
<field name="remaining_hours" eval="60.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">0</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -166,7 +162,6 @@
</record>
<record id="project_task_5" model="project.task">
<field name="planned_hours" eval="76.0"/>
<field name="remaining_hours" eval="76.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -180,7 +175,6 @@
</record>
<record id="project_task_6" model="project.task">
<field name="planned_hours" eval="24.0"/>
<field name="remaining_hours" eval="24.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -189,7 +183,6 @@
</record>
<record id="project_task_7" model="project.task">
<field name="planned_hours" eval="15.0"/>
<field name="remaining_hours" eval="5.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_1"/>
@@ -198,7 +191,6 @@
</record>
<record id="project_task_8" model="project.task">
<field name="planned_hours" eval="22.0"/>
<field name="remaining_hours" eval="22.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_2"/>
@@ -210,7 +202,6 @@
</record>
<record id="project_task_9" model="project.task">
<field name="planned_hours" eval="18.0"/>
<field name="remaining_hours" eval="18.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_2"/>
@@ -219,7 +210,6 @@
</record>
<record id="project_task_10" model="project.task">
<field name="planned_hours" eval="38.0"/>
<field name="remaining_hours" eval="38.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_2"/>
@@ -229,7 +219,6 @@
</record>
<record id="project_task_11" model="project.task">
<field name="planned_hours" eval="16.0"/>
<field name="remaining_hours" eval="16.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_2"/>
@@ -242,7 +231,6 @@
<record id="project_task_12" model="project.task">
<field name="planned_hours" eval="40.0"/>
<field name="remaining_hours" eval="40.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_2"/>
@@ -253,7 +241,6 @@
<record id="project_task_13" model="project.task">
<field name="planned_hours" eval="12.0"/>
<field name="remaining_hours" eval="12.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -263,7 +250,6 @@
<record id="project_task_14" model="project.task">
<field name="planned_hours" eval="12.0"/>
<field name="remaining_hours" eval="12.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -272,7 +258,6 @@
</record>
<record id="project_task_15" model="project.task">
<field name="planned_hours" eval="55.0"/>
<field name="remaining_hours" eval="55.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -286,7 +271,6 @@
<record id="project_task_16" model="project.task">
<field name="sequence" eval="10"/>
<field name="planned_hours" eval="80.0"/>
<field name="remaining_hours" eval="80.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -296,7 +280,6 @@
<record id="project_task_17" model="project.task">
<field name="planned_hours" eval="34.0"/>
<field name="remaining_hours" eval="34.0"/>
<field name="user_id" ref="base.user_demo"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -306,7 +289,6 @@
<record id="project_task_18" model="project.task">
<field name="planned_hours" eval="16.0"/>
<field name="remaining_hours" eval="16.0"/>
<field name="user_id" ref="base.user_root"/>
<field name="priority">1</field>
<field name="project_id" ref="project.project_project_3"/>
@@ -316,7 +298,6 @@
<record id="project_task_19" model="project.task">
<field name="planned_hours">24.0</field>
<field name="remaining_hours">24.0</field>
<field name="stage_id" ref="project_stage_3"/>
<field name="user_id" eval="False"/>
<field name="project_id" ref="project.project_project_4"/>
@@ -327,7 +308,6 @@
<record id="project_task_20" model="project.task">
<field name="planned_hours">42.0</field>
<field name="remaining_hours">42.0</field>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="project_stage_0"/>
<field name="project_id" ref="project.project_project_4"/>
@@ -336,7 +316,6 @@
<record id="project_task_21" model="project.task">
<field name="planned_hours">14.0</field>
<field name="remaining_hours">14.0</field>
<field name="user_id" eval="False"/>
<field name="stage_id" ref="project_stage_1"/>
<field name="project_id" ref="project.project_project_4"/>
@@ -347,7 +326,6 @@
<record id="project_task_22" model="project.task">
<field name="planned_hours">12.0</field>
<field name="remaining_hours">12.0</field>
<field name="stage_id" ref="project_stage_1"/>
<field name="user_id" eval="False"/>
<field name="priority">0</field>
@@ -358,7 +336,6 @@
<record id="project_task_23" model="project.task">
<field name="sequence">15</field>
<field name="planned_hours">15.0</field>
<field name="remaining_hours">15.0</field>
<field name="stage_id" ref="project_stage_1"/>
<field name="user_id" eval="False"/>
<field name="project_id" ref="project.project_project_5"/>
@@ -368,7 +345,6 @@
<record id="project_task_24" model="project.task">
<field name="sequence">17</field>
<field name="planned_hours">8.0</field>
<field name="remaining_hours">8.0</field>
<field name="stage_id" ref="project_stage_2"/>
<field name="user_id" eval="False"/>
<field name="priority">1</field>
@@ -381,7 +357,6 @@
<record id="project_task_25" model="project.task">
<field name="sequence">20</field>
<field name="planned_hours">20.0</field>
<field name="remaining_hours">20.0</field>
<field name="user_id" eval="False"/>
<field name="project_id" ref="project.project_project_5"/>
<field name="name">Data import + Doc</field>
@@ -391,7 +366,6 @@
<record id="project_task_26" model="project.task">
<field name="sequence">20</field>
<field name="planned_hours">35.0</field>
<field name="remaining_hours">35.0</field>
<field name="user_id" eval="False"/>
<field name="project_id" ref="project.project_project_5"/>
<field name="name">Unit Testing</field>
-1
View File
@@ -1,7 +1,6 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import analytic
from . import project
from . import res_config_settings
from . import res_company
+6 -17
View File
@@ -69,7 +69,6 @@ class Project(models.Model):
_name = "project.project"
_description = "Project"
_inherit = ['portal.mixin', 'mail.alias.mixin', 'mail.thread']
_inherits = {'account.analytic.account': "analytic_account_id"}
_order = "sequence, name, id"
_period_number = 5
@@ -83,15 +82,10 @@ class Project(models.Model):
@api.multi
def unlink(self):
analytic_accounts_to_delete = self.env['account.analytic.account']
for project in self:
if project.tasks:
raise UserError(_('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.'))
if project.analytic_account_id and not project.analytic_account_id.line_ids:
analytic_accounts_to_delete |= project.analytic_account_id
res = super(Project, self).unlink()
analytic_accounts_to_delete.unlink()
return res
return super(Project, self).unlink()
def _compute_attached_docs_count(self):
Attachment = self.env['ir.attachment']
@@ -179,14 +173,14 @@ class Project(models.Model):
def _get_default_favorite_user_ids(self):
return [(6, 0, [self.env.uid])]
name = fields.Char("Name", index=True, required=True, track_visibility='onchange')
active = fields.Boolean(default=True,
help="If the active field is set to False, it will allow you to hide the project without removing it.")
sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of Projects.")
analytic_account_id = fields.Many2one(
'account.analytic.account', string='Contract/Analytic',
help="Link this project to an analytic account if you need financial management on projects. "
"It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.",
ondelete="cascade", required=True, auto_join=True)
partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, track_visibility='onchange')
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id)
currency_id = fields.Many2one('res.currency', related="company_id.currency_id", string="Currency", readonly=True)
favorite_user_ids = fields.Many2many(
'res.users', 'project_favorite_user_rel', 'project_id', 'user_id',
default=_get_default_favorite_user_ids,
@@ -315,8 +309,6 @@ class Project(models.Model):
if 'active' in vals:
# archiving/unarchiving a project does it on its tasks, too
self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']})
# archiving/unarchiving a project implies that we don't want to use the analytic account anymore
self.with_context(active_test=False).mapped('analytic_account_id').write({'active': vals['active']})
if vals.get('partner_id') or vals.get('privacy_visibility'):
for project in self.filtered(lambda project: project.privacy_visibility == 'portal'):
project.message_subscribe(project.partner_id.ids)
@@ -494,7 +486,6 @@ class Task(models.Model):
notes = fields.Text(string='Notes')
planned_hours = fields.Float("Planned Hours", help='It is the time planned to achieve the task. If this document has sub-tasks, it means the time needed to achieve this tasks and its childs.',track_visibility='onchange')
subtask_planned_hours = fields.Float("Subtasks", compute='_compute_subtask_planned_hours', help="Computed using sum of hours planned of all subtasks created from main task. Usually these hours are less or equal to the Planned Hours (of main task).")
remaining_hours = fields.Float(string='Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task.")
user_id = fields.Many2one('res.users',
string='Assigned to',
default=lambda self: self.env.uid,
@@ -627,8 +618,6 @@ class Task(models.Model):
default = {}
if not default.get('name'):
default['name'] = _("%s (copy)") % self.name
if 'remaining_hours' not in default:
default['remaining_hours'] = self.planned_hours
return super(Task, self).copy(default)
# Override view according to the company definition
@@ -34,7 +34,7 @@
<field name="name">Workload</field>
<field name="model_id">report.project.task.user</field>
<field name="user_id" eval="False"/>
<field name="context">{'group_by': ['project_id'], 'measures': ['planned_hours','remaining_hours']}</field>
<field name="context">{'group_by': ['project_id'], 'measures': ['planned_hours']}</field>
</record>
<record id="filter_task_report_responsible" model="ir.filters">
<field name="name">By Responsible</field>
@@ -46,7 +46,7 @@
<field name="name">Cumulative Flow</field>
<field name="model_id">report.project.task.user</field>
<field name="user_id" eval="False"/>
<field name="context">{'group_by': ['stage_id', 'state'], 'measures': ['nbr','planned_hours','remaining_hours']}</field>
<field name="context">{'group_by': ['stage_id', 'state'], 'measures': ['nbr','planned_hours']}</field>
</record>
<record id="view_task_project_user_search" model="ir.ui.view">
@@ -1,9 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_project,project.project,model_project_project,project.group_project_user,1,0,0,0
access_project_project_manager,project.project,model_project_project,project.group_project_manager,1,1,1,1
access_account_analytic_account_user,account.analytic.account,analytic.model_account_analytic_account,project.group_project_user,1,0,0,0
access_account_analytic_account_manager,account.analytic.account,analytic.model_account_analytic_account,project.group_project_manager,1,1,1,1
access_account_analytic_account_portal,account_analytic_account,analytic.model_account_analytic_account,base.group_portal,1,0,0,0
access_project_task_type_user,project.task.type.user,model_project_task_type,base.group_user,1,0,0,0
access_project_task_type_project_user,project.task.type.project.user,model_project_task_type,project.group_project_user,1,0,0,0
access_project_task_type_manager,project.task.type manager,model_project_task_type,project.group_project_manager,1,1,1,1
@@ -15,7 +12,6 @@ access_task_on_partner,project.task on partners,model_project_task,base.group_us
access_task_portal,task_portal,project.model_project_task,base.group_portal,1,0,0,0
access_project_user,project.project on partners,model_project_project,base.group_user,1,0,0,0
access_project_portal,project_portal,project.model_project_project,base.group_portal,1,0,0,0
access_account_analytic_line_project,account.analytic.line project,analytic.model_account_analytic_line,project.group_project_manager,1,1,1,1
access_resource_calendar,project.resource_calendar user,resource.model_resource_calendar,project.group_project_user,1,0,0,0
access_resource_calendar_attendance,project.resource_calendar_attendance user,resource.model_resource_calendar_attendance,project.group_project_user,1,0,0,0
access_resource_calendar_leaves_user,resource.calendar.leaves user,resource.model_resource_calendar_leaves,project.group_project_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_project project.project model_project_project project.group_project_user 1 0 0 0
3 access_project_project_manager project.project model_project_project project.group_project_manager 1 1 1 1
access_account_analytic_account_user account.analytic.account analytic.model_account_analytic_account project.group_project_user 1 0 0 0
access_account_analytic_account_manager account.analytic.account analytic.model_account_analytic_account project.group_project_manager 1 1 1 1
access_account_analytic_account_portal account_analytic_account analytic.model_account_analytic_account base.group_portal 1 0 0 0
4 access_project_task_type_user project.task.type.user model_project_task_type base.group_user 1 0 0 0
5 access_project_task_type_project_user project.task.type.project.user model_project_task_type project.group_project_user 1 0 0 0
6 access_project_task_type_manager project.task.type manager model_project_task_type project.group_project_manager 1 1 1 1
12 access_task_portal task_portal project.model_project_task base.group_portal 1 0 0 0
13 access_project_user project.project on partners model_project_project base.group_user 1 0 0 0
14 access_project_portal project_portal project.model_project_project base.group_portal 1 0 0 0
access_account_analytic_line_project account.analytic.line project analytic.model_account_analytic_line project.group_project_manager 1 1 1 1
15 access_resource_calendar project.resource_calendar user resource.model_resource_calendar project.group_project_user 1 0 0 0
16 access_resource_calendar_attendance project.resource_calendar_attendance user resource.model_resource_calendar_attendance project.group_project_user 1 0 0 0
17 access_resource_calendar_leaves_user resource.calendar.leaves user resource.model_resource_calendar_leaves project.group_project_user 1 1 1 1
@@ -14,11 +14,6 @@
<field name="users" eval="[(4, ref('base.user_root'))]"/>
</record>
<record id="group_time_work_estimation_tasks" model="res.groups">
<field name="name">Time Estimation on Tasks</field>
<field name="category_id" ref="base.module_category_hidden"/>
</record>
<record id="group_subtask_project" model="res.groups">
<field name="name">Use Subtasks</field>
<field name="category_id" ref="base.module_category_hidden"/>
+2 -18
View File
@@ -102,7 +102,6 @@
<field name="arch" type="xml">
<form string="Project">
<sheet string="Project">
<field name="analytic_account_id" invisible="1" required="0"/>
<div class="oe_button_box" name="button_box" groups="base.group_user">
<button class="oe_stat_button" name="attachment_tree_view" type="object" icon="fa-files-o">
<field string="Documents" name="doc_count" widget="statinfo"/>
@@ -150,8 +149,9 @@
<field name="portal_show_rating" attrs="{'invisible': [('rating_status','==','no')]}" groups="project.group_project_rating"/>
</group>
<group name="misc">
<group string="Configuration" groups="base.group_no_one">
<group string="Configuration" groups="base.group_no_one" name="configuration">
<field name="sequence" groups="base.group_no_one"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group string="Time Scheduling" groups="base.group_no_one">
<field name="resource_calendar_id" groups="base.group_no_one"/>
@@ -524,7 +524,6 @@
<field name="user_email"/>
<field name="description"/>
<field name="sequence"/>
<field name="remaining_hours" groups="project.group_time_work_estimation_tasks"/>
<field name="date_deadline"/>
<field name="message_needaction_counter"/>
<field name="attachment_ids"/>
@@ -666,21 +665,6 @@
</field>
</record>
<record id="analytic_account_inherited_form" model="ir.ui.view">
<field name="name">account.analytic.account.form.inherit</field>
<field name="model">account.analytic.account</field>
<field name="inherit_id" ref="analytic.view_account_analytic_account_form"/>
<field eval="18" name="priority"/>
<field name="arch" type="xml">
<xpath expr="//button[@name='toggle_active']" position="before">
<button class="oe_stat_button" type="object" name="projects_action"
icon="fa-puzzle-piece" attrs="{'invisible': [('project_count', '=', 0)]}">
<field string="Projects" name="project_count" widget="statinfo"/>
</button>
</xpath>
</field>
</record>
<record id="action_view_task" model="ir.actions.act_window">
<field name="name">Tasks</field>
<field name="res_model">project.task</field>
+1 -1
View File
@@ -384,7 +384,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="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"/>
<field name="analytic_account_id" context="{'default_partner_id':partner_invoice_id, 'default_name':name}" attrs="{'readonly': [('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'])]}"/>
+2
View File
@@ -2,4 +2,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import wizard
from . import controllers
from . import report
+4 -1
View File
@@ -16,15 +16,18 @@ have real delivered quantities in sales orders.
'website': 'https://www.odoo.com/page/warehouse',
'depends': ['sale_management', 'hr_timesheet'],
'data': [
'security/ir.model.access.csv',
'security/sale_timesheet_security.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
'views/hr_views.xml',
'views/project_task_views.xml',
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
'views/hr_timesheet_templates.xml',
'report/project_profitability_report_analysis_views.xml',
'data/sale_timesheet_filters.xml',
'wizard/project_task_assign_so_line_views.xml',
],
'demo': [
'data/sale_service_demo.xml',
+224 -37
View File
@@ -1,58 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import babel
from dateutil.relativedelta import relativedelta
from odoo import http, _
from odoo import http, fields, _
from odoo.http import request
from odoo.osv import expression
from odoo.tools import float_round
DEFAULT_MONTH_RANGE = 3
class SaleTimesheetController(http.Controller):
@http.route('/timesheet/plan', type='json', auth="user")
def plan(self, domain):
domain = expression.AND([domain, [('project_id', '!=', False)]]) # force timesheet and not AAL
values = self._prepare_plan_values(domain)
""" Get the HTML of the project plan for projects matching the given domain
:param domain: a domain for project.project
"""
projects = request.env['project.project'].search(domain)
values = self._plan_prepare_values(projects)
view = request.env.ref('sale_timesheet.timesheet_plan')
return {
'html_content': view.render(values)
}
def _prepare_plan_values(self, domain):
def _plan_prepare_values(self, projects):
timesheet_lines = request.env['account.analytic.line'].search(domain)
currency = request.env.user.company_id.currency_id
values = {
'currency': currency,
'timesheet_lines': timesheet_lines,
'domain': domain,
}
hour_rounding = request.env.ref('uom.product_uom_hour').rounding
billable_types = ['non_billable', 'non_billable_project', 'billable_time', 'billable_fixed']
# -- Stat Buttons
values['stat_buttons'] = self._plan_get_stat_button(timesheet_lines)
values = {
'projects': projects,
'currency': currency,
'timesheet_domain': [('project_id', 'in', projects.ids)],
'stat_buttons': self._plan_get_stat_button(projects),
}
# -- Dashboard (per billable type)
#
# Hours, Rates and Profitability
#
dashboard_values = {
'hours': dict.fromkeys(billable_types + ['total'], 0.0),
'rates': dict.fromkeys(billable_types + ['total'], 0.0),
'money_amount': {
'profit': {
'invoiced': 0.0,
'to_invoiced': 0.0,
'to_invoice': 0.0,
'cost': 0.0,
'total': 0.0,
}
}
dashboard_domain = domain + [('timesheet_invoice_type', '!=', False)] # force billable type
dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
# hours (from timesheet) and rates (by billable type)
dashboard_domain = [('project_id', 'in', projects.ids), ('timesheet_invoice_type', '!=', False)] # force billable type
dashboard_data = request.env['account.analytic.line'].read_group(dashboard_domain, ['unit_amount', 'timesheet_revenue', 'timesheet_invoice_type'], ['timesheet_invoice_type'])
dashboard_total_hours = sum([data['unit_amount'] for data in dashboard_data])
for data in dashboard_data:
billable_type = data['timesheet_invoice_type']
# hours
dashboard_values['hours'][billable_type] = float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
dashboard_values['hours']['total'] += float_round(data.get('unit_amount'), precision_rounding=hour_rounding)
# rates
@@ -60,22 +64,37 @@ class SaleTimesheetController(http.Controller):
dashboard_values['rates'][billable_type] = rate
dashboard_values['rates']['total'] += rate
# money_amount
so_lines = values['timesheet_lines'].mapped('so_line')
invoice_lines = so_lines.mapped('invoice_lines')
dashboard_values['money_amount']['invoiced'] = sum([inv_line.currency_id.with_context(date=inv_line.invoice_id.date_invoice).compute(inv_line.price_unit * inv_line.quantity, currency) for inv_line in invoice_lines.filtered(lambda line: line.invoice_id.state in ['open', 'paid'])])
dashboard_values['money_amount']['to_invoice'] = sum([sol.currency_id.compute(sol.price_unit * (1 - (sol.discount or 0.0) / 100.0) * sol.qty_to_invoice, currency) for sol in so_lines]) + sum([i.currency_id.with_context(date=i.invoice_id.date_invoice).compute(i.price_unit * i.quantity, currency) for i in invoice_lines.filtered(lambda line: line.invoice_id.state == 'draft')])
dashboard_values['money_amount']['cost'] = sum(values['timesheet_lines'].mapped('amount'))
dashboard_values['money_amount']['total'] = sum([dashboard_values['money_amount'][item] for item in dashboard_values['money_amount'].keys()])
# profitability, using profitability SQL report
profit = dict.fromkeys(['invoiced', 'to_invoice', 'cost', 'total'], 0.0)
profitability_raw_data = request.env['project.profitability.report'].read_group([('project_id', 'in', projects.ids)], ['project_id', 'amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost'], ['project_id'])
for data in profitability_raw_data:
profit['invoiced'] += data.get('amount_untaxed_invoiced', 0.0)
profit['to_invoice'] += data.get('amount_untaxed_to_invoice', 0.0)
profit['cost'] += data.get('timesheet_cost', 0.0)
profit['total'] = sum([profit[item] for item in profit.keys()])
dashboard_values['profit'] = profit
values['dashboard'] = dashboard_values
# -- Time Repartition (per employee)
repartition_domain = domain + [('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
#
# Time Repartition (per employee per billable types)
#
employees = projects.mapped('tasks.user_id.employee_ids') | request.env['account.analytic.line'].search([('project_id', 'in', projects.ids)]).mapped('employee_id')
repartition_domain = [('project_id', 'in', projects.ids), ('employee_id', '!=', False), ('timesheet_invoice_type', '!=', False)] # force billable type
repartition_data = request.env['account.analytic.line'].read_group(repartition_domain, ['employee_id', 'timesheet_invoice_type', 'unit_amount'], ['employee_id', 'timesheet_invoice_type'], lazy=False)
# set repartition per type per employee
repartition_employee = {}
for employee in employees:
repartition_employee[employee.id] = dict(
employee_id=employee.id,
employee_name=employee.name,
non_billable_project=0.0,
non_billable=0.0,
billable_time=0.0,
billable_fixed=0.0,
total=0.0,
)
for data in repartition_data:
employee_id = data['employee_id'][0]
repartition_employee.setdefault(employee_id, dict(
@@ -87,7 +106,7 @@ class SaleTimesheetController(http.Controller):
billable_fixed=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']
repartition_employee[employee_id]['__domain_' + data['timesheet_invoice_type']] = data['__domain']
# compute total
for employee_id, vals in repartition_employee.items():
@@ -97,23 +116,191 @@ class SaleTimesheetController(http.Controller):
values['repartition_employee_max'] = (max(hours_per_employee) if hours_per_employee else 1) or 1
values['repartition_employee'] = repartition_employee
#
# Table grouped by SO / SOL / Employees
#
if not projects:
return values
# build SQL query and fetch raw data
query, query_params = self._table_rows_sql_query(projects)
request.env.cr.execute(query, query_params)
raw_data = request.env.cr.dictfetchall()
rows_employee = self._table_rows_get_employee_lines(projects, raw_data)
default_row_vals = self._table_row_default(projects)
empty_line_ids, empty_order_ids = self._table_get_empty_so_lines(projects)
# extract row labels
sale_line_ids = set()
sale_order_ids = set()
for key_tuple, row in rows_employee.items():
if row[0]['sale_line_id']:
sale_line_ids.add(row[0]['sale_line_id'])
if row[0]['sale_order_id']:
sale_order_ids.add(row[0]['sale_order_id'])
sale_order_lines = request.env['sale.order.line'].sudo().browse(sale_line_ids | empty_line_ids)
map_so_names = {so.id: so.name for so in request.env['sale.order'].sudo().browse(sale_order_ids | empty_order_ids)}
map_sol = {sol.id: sol for sol in sale_order_lines}
map_sol_names = {sol.id: sol.name.split('\n')[0] if sol.name else _('No Sale Order Line') for sol in sale_order_lines}
map_sol_so = {sol.id: sol.order_id.id for sol in sale_order_lines}
rows_sale_line = {} # (so, sol) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
for sale_line_id in empty_line_ids: # add service SO line having no timesheet
sale_line_row_key = (map_sol_so.get(sale_line_id), sale_line_id)
rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line_id, _('No Sale Order Line')), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line'}] + default_row_vals[:]
rows_sale_line[sale_line_row_key][-2] = map_sol.get(sale_line_id).product_uom_qty if sale_line_id in map_sol else 0.0
for row_key, row_employee in rows_employee.items():
sale_line_id = row_key[1]
sale_order_id = row_key[0]
# sale line row
sale_line_row_key = (sale_order_id, sale_line_id)
if sale_line_row_key not in rows_sale_line:
sale_line = map_sol.get(sale_line_id, request.env['sale.order.line'])
rows_sale_line[sale_line_row_key] = [{'label': map_sol_names.get(sale_line.id) if sale_line else _('No Sale Order Line'), 'res_id': sale_line_id, 'res_model': 'sale.order.line', 'type': 'sale_order_line'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
rows_sale_line[sale_line_row_key][-2] = sale_line.product_uom_qty or 0.0
for index in range(len(rows_employee[row_key])):
if index != 0:
rows_sale_line[sale_line_row_key][index] += rows_employee[row_key][index]
rows_sale_line[sale_line_row_key][-1] = rows_sale_line[sale_line_row_key][-2] - rows_sale_line[sale_line_row_key][5]
rows_sale_order = {} # so -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
for row_key, row_sale_line in rows_sale_line.items():
sale_order_id = row_key[0]
# sale order row
if sale_order_id not in rows_sale_order:
rows_sale_order[sale_order_id] = [{'label': map_so_names.get(sale_order_id, _('No Sale Order')), 'res_id': sale_order_id, 'res_model': 'sale.order', 'type': 'sale_order'}] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
for index in range(len(rows_sale_line[row_key])):
if index != 0:
rows_sale_order[sale_order_id][index] += rows_sale_line[row_key][index]
# remaining computation of SO row, as Sold - Done (timesheet total)
for sale_order_id, sale_order_row in rows_sale_order.items():
sale_order_row[-1] = sale_order_row[-2] - sale_order_row[5]
# group rows SO, SOL and their related employee rows.
timesheet_forecast_table_rows = []
for sale_order_id, sale_order_row in rows_sale_order.items():
timesheet_forecast_table_rows.append(sale_order_row)
for sale_line_row_key, sale_line_row in rows_sale_line.items():
if sale_order_id == sale_line_row_key[0]:
timesheet_forecast_table_rows.append(sale_line_row)
for employee_row_key, employee_row in rows_employee.items():
if sale_order_id == employee_row_key[0] and sale_line_row_key[1] == employee_row_key[1]:
timesheet_forecast_table_rows.append(employee_row)
# complete table data
values['timesheet_forecast_table'] = {
'header': self._table_header(projects),
'rows': timesheet_forecast_table_rows
}
return values
def _plan_get_stat_button(self, timesheet_lines):
def _table_header(self, projects):
initial_date = fields.Date.from_string(fields.Date.today())
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
def _to_short_month_name(date):
month_index = fields.Date.from_string(date).month
return babel.dates.get_month_names('abbreviated', locale=request.env.context.get('lang', 'en_US'))[month_index]
header = [_('Name'), _('Before')] + [_to_short_month_name(date) for date in ts_months] + [_('Done'), _('Sold'), _('Remaining')]
return header
def _table_row_default(self, projects):
lenght = len(self._table_header(projects))
return [0.0] * (lenght - 1) # before, M1, M2, M3, Done, Sold, Remaining
def _table_rows_sql_query(self, projects):
initial_date = fields.Date.from_string(fields.Date.today())
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
# build query
query = """
SELECT
'timesheet' AS type,
date_trunc('month', date)::date AS month_date,
E.id AS employee_id,
S.order_id AS sale_order_id,
A.so_line AS sale_line_id,
SUM(A.unit_amount) AS number_hours
FROM account_analytic_line A
JOIN hr_employee E ON E.id = A.employee_id
LEFT JOIN sale_order_line S ON S.id = A.so_line
WHERE A.project_id IS NOT NULL
AND A.project_id IN %s
AND A.date < %s
GROUP BY date_trunc('month', date)::date, S.order_id, A.so_line, E.id
"""
last_ts_month = fields.Date.to_string(fields.Date.from_string(ts_months[-1]) + relativedelta(months=1))
query_params = (tuple(projects.ids), last_ts_month)
return query, query_params
def _table_rows_get_employee_lines(self, projects, data_from_db):
initial_date = fields.Date.from_string(fields.Date.today())
ts_months = sorted([fields.Date.to_string(initial_date - relativedelta(months=i, day=1)) for i in range(0, DEFAULT_MONTH_RANGE)]) # M1, M2, M3
default_row_vals = self._table_row_default(projects)
# extract employee names
employee_ids = set()
for data in data_from_db:
employee_ids.add(data['employee_id'])
map_empl_names = {empl.id: empl.name for empl in request.env['hr.employee'].sudo().browse(employee_ids)}
# extract rows data for employee, sol and so rows
rows_employee = {} # (so, sol, employee) -> [INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted]
for data in data_from_db:
sale_line_id = data['sale_line_id']
sale_order_id = data['sale_order_id']
# employee row
row_key = (data['sale_order_id'], sale_line_id, data['employee_id'])
if row_key not in rows_employee:
meta_vals = {
'label': map_empl_names.get(row_key[2]),
'sale_line_id': sale_line_id,
'sale_order_id': sale_order_id,
'res_id': row_key[2],
'res_model': 'hr.employee',
'type': 'hr_employee'
}
rows_employee[row_key] = [meta_vals] + default_row_vals[:] # INFO, before, M1, M2, M3, Done, M3, M4, M5, After, Forecasted
index = False
if data['type'] == 'timesheet':
if data['month_date'] in ts_months:
index = ts_months.index(data['month_date']) + 2
elif data['month_date'] < ts_months[0]:
index = 1
rows_employee[row_key][index] += data['number_hours']
rows_employee[row_key][5] += data['number_hours']
return rows_employee
def _table_get_empty_so_lines(self, projects):
""" get the Sale Order Lines having no timesheet but having generated a task or a project """
so_lines = projects.mapped('sale_line_id.order_id.order_line').filtered(lambda sol: sol.is_service)
return set(so_lines.ids), set(so_lines.mapped('order_id').ids)
# --------------------------------------------------
# Actions: Stat buttons, ...
# --------------------------------------------------
def _plan_get_stat_button(self, projects):
stat_buttons = []
stat_buttons.append({
'name': _('Timesheets'),
'res_model': 'account.analytic.line',
'domain': [('id', 'in', timesheet_lines.ids)],
'domain': [('project_id', 'in', projects.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),
'count': sum(projects.mapped('task_count')),
'res_model': 'project.task',
'domain': stat_task_domain,
'domain': [('project_id', 'in', projects.ids)],
'icon': 'fa fa-tasks',
})
return stat_buttons
@@ -11,7 +11,7 @@
<field name="product_uom_qty">5.0</field>
</record>
<!-- Projects and Stages -->
<!-- Projects, Analytic Account and Stages -->
<record id="project_stage_fixed" model="project.task.type">
<field name="sequence">30</field>
<field name="name">Fixed</field>
@@ -19,17 +19,31 @@
<field name="legend_done">Done</field>
</record>
<record id="account_analytic_account_project_gap" model="account.analytic.account">
<field name="name">Internal - GAP Analysis</field>
<field name="code">INT</field>
<field name="active" eval="True"/>
</record>
<record id="project_GAP" model="project.project">
<field name="date_start" eval="time.strftime('%Y-%m-01 10:00:00')"/>
<field name="name">Internal - GAP Analysis</field>
<field name="analytic_account_id" ref="account_analytic_account_project_gap"/>
<field name="color">2</field>
<field name="privacy_visibility">employees</field>
<field name="type_ids" eval="[(4, ref('project.project_stage_0')) ,(4,ref('project.project_stage_1')) ,(4,ref('project.project_stage_2')) ,(4,ref('project.project_stage_3'))]"/>
</record>
<record id="account_analytic_account_project_support" model="account.analytic.account">
<field name="name">Support</field>
<field name="code">INT</field>
<field name="active" eval="True"/>
</record>
<record id="project_support" model="project.project">
<field name="date_start" eval="time.strftime('%Y-%m-01 10:00:00')"/>
<field name="name">Support</field>
<field name="analytic_account_id" ref="account_analytic_account_project_support"/>
<field name="type_ids" eval="[(4, ref('project.project_stage_data_0')), (4, ref('project.project_stage_1')), (4, ref('project_stage_fixed'))]"/>
</record>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_filter_project_profitability_report_costs_and_revenues" model="ir.filters">
<field name="name">Costs and Revenues</field>
<field name="model_id">project.profitability.report</field>
<field name="user_id" eval="False"/>
<field name="is_default" eval="True"/>
<field name="context">{
'group_by': ['project_id', 'sale_line_id'],
'pivot_measures': ['amount_untaxed_to_invoice', 'amount_untaxed_invoiced', 'timesheet_cost', 'timesheet_unit_amount'],
'pivot_column_groupby': [], 'pivot_row_groupby': []
}</field>
</record>
<record id="ir_filter_project_profitability_report_manager_and_product" model="ir.filters">
<field name="name">Product by Customer</field>
<field name="model_id">project.profitability.report</field>
<field name="user_id" eval="False"/>
<field name="context">{
'group_by': ['partner_id', 'product_id'],
'graph_measure': 'amount_untaxed_to_invoice',
'graph_mode': 'bar',
'graph_groupbys': ['partner_id', 'product_id']
}</field>
</record>
</odoo>
-1
View File
@@ -3,7 +3,6 @@
from . import account
from . import account_invoice
from . import hr
from . import product
from . import project
from . import sale_order
+13 -27
View File
@@ -21,14 +21,6 @@ class AccountAnalyticLine(models.Model):
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, copy=False)
@api.model
def create(self, 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
@@ -36,8 +28,6 @@ class AccountAnalyticLine(models.Model):
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 (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
@api.model
@@ -55,30 +45,23 @@ class AccountAnalyticLine(models.Model):
return values
@api.multi
def _timesheet_postprocess(self, values):
def _timesheet_postprocess_values(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']]):
result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values)
# (re)compute the UoM from the employee company
if any([field_name in values for field_name in ['employee_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,
result[timesheet.id].update({
'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):
for timesheet in self:
values = timesheet._timesheet_compute_theorical_revenue_values()
timesheet.write(values)
return True
for timesheet in sudo_self:
values_to_write = timesheet._timesheet_compute_theorical_revenue_values()
if values_to_write:
result[timesheet.id].update(values_to_write)
return result
@api.multi
def _timesheet_compute_theorical_revenue_values(self):
@@ -93,6 +76,9 @@ class AccountAnalyticLine(models.Model):
)
else:
0
:return: a dictionary mapping each record id to its corresponding
dictionnary values to write (may be empty).
"""
self.ensure_one()
timesheet = self
+25 -2
View File
@@ -1,7 +1,8 @@
# -*- 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 ProductTemplate(models.Model):
@@ -25,6 +26,9 @@ class ProductTemplate(models.Model):
project_id = fields.Many2one(
'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.')
project_template_id = fields.Many2one(
'project.project', 'Project Template', company_dependent=True, domain=[('sale_line_id', '=', False)], copy=True,
help='Select a non billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.')
@api.depends('invoice_policy', 'service_type')
def _compute_service_policy(self):
@@ -49,9 +53,28 @@ class ProductTemplate(models.Model):
product.invoice_policy = 'delivery'
product.service_type = 'manual' if policy == 'delivered_manual' else 'timesheet'
@api.constrains('project_id', 'project_template_id')
def _check_project_and_template(self):
""" NOTE 'service_tracking' should be in decorator parameters but since ORM check constraints twice (one after setting
stored fields, one after setting non stored field), the error is raised when company-dependent fields are not set.
So, this constraints does cover all cases and inconsistent can still be recorded until the ORM change its behavior.
"""
for product in self:
if product.service_tracking == 'no' and (product.project_id or product.project_template_id):
raise ValidationError(_('The product %s should not have a project nor a project template since it will not generate project.') % (product.name,))
elif product.service_tracking == 'task_global_project' and product.project_template_id:
raise ValidationError(_('The product %s should not have a project template since it will generate a task in a global project.') % (product.name,))
elif product.service_tracking in ['task_new_project', 'project_only'] and product.project_id:
raise ValidationError(_('The product %s should not have a global project since it will generate a project.') % (product.name,))
@api.onchange('service_tracking')
def _onchange_service_tracking(self):
if self.service_tracking != 'task_global_project':
if self.service_tracking == 'no':
self.project_id = False
self.project_template_id = False
elif self.service_tracking == 'task_global_project':
self.project_template_id = False
elif self.service_tracking in ['task_new_project', 'project_only']:
self.project_id = False
@api.onchange('type')
+26 -9
View File
@@ -41,21 +41,28 @@ class Project(models.Model):
@api.multi
def action_view_timesheet_plan(self):
return {
'name': _('Overview'),
'type': 'ir.actions.client',
'tag': 'timesheet.plan',
'context': {
'active_id': self.id,
'active_ids': self.ids,
'search_default_project_id': self.id,
}
action = self.env.ref('sale_timesheet.project_timesheet_action_client_timesheet_plan').read()[0]
action['params'] = {
'project_ids': self.ids,
}
action['context'] = {
'active_id': self.id,
'active_ids': self.ids,
'search_default_display_name': self.name,
}
return action
class ProjectTask(models.Model):
_inherit = "project.task"
def _get_default_partner(self):
partner = super(ProjectTask, self)._get_default_partner()
if 'default_project_id' in self.env.context: # partner from SO line is prior on one from project
project = self.env['project.project'].browse(self.env.context['default_project_id'])
partner = project.sale_line_id.order_partner_id
return partner
@api.model
def _default_sale_line_id(self):
sale_line_id = False
@@ -66,6 +73,16 @@ class ProjectTask(models.Model):
return sale_line_id
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', default=_default_sale_line_id, domain="[('is_service', '=', True), ('order_partner_id', '=', partner_id), ('is_expense', '=', False)]")
sale_order_id = fields.Many2one('sale.order', 'Sales Order', related='sale_line_id.order_id', store=True, readonly=True)
@api.onchange('project_id')
def _onchange_project(self):
result = super(ProjectTask, self)._onchange_project()
if self.project_id:
self.sale_line_id = self.project_id.sale_line_id
if not self.partner_id:
self.partner_id = self.sale_line_id.order_partner_id
return result
@api.multi
@api.constrains('sale_line_id')
+133 -113
View File
@@ -6,7 +6,6 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.osv import expression
from odoo.tools.safe_eval import safe_eval
from odoo.tools import float_is_zero
class SaleOrder(models.Model):
@@ -18,7 +17,6 @@ class SaleOrder(models.Model):
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', 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
@@ -42,61 +40,56 @@ class SaleOrder(models.Model):
order.tasks_count = len(order.tasks_ids)
@api.multi
@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.analytic_account_id.id)])
@api.multi
@api.depends('order_line.product_id', 'project_project_id')
@api.depends('order_line.product_id', 'order_line.project_id')
def _compute_project_ids(self):
for order in self:
projects = order.order_line.mapped('product_id.project_id')
if order.project_project_id:
projects |= order.project_project_id
projects |= order.order_line.mapped('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()
self.order_line._timesheet_service_generation()
self.order_line.sudo()._timesheet_service_generation()
return result
@api.multi
def action_view_task(self):
self.ensure_one()
action = self.env.ref('project.action_view_task')
list_view_id = self.env.ref('project.view_task_tree2').id
form_view_id = self.env.ref('project.view_task_form2').id
result = {
'name': action.name,
'help': action.help,
'type': action.type,
'views': [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']],
'target': action.target,
'context': "{'group_by':'stage_id'}",
'res_model': action.res_model,
}
if len(self.tasks_ids) > 1:
result['domain'] = "[('id','in',%s)]" % self.tasks_ids.ids
elif len(self.tasks_ids) == 1:
result['views'] = [(form_view_id, 'form')]
result['res_id'] = self.tasks_ids.id
action = {'type': 'ir.actions.act_window_close'}
task_projects = self.tasks_ids.mapped('project_id')
if len(task_projects) == 1 and len(self.tasks_ids) > 1: # redirect to task of the project (with kanban stage, ...)
action = self.env.ref('project.act_project_project_2_project_task_all').read()[0]
if action.get('context'):
eval_context = self.env['ir.actions.actions']._get_eval_context()
eval_context.update({'active_id': task_projects.id})
action['context'] = safe_eval(action['context'], eval_context)
else:
result = {'type': 'ir.actions.act_window_close'}
return result
action = self.env.ref('project.action_view_task').read()[0]
action['context'] = {} # erase default context to avoid default filter
if len(self.tasks_ids) > 1: # cross project kanban task
action['views'] = [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']]
elif len(self.tasks_ids) == 1: # single task -> form view
action['views'] = [(form_view_id, 'form')]
action['res_id'] = self.tasks_ids.id
# filter on the task of the current SO
action.setdefault('context', {})
action['context'].update({'search_default_sale_order_id': self.id})
return action
@api.multi
def action_view_project_ids(self):
self.ensure_one()
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})
# redirect to form or kanban view
billable_projects = self.project_ids.filtered(lambda project: project.sale_line_id)
if len(billable_projects) == 1:
action = billable_projects[0].action_view_timesheet_plan()
else:
view_form_id = self.env.ref('project.edit_project').id
view_kanban_id = self.env.ref('project.view_project_kanban').id
@@ -137,7 +130,8 @@ class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
task_id = fields.Many2one('project.task', 'Task', index=True, help="Task generated by the sales order item")
project_id = fields.Many2one('project.project', 'Generated Project', index=True, copy=False, help="Project generated by the sales order item")
task_id = fields.Many2one('project.task', 'Generated Task', index=True, copy=False, help="Task generated by the sales order item")
is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
@@ -186,11 +180,15 @@ class SaleOrderLine(models.Model):
# Do not generate task/project when expense SO line, but allow
# generate task with hours=0.
if line.state == 'sale' and not line.is_expense:
line._timesheet_service_generation()
line.sudo()._timesheet_service_generation()
# if the SO line created a task, post a message on the order
if line.task_id:
msg_body = _("Task Created (%s): <a href=# data-oe-model=project.task data-oe-id=%d>%s</a>") % (line.product_id.name, line.task_id.id, line.task_id.name)
line.order_id.message_post(body=msg_body)
return line
###########################################
## Service : Project and task generation
# Service : Project and task generation
###########################################
def _convert_qty_company_hours(self):
@@ -201,103 +199,125 @@ class SaleOrderLine(models.Model):
planned_hours = self.product_uom_qty
return planned_hours
def _timesheet_find_project(self):
@api.multi
def _timesheet_create_project(self):
""" Generate project for the given so line, and link it.
:param project: record of project.project in which the task should be created
:return task: record of the created task
"""
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.analytic_account_id
if not account:
self.order_id._create_analytic_account(prefix=self.product_id.default_code or None)
account = self.order_id.analytic_account_id
if not account:
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_name = '%s (%s)' % (account.name, self.order_partner_id.ref) if self.order_partner_id.ref else account.name
project = Project.create({
'name': project_name,
'allow_timesheets': self.product_id.service_type == 'timesheet',
'analytic_account_id': account.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})
# create the project or duplicate one
values = {
'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
'allow_timesheets': True,
'analytic_account_id': account.id,
'partner_id': self.order_id.partner_id.id,
'sale_line_id': self.id,
}
if self.product_id.project_template_id:
values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
project = self.product_id.project_template_id.copy(values)
project.tasks.write({
'sale_line_id': self.id,
'partner_id': self.order_id.partner_id.id,
'email_from': self.order_id.partner_id.email,
})
else:
project = self.env['project.project'].create(values)
# link project as generated by current so line
self.write({'project_id': project.id})
return project
def _timesheet_create_task_prepare_values(self):
def _timesheet_create_task_prepare_values(self, project):
self.ensure_one()
project = self._timesheet_find_project()
planned_hours = self._convert_qty_company_hours()
sale_line_name_parts = self.name.split('\n')
title = sale_line_name_parts[0] or self.product_id.name
description = '<br/>'.join(sale_line_name_parts[1:])
return {
'name': '%s:%s' % (self.order_id.name or '', self.name.split('\n')[0] or self.product_id.name),
'name': title if project.sale_line_id else '%s: %s' % (self.order_id.name or '', title),
'planned_hours': planned_hours,
'remaining_hours': planned_hours,
'partner_id': self.order_id.partner_id.id,
'email_from': self.order_id.partner_id.email,
'description': self.name + '<br/>',
'description': description,
'project_id': project.id,
'sale_line_id': self.id,
'company_id': self.company_id.id,
'user_id': False, # force non assigned task, as created as sudo()
'user_id': False, # force non assigned task, as created as sudo()
}
@api.multi
def _timesheet_create_task(self):
def _timesheet_create_task(self, project):
""" Generate task for the given so line, and link it.
:return a mapping with the so line id and its linked task
:rtype dict
:param project: record of project.project in which the task should be created
:return task: record of the created task
"""
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
values = self._timesheet_create_task_prepare_values(project)
task = self.env['project.task'].sudo().create(values)
self.write({'task_id': task.id})
# 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)") % (self.order_id.id, self.order_id.name, self.product_id.name)
task.message_post(body=task_msg)
return task
@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.
Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
new project/task. This explains the searches on 'sale_line_id' on project/task. This also
implied if so line of generated task has been modified, we may regenerate it.
"""
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
so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_new_project'])
# search so lines from SO of current so lines having their project generated, in order to check if the current one can
# create its own project, or reuse the one of its order.
map_so_project = {}
if so_line_new_project:
order_ids = self.mapped('order_id').ids
so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '=', False)])
map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_new_project']), ('product_id.project_template_id', '!=', False)])
map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}
# search the global project of current SO lines, in which create their task
map_sol_project = {}
if so_line_task_global_project:
map_sol_project = {sol.id: sol.product_id.with_context(force_company=sol.company_id.id).project_id for sol in so_line_task_global_project}
def _can_create_project(sol):
if not sol.project_id:
if sol.product_id.project_template_id:
return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates
elif sol.order_id.id not in map_so_project:
return True
return False
# task_global_project: create task in global project
for so_line in so_line_task_global_project:
if not so_line.task_id:
if map_sol_project.get(so_line.id):
so_line._timesheet_create_task(project=map_sol_project[so_line.id])
# project_only, task_new_project: create a new project, based or not on a template (1 per SO). May be create a task too.
for so_line in so_line_new_project:
project = so_line.project_id
if not project and _can_create_project(so_line):
project = so_line._timesheet_create_project()
if so_line.product_id.project_template_id:
map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
else:
map_so_project[so_line.order_id.id] = project
if so_line.product_id.service_tracking == 'task_new_project':
so_line._timesheet_find_task()
if not project:
if so_line.product_id.project_template_id:
project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
else:
project = map_so_project[so_line.order_id.id]
so_line._timesheet_create_task(project=project)
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_profitability_report_analysis
@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
class ProfitabilityAnalysis(models.Model):
_name = "project.profitability.report"
_description = "Project Profitability Analysis"
_order = 'project_id, sale_line_id'
_auto = False
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True)
project_id = fields.Many2one('project.project', string='Project', readonly=True)
currency_id = fields.Many2one('res.currency', string='Project Currency', readonly=True)
company_id = fields.Many2one('res.company', string='Project Company', readonly=True)
user_id = fields.Many2one('res.users', string='Project Manager', readonly=True)
partner_id = fields.Many2one('res.partner', string='Customer', readonly=True)
# cost
timesheet_unit_amount = fields.Float("Timesheet Unit Amount", digits=(16, 2), readonly=True, group_operator="sum")
timesheet_cost = fields.Float("Timesheet Cost", digits=(16, 2), readonly=True, group_operator="sum")
# sale revenue
order_confirmation_date = fields.Datetime('Sales Order Confirmation Date', readonly=True)
sale_line_id = fields.Many2one('sale.order.line', string='Sale Order Line', readonly=True)
sale_order_id = fields.Many2one('sale.order', string='Sale Order', readonly=True)
product_id = fields.Many2one('product.product', string='Product', readonly=True)
amount_untaxed_to_invoice = fields.Float("Untaxed Amout To Invoice", digits=(16, 2), readonly=True, group_operator="sum")
amount_untaxed_invoiced = fields.Float("Untaxed Amout Invoiced", digits=(16, 2), readonly=True, group_operator="sum")
def init(self):
tools.drop_view_if_exists(self._cr, self._table)
query = """
CREATE VIEW %s AS (
WITH currency_rate as (%s)
SELECT
ROW_NUMBER() OVER (ORDER BY P.id, SOL.id) AS id,
P.user_id AS user_id,
TIMESHEET_AMOUNT.project_id AS project_id,
TIMESHEET_AMOUNT.sale_line_id AS sale_line_id,
AA.id AS analytic_account_id,
AA.partner_id AS partner_id,
C.id AS company_id,
C.currency_id AS currency_id,
S.id AS sale_order_id,
S.date_order AS order_confirmation_date,
SOL.product_id AS product_id,
(SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_to_invoice AS amount_untaxed_to_invoice,
(SOL.price_reduce / COALESCE(CR.rate, 1.0)) * SOL.qty_invoiced AS amount_untaxed_invoiced,
TIMESHEET_AMOUNT.timesheet_unit_amount AS timesheet_unit_amount,
TIMESHEET_AMOUNT.timesheet_cost AS timesheet_cost
FROM project_project P
LEFT JOIN account_analytic_account AA ON P.analytic_account_id = AA.id
JOIN res_company C ON C.id = AA.company_id
LEFT JOIN
(
SELECT T.sale_line_id AS sale_line_id, T.project_id AS project_id
FROM project_task T
WHERE T.sale_line_id IS NOT NULL
UNION
SELECT P.sale_line_id AS sale_line_id, P.id AS project_id
FROM project_project P
WHERE P.sale_line_id IS NOT NULL
) SOL_PER_PROJECT ON P.id = SOL_PER_PROJECT.project_id
RIGHT OUTER JOIN
(
SELECT
project_id AS project_id,
so_line AS sale_line_id,
array_agg(id),
SUM(TS.unit_amount) AS timesheet_unit_amount,
SUM(TS.amount) AS timesheet_cost
FROM account_analytic_line TS
WHERE project_id IS NOT NULL
GROUP BY project_id, so_line
) TIMESHEET_AMOUNT ON TIMESHEET_AMOUNT.project_id = SOL_PER_PROJECT.project_id AND TIMESHEET_AMOUNT.sale_line_id = SOL_PER_PROJECT.sale_line_id
LEFT JOIN sale_order_line SOL ON SOL_PER_PROJECT.sale_line_id = SOL.id
LEFT JOIN sale_order S ON SOL.order_id = S.id
LEFT JOIN currency_rate CR ON (CR.currency_id = SOL.currency_id
AND CR.currency_id != C.currency_id
AND CR.company_id = SOL.company_id
AND CR.date_start <= COALESCE(S.date_order, now())
AND (CR.date_end IS NULL OR cr.date_end > COALESCE(S.date_order, now())))
)
""" % (self._table, self.env['res.currency']._select_companies_rates())
self._cr.execute(query)
@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="project_profitability_report_view_pivot" model="ir.ui.view">
<field name="name">project.profitability.report.pivot</field>
<field name="model">project.profitability.report</field>
<field name="arch" type="xml">
<pivot string="Profitability Analysis" display_quantity="true" disable_linking="True">
<field name="project_id" type="row"/>
<field name="amount_untaxed_to_invoice" type="measure"/>
<field name="amount_untaxed_invoiced" type="measure"/>
<field name="timesheet_cost" type="measure"/>
</pivot>
</field>
</record>
<record id="project_profitability_report_view_graph" model="ir.ui.view">
<field name="name">project.profitability.report.graph</field>
<field name="model">project.profitability.report</field>
<field name="arch" type="xml">
<graph string="Profitability Analysis" type="bar">
<field name="project_id" type="row"/>
<field name="product_id" type="col"/>
<field name="amount_untaxed_to_invoice" type="measure"/>
<field name="amount_untaxed_invoiced" type="measure"/>
<field name="timesheet_cost" type="measure"/>
</graph>
</field>
</record>
<record id="project_profitability_report_view_search" model="ir.ui.view">
<field name="name">project.profitability.report.search</field>
<field name="model">project.profitability.report</field>
<field name="arch" type="xml">
<search string="Profitability Analysis">
<field name="project_id"/>
<field name="user_id"/>
<field name="product_id"/>
<field name="partner_id" filter_domain="[('partner_id', 'child_of', self)]"/>
<field name="company_id" groups="base.group_multi_company"/>
<filter string="My Project" name="my_project" domain="[('user_id','=', uid)]"/>
<group expand="1" string="Group By">
<filter string="Project" name="group_by_project" context="{'group_by':'project_id'}"/>
<filter string="Project Manager" name="group_by_user_id" context="{'group_by':'user_id'}"/>
<filter string="Customer" name="group_by_partner_id" context="{'group_by':'partner_id'}"/>
<filter string="Company" name="group_by_company" context="{'group_by':'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<record id="project_profitability_report_action" model="ir.actions.act_window">
<field name="name">Project Costs and Revenues</field>
<field name="res_model">project.profitability.report</field>
<field name="view_type">form</field>
<field name="view_mode">pivot,graph</field>
<field name="search_view_id" ref="project_profitability_report_view_search"/>
<field name="context">{
'group_by_no_leaf':1,
'group_by':[],
'sale_show_order_product_name': 1,
}</field>
<field name="help">This report allows you to analyse the profitability of your projects: compare the amount to invoice, the ones already invoiced and the project cost (via timesheet cost of your employees).</field>
</record>
<menuitem id="menu_project_profitability_analysis"
parent="hr_timesheet.menu_timesheets_reports"
action="project_profitability_report_action"
name="Project Costs and Revenues"
sequence="50"/>
</odoo>
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_profitability_report_analysis_manager,project.profitability.report.analysis,model_project_profitability_report,project.group_project_manager,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_profitability_report_analysis_manager project.profitability.report.analysis model_project_profitability_report project.group_project_manager 1 1 1 1
@@ -33,7 +33,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
var self = this;
var view_id = this.action && this.action.search_view_id && this.action.search_view_id[0];
var def = this
.loadViews('account.analytic.line', this.action.context || {}, [[view_id, 'search']])
.loadViews('project.project', this.action.context || {}, [[view_id, 'search']])
.then(function (result) {
self.fields_view = result.search;
});
@@ -63,7 +63,7 @@ var ProjectPlan = AbstractAction.extend(ControlPanelMixin, {
search_defaults: search_defaults,
};
var dataset = new data.DataSetSearch(this, 'account.analytic.line');
var dataset = new data.DataSetSearch(this, 'project.project');
this.searchview = new SearchView(this, dataset, this.fields_view, options);
this.searchview.on('search', this, this._onSearch);
@@ -74,5 +74,39 @@
}
}
.o_project_plan_project_timesheet_forecast {
th{
text-align: center;
}
th.o_right_bordered, td.o_right_bordered {
border-right: 2px solid @odoo-brand-optional;
text-align: center;
}
.o_timesheet_forecast_sale_order{
font-weight: bold;
& > td:first-child {
color: @odoo-brand-optional;
}
}
.o_timesheet_forecast_sale_order_line{
td:first-child > span {
margin-left: 15px;
}
td:first-child > a {
margin-left: 15px;
cursor: pointer;
}
}
.o_timesheet_forecast_hr_employee{
td:first-child > span {
margin-left: 30px;
}
}
.o_timesheet_plan_redirect {
cursor: pointer;
}
}
}
@@ -26,7 +26,7 @@ function createPlan(params) {
QUnit.module('Timesheet Plan', {
beforeEach: function () {
this.data = {
'account.analytic.line': {
'project.project': {
fields: {
account_id: {string: "Analytic account", type: "many2one", relation: "account.analytic.account"},
},
@@ -41,7 +41,7 @@ QUnit.module('Timesheet Plan', {
var plan = createPlan({
archs: {
'account.analytic.line,false,search': '<search></search>',
'project.project,false,search': '<search></search>',
},
data: this.data,
action: {},
@@ -61,7 +61,7 @@ QUnit.module('Timesheet Plan', {
},
});
assert.verifySteps([
"/web/dataset/call_kw/account.analytic.line",
"/web/dataset/call_kw/project.project",
"/timesheet/plan"
]);
assert.strictEqual(plan.$el.text(), 'Banach-Tarski', 'should have rendered html content');
@@ -75,7 +75,7 @@ QUnit.module('Timesheet Plan', {
var plan = createPlan({
archs: {
'account.analytic.line,false,search': '<search></search>',
'project.project,false,search': '<search></search>',
},
data: this.data,
action: {name: 'Fibonacci'},
+58
View File
@@ -29,10 +29,20 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart):
'user_type_id': cls.env.ref('account.data_account_type_revenue').id,
})
# Create projects
cls.project_global = cls.env['project.project'].create({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
})
cls.project_template = cls.env['project.project'].create({
'name': 'Project TEMPLATE for services',
'allow_timesheets': True,
})
cls.project_template_state = cls.env['project.task.type'].create({
'name': 'Only stage in project template',
'sequence': 1,
'project_ids': [(4, cls.project_template.id)]
})
# Create service products
uom_hour = cls.env.ref('uom.product_uom_hour')
@@ -98,6 +108,22 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart):
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_order_timesheet5 = cls.env['product.product'].create({
'name': "Service Ordered, create project only based on template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
'project_id': False,
'project_template_id': cls.project_template.id,
'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({
@@ -160,6 +186,22 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart):
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_timesheet5 = cls.env['product.product'].create({
'name': "Service delivered, create project only based on template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'default_code': 'SERV-DELI4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
'project_template_id': cls.project_template.id,
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# -- milestones (delivered, manual)
cls.product_delivery_manual1 = cls.env['product.product'].create({
@@ -222,3 +264,19 @@ class TestCommonSaleTimesheetNoChart(TestCommonSaleNoChart):
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual5 = cls.env['product.product'].create({
'name': "Service delivered, create project only with template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'project_template_id': cls.project_template.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
+161 -15
View File
@@ -37,13 +37,15 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
self.sale_order.action_confirm()
self.sale_order.order_line._compute_product_updatable()
self.sale_order.action_confirm()
self.sale_order.order_line._compute_product_updatable()
self.assertFalse(sale_order_line.product_updatable)
self.assertEqual(self.sale_order.invoice_status, 'no', 'Sale Service: there should be nothing to invoice after validation')
# check task creation
project = self.project_global
task = project.task_ids.filtered(lambda t: t.name == '%s:%s' % (self.sale_order.name, self.product_delivery_timesheet2.name))
self.assertTrue(task, 'Sale Service: task is not created')
task = project.task_ids.filtered(lambda t: t.name == '%s: %s' % (self.sale_order.name, self.product_delivery_timesheet2.name))
self.assertTrue(task, 'Sale Service: task is not created, or it badly named')
self.assertEqual(task.partner_id, self.sale_order.partner_id, 'Sale Service: customer should be the same on task and on SO')
self.assertEqual(task.email_from, self.sale_order.partner_id.email, 'Sale Service: Task Email should be the same as the SO customer Email')
@@ -106,16 +108,16 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.sale_order.project_project_id.id,
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 16,
'employee_id': self.employee_manager.id,
})
self.assertEqual(self.sale_order.order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
self.assertEqual(sale_order_line.qty_delivered, 2, 'Sale: uom conversion of timesheets is wrong')
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': self.sale_order.project_project_id.id,
'project_id': task.project_id.id,
'task_id': task.id,
'unit_amount': 24,
'employee_id': self.employee_user.id,
@@ -124,7 +126,7 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
self.assertEqual(self.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
# create SO line and confirm it
so_line_deliver_global_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet2.name,
'product_id': self.product_delivery_timesheet2.id,
@@ -174,23 +176,17 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
task_serv2.write({'sale_line_id': False})
def test_delivered_quantity(self):
# create SO and confirm it
sale_order = self.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': self.partner_customer_usd.id,
'partner_invoice_id': self.partner_customer_usd.id,
'partner_shipping_id': self.partner_customer_usd.id,
'pricelist_id': self.pricelist_usd.id,
})
# create SO line and confirm it
so_line_deliver_new_task_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet3.name,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 10,
'product_uom': self.product_delivery_timesheet3.uom_id.id,
'price_unit': self.product_delivery_timesheet3.list_price,
'order_id': sale_order.id,
'order_id': self.sale_order.id,
})
so_line_deliver_new_task_project.product_id_change()
sale_order.action_confirm()
self.sale_order.action_confirm()
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_new_task_project.id)])
# add a timesheet
@@ -227,3 +223,153 @@ class TestSaleService(TestCommonSaleTimesheetNoChart):
# remove timesheet2
timesheet2.unlink()
self.assertEqual(so_line_deliver_new_task_project.qty_delivered, timesheet3.unit_amount, 'Delivered quantity should be reset to the sum of remaining timesheets unit amounts.')
def test_sale_create_project(self):
""" A SO with multiple product that should create project (with and without template) like ;
Line 1 : Service 1 create project with Template A ===> project created with template A
Line 2 : Service 2 create project no template ==> empty project created
Line 3 : Service 3 create project with Template A ===> Don't create any project because line 1 has already created a project with template A
Line 4 : Service 4 create project no template ==> Don't create any project because line 2 has already created an empty project
Line 5 : Service 5 create project with Template B ===> project created with template B
"""
# second project template and its associated product
project_template2 = self.env['project.project'].create({
'name': 'Second Project TEMPLATE for services',
'allow_timesheets': True,
})
Stage = self.env['project.task.type'].with_context(default_project_id=project_template2.id)
stage1_tmpl2 = Stage.create({
'name': 'Stage 1',
'sequence': 1
})
stage2_tmpl2 = Stage.create({
'name': 'Stage 2',
'sequence': 2
})
product_deli_ts_tmpl = self.env['product.product'].create({
'name': "Service delivered, create project only based on template B",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': self.env.ref('uom.product_uom_hour').id,
'uom_po_id': self.env.ref('uom.product_uom_hour').id,
'default_code': 'SERV-DELI4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
'project_template_id': project_template2.id,
'project_id': False,
'taxes_id': False,
'property_account_income_id': self.account_sale.id,
})
# create 5 so lines
so_line1 = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet5.name,
'product_id': self.product_delivery_timesheet5.id,
'product_uom_qty': 11,
'product_uom': self.product_delivery_timesheet5.uom_id.id,
'price_unit': self.product_delivery_timesheet5.list_price,
'order_id': self.sale_order.id,
})
so_line2 = 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': self.sale_order.id,
})
so_line3 = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet5.name,
'product_id': self.product_delivery_timesheet5.id,
'product_uom_qty': 5,
'product_uom': self.product_delivery_timesheet5.uom_id.id,
'price_unit': self.product_delivery_timesheet5.list_price,
'order_id': self.sale_order.id,
})
so_line4 = self.env['sale.order.line'].create({
'name': self.product_delivery_manual3.name,
'product_id': self.product_delivery_manual3.id,
'product_uom_qty': 4,
'product_uom': self.product_delivery_manual3.uom_id.id,
'price_unit': self.product_delivery_manual3.list_price,
'order_id': self.sale_order.id,
})
so_line5 = self.env['sale.order.line'].create({
'name': product_deli_ts_tmpl.name,
'product_id': product_deli_ts_tmpl.id,
'product_uom_qty': 8,
'product_uom': product_deli_ts_tmpl.uom_id.id,
'price_unit': product_deli_ts_tmpl.list_price,
'order_id': self.sale_order.id,
})
# confirm SO
self.sale_order.action_confirm()
# check each line has or no generate something
self.assertTrue(so_line1.project_id, "Line1 should have create a project based on template A")
self.assertTrue(so_line2.project_id, "Line2 should have create an empty project")
self.assertFalse(so_line3.project_id, "Line3 should not have create a project, since line1 already create a project based on template A")
self.assertFalse(so_line4.project_id, "Line4 should not have create a project, since line1 already create an empty project")
self.assertTrue(so_line4.task_id, "Line4 should have create a new task, even if no project created.")
self.assertTrue(so_line5.project_id, "Line5 should have create a project based on template B")
# check generated stuff are correct
self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1")
self.assertTrue(so_line1.project_id in self.project_template_state.project_ids, "Stage 1 from template B should be part of project from so line 1")
self.assertTrue(so_line5.project_id in stage1_tmpl2.project_ids, "Stage 1 from template B should be part of project from so line 5")
self.assertTrue(so_line5.project_id in stage2_tmpl2.project_ids, "Stage 2 from template B should be part of project from so line 5")
self.assertTrue(so_line1.project_id.allow_timesheets, "Create project should allow timesheets")
self.assertTrue(so_line2.project_id.allow_timesheets, "Create project should allow timesheets")
self.assertTrue(so_line5.project_id.allow_timesheets, "Create project should allow timesheets")
self.assertEqual(so_line4.task_id.project_id, so_line2.project_id, "Task created with line 4 should have the project based on template A of the SO.")
self.assertEqual(so_line1.project_id.sale_line_id, so_line1, "SO line of project with template A should be the one that create it.")
self.assertEqual(so_line2.project_id.sale_line_id, so_line2, "SO line of project should be the one that create it.")
self.assertEqual(so_line5.project_id.sale_line_id, so_line5, "SO line of project with template B should be the one that create it.")
def test_billable_task_and_subtask(self):
""" Test if subtasks and tasks are billed on the correct SO line """
# create SO line and confirm it
so_line_deliver_new_task_project = self.env['sale.order.line'].create({
'name': self.product_delivery_timesheet3.name,
'product_id': self.product_delivery_timesheet3.id,
'product_uom_qty': 10,
'product_uom': self.product_delivery_timesheet3.uom_id.id,
'price_unit': self.product_delivery_timesheet3.list_price,
'order_id': self.sale_order.id,
})
so_line_deliver_new_task_project.product_id_change()
self.sale_order.action_confirm()
project = so_line_deliver_new_task_project.project_id
task = so_line_deliver_new_task_project.task_id
self.assertEqual(project.sale_line_id, so_line_deliver_new_task_project, "The created project should be linked to the so line")
self.assertEqual(task.sale_line_id, so_line_deliver_new_task_project, "The created task should be linked to the so line")
# create a new task and subtask
subtask = self.env['project.task'].create({
'parent_id': task.id,
'project_id': project.id,
'name': '%s: substask1' % (task.name,),
'sale_line_id': False # forcing, but should have not effect
})
task2 = self.env['project.task'].create({
'project_id': project.id,
'name': '%s: substask1' % (task.name,)
})
self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should have the same SO line than its mother")
self.assertEqual(task2.sale_line_id, project.sale_line_id, "A new task in a billable project should have the same SO line than its project")
self.assertEqual(task2.partner_id, so_line_deliver_new_task_project.order_partner_id, "A new task in a billable project should have the same SO line than its project")
# moving subtask in another project
subtask.write({'project_id': self.project_global.id})
self.assertEqual(subtask.sale_line_id, task.sale_line_id, "A child task should always have the same SO line than its mother, even when changing project")
@@ -56,9 +56,11 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
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)])
project_serv1 = self.env['project.project'].search([('sale_line_id', '=', so_line_ordered_project_only.id)])
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.")
self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 2 'task in global project'")
self.assertEqual(sale_order.analytic_account_id, project_serv1.analytic_account_id, "The created project should be linked to the analytic account of the SO")
# create invoice
sale_order.action_invoice_create()
@@ -66,7 +68,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# 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': sale_order.project_project_id.id,
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 10.5,
'employee_id': self.employee_user.id,
@@ -76,7 +78,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id,
'project_id': task_serv2.project_id.id,
'task_id': task_serv2.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
@@ -86,7 +88,7 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id,
'project_id': task_serv2.project_id.id,
'unit_amount': 10,
'employee_id': self.employee_user.id,
})
@@ -95,14 +97,14 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# 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,
'project_id': task_serv2.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)
# add so line with produdct "create task in new project".
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,
@@ -111,16 +113,16 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
'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")
self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'new task in new project' product, since it reuse the one from 'project only'.")
# 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
@@ -159,21 +161,24 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# 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)])
task_serv1 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_global_project.id)])
task_serv2 = self.env['project.task'].search([('sale_line_id', '=', so_line_deliver_task_project.id)])
project_serv2 = self.env['project.project'].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(task_serv1.project_id, self.project_global, "Sale Timesheet: task should be created in global project")
self.assertTrue(task_serv1, "Sale Timesheet: on SO confirmation, a task should have been created in global project")
self.assertTrue(task_serv2, "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.analytic_account_id, task_serv2.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")
self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one from SO line 1 'task in global project'")
self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO")
# let's log some timesheets
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task_serv2.project_id.id, # global project
'task_id': task_serv2.id,
'project_id': task_serv1.project_id.id, # global project
'task_id': task_serv1.id,
'unit_amount': 10.5,
'employee_id': self.employee_manager.id,
})
@@ -189,8 +194,8 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# log some timesheets again
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': task_serv2.project_id.id, # global project
'task_id': task_serv2.id,
'project_id': task_serv1.project_id.id, # global project
'task_id': task_serv1.id,
'unit_amount': 39.5,
'employee_id': self.employee_user.id,
})
@@ -212,12 +217,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
'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")
self.assertEqual(len(sale_order.project_ids), 2, "No new project should have been created by the SO, when selling 'project only' product, since it reuse the one from 'new task in new project'.")
# let's log some timesheets on the project
self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id, # global project
'project_id': project_serv2.id, # global project
'unit_amount': 7,
'employee_id': self.employee_user.id,
})
@@ -253,9 +258,13 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
# confirm SO
sale_order.action_confirm()
self.assertTrue(sale_order.project_project_id, "Sales Order should have create a project")
self.assertTrue(sale_order.project_ids, "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')
project_serv2 = so_line_manual_only_project.project_id
self.assertTrue(project_serv2, "A second project is created when selling 'project only' after SO confirmation.")
self.assertEqual(sale_order.analytic_account_id, project_serv2.analytic_account_id, "The created project should be linked to the analytic account of the SO")
# let's log some timesheets (on task and project)
timesheet1 = self.env['account.analytic.line'].create({
'name': 'Test Line',
@@ -267,11 +276,12 @@ class TestSaleTimesheet(TestCommonSaleTimesheetNoChart):
timesheet2 = self.env['account.analytic.line'].create({
'name': 'Test Line',
'project_id': sale_order.project_project_id.id, # global project
'project_id': self.project_global.id, # global project
'unit_amount': 3,
'employee_id': self.employee_manager.id,
})
self.assertEqual(len(sale_order.project_ids), 2, "One project should have been created by the SO, when confirmed + the one coming from SO line 1 'task in global project'.")
self.assertEqual(so_line_manual_global_project.task_id.sale_line_id, so_line_manual_global_project, "Task from a milestone product should be linked to its SO line too")
self.assertEqual(timesheet1.timesheet_invoice_type, 'billable_fixed', "Milestone timesheet goes in billable fixed category")
self.assertTrue(float_is_zero(so_line_manual_global_project.qty_delivered, precision_digits=2), "Milestone Timesheeting should not incremented the delivered quantity on the SO line")
@@ -43,24 +43,24 @@
<tbody>
<tr>
<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>
<a type="action" t-att-domain="json.dumps(timesheet_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']" t-options="{'widget': 'float_time'}"/>
</td>
<td>Timesheets</td>
<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>
<a type="action" t-att-domain="json.dumps(timesheet_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>Timesheets</td>
<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>
<a type="action" t-att-domain="json.dumps(timesheet_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">
<t t-esc="dashboard['money_amount']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<t t-esc="dashboard['profit']['invoiced']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td>Invoiced</td>
</tr>
@@ -74,7 +74,7 @@
</td>
<td>Fixed</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['money_amount']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<t t-esc="dashboard['profit']['to_invoice']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td>To invoice</td>
</tr>
@@ -88,19 +88,19 @@
</td>
<td>No task found</td>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['money_amount']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<t t-esc="dashboard['profit']['cost']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td>Cost</td>
<td>Timesheet costs</td>
</tr>
<tr>
<td class="o_timesheet_plan_dashboard_cell">
<t t-esc="dashboard['hours']['non_billable']" t-options="{'widget': 'float_time'}"/>
</td>
<td>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>Non billable</td>
<td class="o_timesheet_plan_dashboard_cell"></td>
<td></td>
</tr>
@@ -112,7 +112,7 @@
<td></td>
<td></td>
<td class="o_timesheet_plan_dashboard_total">
<t t-esc="dashboard['money_amount']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
<t t-esc="dashboard['profit']['total']" t-options='{"widget": "monetary", "display_currency": currency}'/>
</td>
<td><b>Total</b></td>
</tr>
@@ -128,16 +128,16 @@
<t t-if="repartition_employee">
<div class="pull-right o_timesheet_plan_badge">
<span class="badge o_progress_billable_fixed">
<a type="action" t-att-domain="json.dumps(domain + [('timesheet_invoice_type','=','billable_fixed')])">Billable fixed</a>
<a type="action" t-att-domain="json.dumps(timesheet_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>
<a type="action" t-att-domain="json.dumps(timesheet_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>
<a type="action" t-att-domain="json.dumps(timesheet_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>
<a type="action" t-att-domain="json.dumps(timesheet_domain + [('timesheet_invoice_type','=','non_billable')])">Non billable</a>
</span>
</div>
<table class="table">
@@ -145,7 +145,7 @@
<t t-foreach="repartition_employee" t-as="employee_id">
<tr>
<td style="width: 15%">
<a type="action" t-att-domain="json.dumps(domain)" t-att-context="{'search_default_employee_id': employee_id}" views="[[0, 'list']]">
<a type="action" t-att-domain="json.dumps(timesheet_domain)" t-att-context="{'search_default_employee_id': employee_id}" views="[[0, 'list']]">
<t t-esc="repartition_employee[employee_id]['employee_name']"/>
</a>
</td>
@@ -153,7 +153,7 @@
<t t-esc="repartition_employee[employee_id]['total']" t-options="{'widget': 'float_time'}"/>
</td>
<td>
<div class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
<div t-if="repartition_employee_max" class="progress" t-att-style="'width: ' + str(repartition_employee[employee_id]['total'] / repartition_employee_max * 100) +'%'">
<t t-set="total" t-value="repartition_employee[employee_id]['total']" />
<t t-set="billable_fixed" t-value="repartition_employee[employee_id]['billable_fixed']" />
@@ -201,6 +201,56 @@
</table>
</t>
</div>
<!-- NOTE: this template to display a table works whatever the length of the rows, as project_timesheet_forecast_sale extends the table to add forecasts -->
<div class="o_project_plan_project_timesheet_forecast">
<t t-if="timesheet_forecast_table and timesheet_forecast_table['rows']">
<h3 name="table_plan_title">Timesheets</h3>
<table class="table">
<thead>
<tr>
<t t-foreach="timesheet_forecast_table['header']" t-as="header_val">
<th t-att-class="'o_right_bordered' if header_val_index in [5,10] else ''">
<span><t t-esc="header_val"/></span>
</th>
</t>
</tr>
</thead>
<tbody>
<t t-foreach="timesheet_forecast_table['rows']" t-as="row">
<t t-set="row_type" t-value="row[0].get('type')"/>
<tr t-att-class="'o_timesheet_forecast_' + row_type">
<t t-foreach="row" t-as="row_value">
<td t-att-class="'o_right_bordered' if row_value_index in [5,10] else '' + ' text-center' if row_value_index != 0 else ''">
<t t-if="row_value_index == 0">
<t t-if="row_type == 'sale_order'">
<a t-att-data-oe-model="row_value['res_model']" t-att-data-oe-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
<t t-esc="row_value.get('label')"/>
</a>
</t>
<t t-if="row_type != 'sale_order'">
<span><t t-esc="row_value.get('label')"/></span>
</t>
</t>
<t t-if="row_value_index != 0">
<t t-if="row_value_index &gt; 10">
<t t-if="row_type == 'sale_order'">
<t t-esc="row_value" t-options="{'widget': 'float_time'}"/>
</t>
</t>
<t t-if="row_value_index &lt;= 10">
<t t-esc="row_value" t-options="{'widget': 'float_time'}"/>
</t>
</t>
</td>
</t>
</tr>
</t>
</tbody>
</table>
</t>
</div>
</div>
</div>
</div>
@@ -61,44 +61,6 @@
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
-->
-20
View File
@@ -1,20 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_timesheet_employee_extd_form" model="ir.ui.view">
<field name="name">hr.timesheet.employee.extd_form</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr_timesheet.hr_employee_view_form_inherit_timesheet"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='timesheet']" position="inside">
<label for="timesheet_cost"/>
<div>
<field name="timesheet_cost" class="oe_inline"/> per hour
<field name="currency_id" invisible="1"/>
</div>
</xpath>
<xpath expr="//group[@name='timesheet']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
</field>
</record>
</odoo>
@@ -12,11 +12,9 @@
<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')]}"
/>
<field name="project_id" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"/>
<field name="project_template_id" context="{'active_test': False}" attrs="{'invisible':[('service_tracking','not in',['task_new_project', 'project_only'])]}"/>
</xpath>
<field name="service_type" position="after">
</field>
</field>
</record>
@@ -16,6 +16,18 @@
</field>
</record>
<record id="project_project_view_search" model="ir.ui.view">
<field name="name">project.project.search.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.view_project_project_filter"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='name']" position="after">
<field name="display_name" filter_domain="[('name','=',self)]" string="Exact name"/>
<field name="sale_line_id"/>
</xpath>
</field>
</record>
<record id="view_sale_service_inherit_form2" model="ir.ui.view">
<field name="name">sale.service.form.view.inherit</field>
<field name="model">project.task</field>
@@ -34,6 +46,18 @@
</field>
</record>
<record id="project_task_view_search" model="ir.ui.view">
<field name="name">project.task.search.inherit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_search_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='stage_id']" position="after">
<field name="sale_line_id"/>
<field name="sale_order_id"/>
</xpath>
</field>
</record>
<record id="project_task_view_form_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">project.task.form.inherit.timesheet</field>
<field name="model">project.task</field>
@@ -47,4 +71,12 @@
</xpath>
</field>
</record>
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.client">
<field name="name">Overview</field>
<field name="tag">timesheet.plan</field>
<field name="res_model">project.project</field>
<field name="params" eval="{}"/>
</record>
</odoo>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_task_assign_so_line
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectTaskAssignSOLine(models.TransientModel):
_name = 'project.task.assign.sale'
_description = "Assign Sale Order line to tasks"
@api.model
def default_get(self, fields):
result = super(ProjectTaskAssignSOLine, self).default_get(fields)
if self._context.get('active_model') == 'project.task' and 'task_ids' in fields:
result['task_ids'] = self._context.get('active_ids', [])
return result
sale_line_id = fields.Many2one('sale.order.line', 'Sales Order Item', domain=[('is_service', '=', True)], help="Sale order line to link to selected tasks")
partner_id = fields.Many2one('res.partner', related='sale_line_id.order_partner_id', readonly=True)
task_ids = fields.Many2many('project.task', 'project_task_assign_so_line_rel', 'task_id', 'wizard_id', string='Tasks', domain=[('parent_id', '=', False)], help="Select the tasks to assign to the Sale Order Items. You can only choose task (no sub tasks).")
@api.multi
def action_assign_sale_line(self):
for wizard in self:
wizard.task_ids.write({
'sale_line_id': wizard.sale_line_id.id,
'partner_id': wizard.partner_id.id,
})
return {'type': 'ir.actions.act_window_close'}
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="project_task_assign_so_line_view_form" model="ir.ui.view">
<field name="name">project.task.assign.sale.wizard.form</field>
<field name="model">project.task.assign.sale</field>
<field name="arch" type="xml">
<form string="Assign sale line to Tasks">
<group>
<field name="task_ids" widget="many2many_tags"/>
<field name="sale_line_id" options="{'no_create_edit': True}" context="{'sale_show_order_product_name': True}"/>
<field name="partner_id"/>
</group>
<footer>
<button string="Assign" type="object" name="action_assign_sale_line" class="oe_highlight"/>
<button string="Cancel" special="cancel" type="object" class="btn btn-default oe_inline"/>
</footer>
</form>
</field>
</record>
<record id="project_task_action_multi_assign_so_line" model="ir.actions.act_window">
<field name="name">Assign Sale Order Item</field>
<field name="type">ir.actions.act_window</field>
<field name="res_model">project.task.assign.sale</field>
<field name="view_type">form</field>
<field name="view_mode">form</field>
<field name="view_id" ref="project_task_assign_so_line_view_form"/>
<field name="target">new</field>
<field name="multi">True</field>
<field name="binding_model_id" ref="project.model_project_task"/>
</record>
</odoo>