[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:
@@ -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',
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 > 80 and record.progress.raw_value < 100">
|
||||
<div t-att-class="'oe_kanban_align label label-' + (record.progress.raw_value >= 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 >= 99 ? 'danger': 'warning')" title="Remaining hours">
|
||||
<field name="remaining_hours" widget="float_time" />
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
'depends': [
|
||||
'base_setup',
|
||||
'product',
|
||||
'analytic',
|
||||
'mail',
|
||||
'portal',
|
||||
'rating',
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
from . import account
|
||||
from . import account_invoice
|
||||
from . import hr
|
||||
from . import product
|
||||
from . import project
|
||||
from . import sale_order
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
@@ -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'},
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 > 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 <= 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
|
||||
-->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user