[IMP] project,web_form_project : add the handling of AAL for
project_profitability + change the name displayed in portal This commit's purpose is to - add the computation of the AAL for the project profitabity. If a the analytic account of a project contains AAL that were manually added ( and thus not linked to any sol/purchase/etc ) those lines are not computed in the 'other costs/other revenues section. - to display the title of the ticket/task/project in the name of the page My ticket/My task/My project of the portal task-2960753 closes odoo/odoo#106438 Related: odoo/enterprise#34344 Signed-off-by: Xavier Bol (xbo) <xbo@odoo.com>
This commit is contained in:
@@ -710,6 +710,9 @@ class Project(models.Model):
|
||||
return [('account_id', 'in', self.analytic_account_id.ids)]
|
||||
|
||||
def _get_profitability_items(self, with_action=True):
|
||||
return self._get_items_from_aal(with_action)
|
||||
|
||||
def _get_items_from_aal(self, with_action=True):
|
||||
return {
|
||||
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
|
||||
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
|
||||
|
||||
@@ -45,9 +45,8 @@ class TestProfitability(TestProjectProfitabilityCommon):
|
||||
In this module, the project profitability should have no data.
|
||||
So the no revenue and cost should be found.
|
||||
"""
|
||||
profitability_items = self.project._get_profitability_items(False)
|
||||
self.assertDictEqual(
|
||||
profitability_items,
|
||||
self.project._get_profitability_items(False),
|
||||
self.project_profitability_items_empty,
|
||||
'The profitability data of the project should be return no data and so 0 for each total amount.'
|
||||
)
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
|
||||
<template id="portal_my_project" name="My Project">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="title" t-value="project.name"/>
|
||||
<t t-set="o_portal_fullwidth_alert" groups="project.group_project_user">
|
||||
<t t-call="portal.portal_back_in_edit_mode">
|
||||
<t t-set="backend_url" t-value="'/web#model=project.project&id=%s&view_type=kanban' % (project.id)"/>
|
||||
|
||||
@@ -118,6 +118,7 @@
|
||||
|
||||
<template id="portal_my_task" name="My Task" inherit_id="portal.portal_sidebar" primary="True">
|
||||
<xpath expr="//div[hasclass('o_portal_sidebar')]" position="inside">
|
||||
<t t-set="title" t-value="task.name"/>
|
||||
<t t-set="o_portal_fullwidth_alert" groups="project.group_project_user">
|
||||
<t t-call="portal.portal_back_in_edit_mode">
|
||||
<t t-set="backend_url" t-value="'/web#model=project.task&id=%s&action=%s&view_type=form' % (task.id, task.env.ref('project.action_view_my_task').id)"/>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
@@ -0,0 +1,18 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
{
|
||||
'name': "Project - Account",
|
||||
'summary': "project profitability items computation",
|
||||
'description': """
|
||||
Allows the computation of some section for the project profitability
|
||||
==================================================================================================
|
||||
This module allows the computation of the 'Vendor Bills', 'Other Costs' and 'Other Revenues' section for the project profitability, in the project update view.
|
||||
""",
|
||||
'category': 'Hidden',
|
||||
'depends': ['account', 'project'],
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
'data': [
|
||||
'views/account_analytic_line_views.xml',
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import project_project
|
||||
@@ -0,0 +1,160 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from odoo import models, _lt
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
_inherit = 'project.project'
|
||||
|
||||
def _add_purchase_items(self, profitability_items, with_action=True):
|
||||
domain = [
|
||||
('move_type', 'in', ['in_invoice', 'in_refund']),
|
||||
('parent_state', 'in', ['draft', 'posted']),
|
||||
('price_subtotal', '>', 0)
|
||||
]
|
||||
with_action = with_action and self.user_has_groups('account.group_account_invoice, account.group_account_readonly')
|
||||
self._get_costs_items_from_purchase(domain, profitability_items, with_action=with_action)
|
||||
|
||||
def _get_costs_items_from_purchase(self, domain, profitability_items, with_action=True):
|
||||
""" This method is used in sale_project and project_purchase. Since project_account is the only common module (except project), we create the method here. """
|
||||
# calculate the cost of bills without a purchase order
|
||||
query = self.env['account.move.line'].sudo()._search(domain)
|
||||
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
|
||||
# account_move_line__move_id is the alias of the joined table account_move in the query
|
||||
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
|
||||
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
|
||||
query_string, query_param = query.select('price_subtotal', 'parent_state', 'account_move_line.currency_id', 'account_move_line.analytic_distribution', 'account_move_line__move_id.move_type', 'move_id')
|
||||
self._cr.execute(query_string, query_param)
|
||||
bills_move_line_read = self._cr.dictfetchall()
|
||||
if bills_move_line_read:
|
||||
# Get conversion rate from currencies to currency of analytic account
|
||||
currency_ids = {bml['currency_id'] for bml in bills_move_line_read + [{'currency_id': self.analytic_account_id.currency_id.id}]}
|
||||
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
|
||||
conversion_rates = {cid: rates[self.analytic_account_id.currency_id.id] / rate_from for cid, rate_from in rates.items()}
|
||||
amount_invoiced = amount_to_invoice = 0.0
|
||||
move_ids = set()
|
||||
for moves_read in bills_move_line_read:
|
||||
price_subtotal = self.analytic_account_id.currency_id.round(moves_read['price_subtotal'] * conversion_rates[moves_read['currency_id']])
|
||||
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
|
||||
move_ids.add(moves_read['move_id'])
|
||||
if moves_read['parent_state'] == 'draft':
|
||||
if moves_read['move_type'] == 'in_invoice':
|
||||
amount_to_invoice -= price_subtotal * analytic_contribution
|
||||
else: # moves_read['move_type'] == 'in_refund'
|
||||
amount_to_invoice += price_subtotal * analytic_contribution
|
||||
else: # moves_read['parent_state'] == 'posted'
|
||||
if moves_read['move_type'] == 'in_invoice':
|
||||
amount_invoiced -= price_subtotal * analytic_contribution
|
||||
else: # moves_read['move_type'] == 'in_refund'
|
||||
amount_invoiced += price_subtotal * analytic_contribution
|
||||
# don't display the section if the final values are both 0 (bill -> vendor credit)
|
||||
if amount_invoiced != 0 or amount_to_invoice != 0:
|
||||
costs = profitability_items['costs']
|
||||
section_id = 'other_purchase_costs'
|
||||
bills_costs = {
|
||||
'id': section_id,
|
||||
'sequence': self._get_profitability_sequence_per_invoice_type()[section_id],
|
||||
'billed': amount_invoiced,
|
||||
'to_bill': amount_to_invoice,
|
||||
}
|
||||
if with_action:
|
||||
bills_costs['action'] = self._get_action_for_profitability_section(list(move_ids), section_id)
|
||||
costs['data'].append(bills_costs)
|
||||
costs['total']['billed'] += amount_invoiced
|
||||
costs['total']['to_bill'] += amount_to_invoice
|
||||
|
||||
def _get_action_for_profitability_section(self, record_ids, name):
|
||||
self.ensure_one()
|
||||
args = [name, [('id', 'in', record_ids)]]
|
||||
if len(record_ids) == 1:
|
||||
args.append(record_ids[0])
|
||||
return {'name': 'action_profitability_items', 'type': 'object', 'args': json.dumps(args)}
|
||||
|
||||
def _get_profitability_labels(self):
|
||||
return {
|
||||
**super()._get_profitability_labels(),
|
||||
'other_purchase_costs': _lt('Vendor Bills'),
|
||||
'other_revenues': _lt('Other Revenues'),
|
||||
'other_costs': _lt('Other Costs'),
|
||||
}
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
return {
|
||||
**super()._get_profitability_sequence_per_invoice_type(),
|
||||
'other_purchase_costs': 11,
|
||||
'other_revenues': 14,
|
||||
'other_costs': 15,
|
||||
}
|
||||
|
||||
def action_profitability_items(self, section_name, domain=None, res_id=False):
|
||||
if section_name in ['other_revenues', 'other_costs']:
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("analytic.account_analytic_line_action_entries")
|
||||
action['domain'] = domain
|
||||
action['context'] = {
|
||||
'group_by_date': True,
|
||||
}
|
||||
if res_id:
|
||||
action['views'] = [(False, 'form')]
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = res_id
|
||||
else:
|
||||
pivot_view_id = self.env['ir.model.data']._xmlid_to_res_id('project_account.project_view_account_analytic_line_pivot')
|
||||
graph_view_id = self.env['ir.model.data']._xmlid_to_res_id('project_account.project_view_account_analytic_line_graph')
|
||||
action['views'] = [(pivot_view_id, view_type) if view_type == 'pivot' else (graph_view_id, view_type) if view_type == 'graph' else (view_id, view_type)
|
||||
for (view_id, view_type) in action['views']]
|
||||
return action
|
||||
|
||||
if section_name == 'other_purchase_costs':
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_in_invoice_type")
|
||||
action['domain'] = domain or []
|
||||
if res_id:
|
||||
action['views'] = [(False, 'form')]
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = res_id
|
||||
return action
|
||||
|
||||
return super().action_profitability_items(section_name, domain, res_id)
|
||||
|
||||
def _get_domain_aal_with_no_move_line(self):
|
||||
""" this method is used in order to overwrite the domain in sale_timesheet module. Since the field 'project_id' is added to the "analytic line" model
|
||||
in the hr_timesheet module, we can't add the condition ('project_id', '=', False) here. """
|
||||
return [('account_id', '=', self.analytic_account_id.id), ('move_line_id', '=', False)]
|
||||
|
||||
def _get_items_from_aal(self, with_action=True):
|
||||
domain = self._get_domain_aal_with_no_move_line()
|
||||
aal_other_search = self.env['account.analytic.line'].sudo().search_read(domain, ['id', 'amount'])
|
||||
if not aal_other_search:
|
||||
return {
|
||||
'revenues': {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}},
|
||||
'costs': {'data': [], 'total': {'billed': 0.0, 'to_bill': 0.0}},
|
||||
}
|
||||
total_revenues = total_costs = 0.0
|
||||
cost_ids = []
|
||||
revenue_ids = []
|
||||
for aal in aal_other_search:
|
||||
aal_amount = aal['amount']
|
||||
if aal_amount < 0.0:
|
||||
total_costs += aal_amount
|
||||
cost_ids.append(aal['id'])
|
||||
else:
|
||||
total_revenues += aal_amount
|
||||
revenue_ids.append(aal['id'])
|
||||
|
||||
# we dont know what part of the numbers has already been billed or not, so we have no choice but to put everything under the billed/invoiced columns.
|
||||
# The to bill/to invoice ones will simply remain 0
|
||||
profitability_sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
|
||||
revenues = {'id': 'other_revenues', 'sequence': profitability_sequence_per_invoice_type['other_revenues'], 'invoiced': total_revenues, 'to_invoice': 0.0}
|
||||
costs = {'id': 'other_costs', 'sequence': profitability_sequence_per_invoice_type['other_costs'], 'billed': total_costs, 'to_bill': 0.0}
|
||||
|
||||
if with_action and self.user_has_groups('account.group_account_readonly'):
|
||||
costs['action'] = self._get_action_for_profitability_section(cost_ids, 'other_costs')
|
||||
revenues['action'] = self._get_action_for_profitability_section(revenue_ids, 'other_revenues')
|
||||
|
||||
return {
|
||||
'revenues': {'data': [revenues], 'total': {'invoiced': total_revenues, 'to_invoice': 0.0}},
|
||||
'costs': {'data': [costs], 'total': {'billed': total_costs, 'to_bill': 0.0}},
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import test_project_profitability
|
||||
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import tagged
|
||||
|
||||
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon):
|
||||
|
||||
def test_project_profitability(self):
|
||||
"""
|
||||
In this module, the project profitability should be computed while checking the AAL data.
|
||||
The Other Revenue and Other Cost sections should be displayed if some data are available.
|
||||
"""
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False),
|
||||
self.project_profitability_items_empty,
|
||||
'The profitability data of the project should return no data and so 0 for each total amount.'
|
||||
)
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'extra revenues 1',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': 100,
|
||||
}, {
|
||||
'name': 'extra costs 1',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': -100,
|
||||
}, {
|
||||
'name': 'extra revenues 2',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': 50,
|
||||
}, {
|
||||
'name': 'extra costs 2',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': -50,
|
||||
}])
|
||||
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False),
|
||||
{
|
||||
'revenues': {'data': [{'id': 'other_revenues', 'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_revenues'],
|
||||
'invoiced': 150.0, 'to_invoice': 0.0}], 'total': {'invoiced': 150.0, 'to_invoice': 0.0}},
|
||||
'costs': {'data': [{'id': 'other_costs', 'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'billed': -150.0, 'to_bill': 0.0}], 'total': {'billed': -150.0, 'to_bill': 0.0}}
|
||||
},
|
||||
'The profitability data of the project should return the total amount for the revenues and costs from tha AAL of the account of the project.'
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="project_view_account_analytic_line_graph" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.graph</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="analytic.view_account_analytic_line_graph"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//graph" position="inside">
|
||||
<field name="date" type="row"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='account_id']" position="replace">
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="project_view_account_analytic_line_pivot" model="ir.ui.view">
|
||||
<field name="name">account.analytic.line.pivot</field>
|
||||
<field name="model">account.analytic.line</field>
|
||||
<field name="inherit_id" ref="analytic.view_account_analytic_line_pivot"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//pivot" position="inside">
|
||||
<field name="date" type="row"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='partner_id']" position="attributes">
|
||||
<attribute name="invisible">context.get('group_by_date', False)</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='account_id']" position="replace">
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -64,7 +64,7 @@ class Project(models.Model):
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
sequence_per_invoice_type = super()._get_profitability_sequence_per_invoice_type()
|
||||
sequence_per_invoice_type['expenses'] = 11
|
||||
sequence_per_invoice_type['expenses'] = 13
|
||||
return sequence_per_invoice_type
|
||||
|
||||
def _get_already_included_profitability_invoice_line_ids(self):
|
||||
|
||||
@@ -53,7 +53,7 @@ class Project(models.Model):
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
sequence_per_invoice_type = super()._get_profitability_sequence_per_invoice_type()
|
||||
sequence_per_invoice_type['manufacturing_order'] = 10
|
||||
sequence_per_invoice_type['manufacturing_order'] = 12
|
||||
return sequence_per_invoice_type
|
||||
|
||||
def _get_profitability_aal_domain(self):
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
'version': '1.0',
|
||||
'summary': "Monitor purchase in project",
|
||||
'category': 'Services/Project',
|
||||
'depends': ['purchase', 'project'],
|
||||
'depends': ['purchase', 'project_account'],
|
||||
'demo': [
|
||||
'data/project_purchase_demo.xml',
|
||||
],
|
||||
|
||||
@@ -106,16 +106,17 @@ class Project(models.Model):
|
||||
['|', ('move_line_id', '=', False), ('move_line_id.purchase_line_id', '=', False)],
|
||||
])
|
||||
|
||||
def _add_purchase_items(self, profitability_items, with_action=True):
|
||||
return False
|
||||
|
||||
def _get_profitability_labels(self):
|
||||
labels = super()._get_profitability_labels()
|
||||
labels['purchase_order'] = _lt('Purchase Orders')
|
||||
labels['other_purchase_costs'] = _lt('Other Costs')
|
||||
return labels
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
sequence_per_invoice_type = super()._get_profitability_sequence_per_invoice_type()
|
||||
sequence_per_invoice_type['purchase_order'] = 9
|
||||
sequence_per_invoice_type['other_purchase_costs'] = 10
|
||||
sequence_per_invoice_type['purchase_order'] = 10
|
||||
return sequence_per_invoice_type
|
||||
|
||||
def _get_profitability_items(self, with_action=True):
|
||||
@@ -135,6 +136,7 @@ class Project(models.Model):
|
||||
'invoice_lines': self.env['purchase.order.line'].browse(pol['id']).invoice_lines, # One2Many cannot be queried, they are not columns
|
||||
} for pol in self._cr.dictfetchall()]
|
||||
purchase_order_line_invoice_line_ids = self._get_already_included_profitability_invoice_line_ids()
|
||||
with_action = with_action and self.user_has_groups('purchase.group_purchase_user, account.group_account_invoice, account.group_account_readonly')
|
||||
if purchase_order_line_read:
|
||||
|
||||
# Get conversion rate from currencies to currency of analytic account
|
||||
@@ -157,7 +159,7 @@ class Project(models.Model):
|
||||
costs = profitability_items['costs']
|
||||
section_id = 'purchase_order'
|
||||
purchase_order_costs = {'id': section_id, 'sequence': self._get_profitability_sequence_per_invoice_type()[section_id], 'billed': amount_invoiced, 'to_bill': amount_to_invoice}
|
||||
if with_action and purchase_order_line_ids and self.user_has_groups('purchase.group_purchase_user'):
|
||||
if with_action and purchase_order_line_ids:
|
||||
args = [section_id, [('id', 'in', purchase_order_line_ids)]]
|
||||
if len(purchase_order_line_ids) == 1:
|
||||
args.append(purchase_order_line_ids[0])
|
||||
@@ -166,52 +168,11 @@ class Project(models.Model):
|
||||
costs['data'].append(purchase_order_costs)
|
||||
costs['total']['billed'] += amount_invoiced
|
||||
costs['total']['to_bill'] += amount_to_invoice
|
||||
# calculate the cost of bills without a purchase order
|
||||
query = self.env['account.move.line'].sudo()._search([
|
||||
domain = [
|
||||
('move_id.move_type', 'in', ['in_invoice', 'in_refund']),
|
||||
('parent_state', 'in', ['draft', 'posted']),
|
||||
('price_subtotal', '>', 0),
|
||||
('id', 'not in', purchase_order_line_invoice_line_ids),
|
||||
], order=self.env['account.move.line']._order)
|
||||
query.add_where('account_move_line.analytic_distribution ? %s', [str(self.analytic_account_id.id)])
|
||||
# account_move_line__move_id is the alias of the joined table account_move in the query
|
||||
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
|
||||
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
|
||||
query_string, query_param = query.select('price_subtotal', 'parent_state', 'account_move_line.currency_id', 'account_move_line.analytic_distribution', 'account_move_line__move_id.move_type')
|
||||
self._cr.execute(query_string, query_param)
|
||||
bills_move_line_read = self._cr.dictfetchall()
|
||||
if bills_move_line_read:
|
||||
|
||||
# Get conversion rate from currencies to currency of analytic account
|
||||
currency_ids = {bml['currency_id'] for bml in bills_move_line_read + [{'currency_id': self.analytic_account_id.currency_id.id}]}
|
||||
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
|
||||
conversion_rates = {cid: rates[self.analytic_account_id.currency_id.id] / rate_from for cid, rate_from in rates.items()}
|
||||
|
||||
amount_invoiced = amount_to_invoice = 0.0
|
||||
for moves_read in bills_move_line_read:
|
||||
price_subtotal = self.analytic_account_id.currency_id.round(moves_read['price_subtotal'] * conversion_rates[moves_read['currency_id']])
|
||||
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
|
||||
if moves_read['parent_state'] == 'draft':
|
||||
if moves_read['move_type'] == 'in_invoice':
|
||||
amount_to_invoice -= price_subtotal * analytic_contribution
|
||||
else: # moves_read['move_type'] == 'in_refund'
|
||||
amount_to_invoice += price_subtotal * analytic_contribution
|
||||
else: # moves_read['parent_state'] == 'posted'
|
||||
if moves_read['move_type'] == 'in_invoice':
|
||||
amount_invoiced -= price_subtotal * analytic_contribution
|
||||
else: # moves_read['move_type'] == 'in_refund'
|
||||
amount_invoiced += price_subtotal * analytic_contribution
|
||||
# don't display the section if the final values are both 0 (bill -> vendor credit)
|
||||
if amount_invoiced != 0 or amount_to_invoice != 0:
|
||||
costs = profitability_items['costs']
|
||||
section_id = 'other_purchase_costs'
|
||||
bills_costs = {
|
||||
'id': section_id,
|
||||
'sequence': self._get_profitability_sequence_per_invoice_type()[section_id],
|
||||
'billed': amount_invoiced,
|
||||
'to_bill': amount_to_invoice,
|
||||
}
|
||||
costs['data'].append(bills_costs)
|
||||
costs['total']['billed'] += amount_invoiced
|
||||
costs['total']['to_bill'] += amount_to_invoice
|
||||
]
|
||||
self._get_costs_items_from_purchase(domain, profitability_items, with_action=with_action)
|
||||
return profitability_items
|
||||
|
||||
@@ -13,7 +13,7 @@ from odoo.addons.purchase.tests.test_purchase_invoice import TestPurchaseToInvoi
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurchaseToInvoiceCommon):
|
||||
|
||||
def test_bills_without_purchase_order_are_accounted_in_profitability(self):
|
||||
def test_bills_without_purchase_order_are_accounted_in_profitability_project_purchase(self):
|
||||
"""
|
||||
A bill that has an AAL on one of its line should be taken into account
|
||||
for the profitability of the project.
|
||||
@@ -39,17 +39,32 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
"currency_id": self.env.company.currency_id.id,
|
||||
})],
|
||||
})
|
||||
# the bill_1 is in draft, therefor it should have the cost "to_bill" same as the -product_price (untaxed)
|
||||
# add 2 new AAL to the analytic account. Those costs must be present in the cost data
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'extra costs 1',
|
||||
'account_id': self.analytic_account.id,
|
||||
'amount': -50,
|
||||
}, {
|
||||
'name': 'extra costs 2',
|
||||
'account_id': self.analytic_account.id,
|
||||
'amount': -100,
|
||||
}])
|
||||
# the bill_1 is in draft, therefore it should have the cost "to_bill" same as the -product_price (untaxed)
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': -self.product_a.standard_price * analytic_contribution,
|
||||
'billed': 0.0,
|
||||
}],
|
||||
'total': {'to_bill': -self.product_a.standard_price * analytic_contribution, 'billed': 0.0},
|
||||
'total': {'to_bill': -self.product_a.standard_price * analytic_contribution, 'billed': -150.0},
|
||||
},
|
||||
)
|
||||
# post bill_1
|
||||
@@ -59,12 +74,17 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -self.product_a.standard_price * analytic_contribution,
|
||||
}],
|
||||
'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price * analytic_contribution},
|
||||
'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price * analytic_contribution - 150},
|
||||
},
|
||||
)
|
||||
# create another bill, with 2 lines, 2 diff products, the second line has 2 as quantity
|
||||
@@ -95,6 +115,11 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution,
|
||||
@@ -102,7 +127,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution,
|
||||
'billed': -self.product_a.standard_price * analytic_contribution,
|
||||
'billed': -self.product_a.standard_price * analytic_contribution - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -113,6 +138,11 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': 0.0,
|
||||
@@ -120,7 +150,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -145,6 +175,11 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'purchase_order',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
|
||||
'to_bill': -self.product_order.standard_price * analytic_contribution,
|
||||
@@ -157,7 +192,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': -self.product_order.standard_price * analytic_contribution,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -171,6 +206,11 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'purchase_order',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['purchase_order'],
|
||||
'to_bill': 0.0,
|
||||
@@ -185,7 +225,7 @@ class TestProjectPurchaseProfitability(TestProjectProfitabilityCommon, TestPurch
|
||||
'to_bill': 0.0,
|
||||
'billed': -(2 * self.product_a.standard_price +
|
||||
2 * self.product_b.standard_price +
|
||||
self.product_order.standard_price) * analytic_contribution,
|
||||
self.product_order.standard_price) * analytic_contribution - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ Allows to create task from your sales order
|
||||
This module allows to generate a project/task from sales orders.
|
||||
""",
|
||||
'category': 'Hidden',
|
||||
'depends': ['sale_management', 'sale_service', 'project'],
|
||||
'auto_install': ['sale_management', 'project'],
|
||||
'depends': ['sale_management', 'sale_service', 'project_account'],
|
||||
'auto_install': ['sale_management', 'project_account'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'security/sale_project_security.xml',
|
||||
|
||||
@@ -118,7 +118,7 @@ class Project(models.Model):
|
||||
return action
|
||||
|
||||
def action_profitability_items(self, section_name, domain=None, res_id=False):
|
||||
if section_name in ['service_revenues', 'other_revenues']:
|
||||
if section_name in ['service_revenues', 'materials']:
|
||||
view_types = ['list', 'kanban', 'form']
|
||||
action = {
|
||||
'name': _('Sales Order Items'),
|
||||
@@ -133,6 +133,16 @@ class Project(models.Model):
|
||||
action['domain'] = domain
|
||||
action['views'] = [(False, v) for v in view_types]
|
||||
return action
|
||||
|
||||
if section_name == 'other_invoice_revenues':
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_out_invoice_type")
|
||||
action['domain'] = domain if domain else []
|
||||
if res_id:
|
||||
action['views'] = [(False, 'form')]
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = res_id
|
||||
return action
|
||||
|
||||
return super().action_profitability_items(section_name, domain, res_id)
|
||||
|
||||
@api.depends('sale_order_id.invoice_status', 'tasks.sale_order_id.invoice_status')
|
||||
@@ -309,16 +319,16 @@ class Project(models.Model):
|
||||
return {
|
||||
**super()._get_profitability_labels(),
|
||||
'service_revenues': _lt('Other Services'),
|
||||
'other_revenues': _lt('Materials'),
|
||||
'other_invoice_revenues': _lt('Other Revenues'),
|
||||
'materials': _lt('Materials'),
|
||||
'other_invoice_revenues': _lt('Customer Invoices'),
|
||||
}
|
||||
|
||||
def _get_profitability_sequence_per_invoice_type(self):
|
||||
return {
|
||||
**super()._get_profitability_sequence_per_invoice_type(),
|
||||
'service_revenues': 6,
|
||||
'other_revenues': 7,
|
||||
'other_invoice_revenues': 8,
|
||||
'materials': 7,
|
||||
'other_invoice_revenues': 9,
|
||||
}
|
||||
|
||||
def _get_service_policy_to_invoice_type(self):
|
||||
@@ -376,32 +386,33 @@ class Project(models.Model):
|
||||
'ordered_prepaid')
|
||||
for product_id, (amount_to_invoice, amount_invoiced, sol_ids) in sols_per_product.items():
|
||||
if product_id in product_ids:
|
||||
invoice_type = service_policy_to_invoice_type.get(service_policy, 'other_revenues')
|
||||
invoice_type = service_policy_to_invoice_type.get(service_policy, 'materials')
|
||||
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
|
||||
revenue['to_invoice'] += amount_to_invoice
|
||||
total_to_invoice += amount_to_invoice
|
||||
revenue['invoiced'] += amount_invoiced
|
||||
total_invoiced += amount_invoiced
|
||||
if display_sol_action and invoice_type in ['service_revenues', 'other_revenues']:
|
||||
if display_sol_action and invoice_type in ['service_revenues', 'materials']:
|
||||
revenue.setdefault('record_ids', []).extend(sol_ids)
|
||||
|
||||
if display_sol_action:
|
||||
section_name = 'other_revenues'
|
||||
other_revenues = revenues_dict.get(section_name, {})
|
||||
section_name = 'materials'
|
||||
materials = revenues_dict.get(section_name, {})
|
||||
sale_order_items = self.env['sale.order.line'] \
|
||||
.browse(other_revenues.pop('record_ids', [])) \
|
||||
.browse(materials.pop('record_ids', [])) \
|
||||
._filter_access_rules_python('read')
|
||||
if sale_order_items:
|
||||
if sale_order_items:
|
||||
args = [section_name, [('id', 'in', sale_order_items.ids)]]
|
||||
if len(sale_order_items) == 1:
|
||||
args.append(sale_order_items.id)
|
||||
action_params = {
|
||||
'name': 'action_profitability_items',
|
||||
'type': 'object',
|
||||
'args': json.dumps(args),
|
||||
}
|
||||
other_revenues['action'] = action_params
|
||||
args = [section_name, [('id', 'in', sale_order_items.ids)]]
|
||||
if len(sale_order_items) == 1:
|
||||
args.append(sale_order_items.id)
|
||||
action_params = {
|
||||
'name': 'action_profitability_items',
|
||||
'type': 'object',
|
||||
'args': json.dumps(args),
|
||||
}
|
||||
if len(sale_order_items) == 1:
|
||||
action_params['res_id'] = sale_order_items.id
|
||||
materials['action'] = action_params
|
||||
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
|
||||
return {
|
||||
'data': [{'id': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], **vals} for invoice_type, vals in revenues_dict.items()],
|
||||
@@ -419,7 +430,7 @@ class Project(models.Model):
|
||||
('is_downpayment', '=', False)],
|
||||
])
|
||||
|
||||
def _get_revenues_items_from_invoices(self, excluded_move_line_ids=None):
|
||||
def _get_revenues_items_from_invoices(self, excluded_move_line_ids=None, with_action=True):
|
||||
"""
|
||||
Get all revenues items from invoices, and put them into their own
|
||||
"other_invoice_revenues" section.
|
||||
@@ -439,7 +450,7 @@ class Project(models.Model):
|
||||
# account_move_line__move_id is the alias of the joined table account_move in the query
|
||||
# we can use it, because of the "move_id.move_type" clause in the domain of the query, which generates the join
|
||||
# this is faster than a search_read followed by a browse on the move_id to retrieve the move_type of each account.move.line
|
||||
query_string, query_param = query.select('price_subtotal', 'parent_state', 'account_move_line.currency_id', 'account_move_line.analytic_distribution', 'account_move_line__move_id.move_type')
|
||||
query_string, query_param = query.select('price_subtotal', 'parent_state', 'account_move_line.currency_id', 'account_move_line.analytic_distribution', 'account_move_line__move_id.move_type', 'move_id')
|
||||
self._cr.execute(query_string, query_param)
|
||||
invoices_move_line_read = self._cr.dictfetchall()
|
||||
if invoices_move_line_read:
|
||||
@@ -449,10 +460,12 @@ class Project(models.Model):
|
||||
rates = self.env['res.currency'].browse(list(currency_ids))._get_rates(self.company_id, date.today())
|
||||
conversion_rates = {cid: rates[self.analytic_account_id.currency_id.id] / rate_from for cid, rate_from in rates.items()}
|
||||
|
||||
move_ids = set()
|
||||
amount_invoiced = amount_to_invoice = 0.0
|
||||
for moves_read in invoices_move_line_read:
|
||||
price_subtotal = self.analytic_account_id.currency_id.round(moves_read['price_subtotal'] * conversion_rates[moves_read['currency_id']])
|
||||
analytic_contribution = moves_read['analytic_distribution'][str(self.analytic_account_id.id)] / 100.
|
||||
move_ids.add(moves_read['move_id'])
|
||||
if moves_read['parent_state'] == 'draft':
|
||||
if moves_read['move_type'] == 'out_invoice':
|
||||
amount_to_invoice += price_subtotal * analytic_contribution
|
||||
@@ -472,6 +485,8 @@ class Project(models.Model):
|
||||
'invoiced': amount_invoiced,
|
||||
'to_invoice': amount_to_invoice,
|
||||
}
|
||||
if with_action and self.user_has_groups('sales_team.group_sale_salesman_all_leads, account.group_account_invoice, account.group_account_readonly'):
|
||||
invoices_revenues['action'] = self._get_action_for_profitability_section(list(move_ids), section_id)
|
||||
return {
|
||||
'data': [invoices_revenues],
|
||||
'total': {
|
||||
@@ -481,18 +496,7 @@ class Project(models.Model):
|
||||
}
|
||||
return {'data': [], 'total': {'invoiced': 0.0, 'to_invoice': 0.0}}
|
||||
|
||||
def _get_profitability_items(self, with_action=True):
|
||||
profitability_items = super()._get_profitability_items(with_action)
|
||||
domain = [('order_id', 'in', self.sudo()._get_sale_orders().ids)]
|
||||
revenue_items_from_sol = self._get_revenues_items_from_sol(
|
||||
domain,
|
||||
with_action,
|
||||
)
|
||||
revenues = profitability_items['revenues']
|
||||
revenues['data'] += revenue_items_from_sol['data']
|
||||
revenues['total']['to_invoice'] += revenue_items_from_sol['total']['to_invoice']
|
||||
revenues['total']['invoiced'] += revenue_items_from_sol['total']['invoiced']
|
||||
|
||||
def _add_invoice_items(self, domain, profitability_items, with_action=True):
|
||||
sale_line_read_group = self.env['sale.order.line'].sudo()._read_group(
|
||||
self._get_profitability_sale_order_items_domain(domain),
|
||||
['ids:array_agg(id)'],
|
||||
@@ -501,11 +505,25 @@ class Project(models.Model):
|
||||
revenue_items_from_invoices = self._get_revenues_items_from_invoices(
|
||||
excluded_move_line_ids=self.env['sale.order.line'].browse(
|
||||
[sol_id for sol_read in sale_line_read_group for sol_id in sol_read['ids']]
|
||||
).invoice_lines.ids
|
||||
).invoice_lines.ids,
|
||||
with_action=with_action
|
||||
)
|
||||
revenues['data'] += revenue_items_from_invoices['data']
|
||||
revenues['total']['to_invoice'] += revenue_items_from_invoices['total']['to_invoice']
|
||||
revenues['total']['invoiced'] += revenue_items_from_invoices['total']['invoiced']
|
||||
profitability_items['revenues']['data'] += revenue_items_from_invoices['data']
|
||||
profitability_items['revenues']['total']['to_invoice'] += revenue_items_from_invoices['total']['to_invoice']
|
||||
profitability_items['revenues']['total']['invoiced'] += revenue_items_from_invoices['total']['invoiced']
|
||||
|
||||
def _get_profitability_items(self, with_action=True):
|
||||
profitability_items = super()._get_profitability_items(with_action)
|
||||
domain = [('order_id', 'in', self.sudo()._get_sale_orders().ids)]
|
||||
revenue_items_from_sol = self._get_revenues_items_from_sol(
|
||||
domain,
|
||||
with_action,
|
||||
)
|
||||
profitability_items['revenues']['data'] += revenue_items_from_sol['data']
|
||||
profitability_items['revenues']['total']['to_invoice'] += revenue_items_from_sol['total']['to_invoice']
|
||||
profitability_items['revenues']['total']['invoiced'] += revenue_items_from_sol['total']['invoiced']
|
||||
self._add_invoice_items(domain, profitability_items, with_action=with_action)
|
||||
self._add_purchase_items(profitability_items, with_action=with_action)
|
||||
return profitability_items
|
||||
|
||||
def _get_stat_buttons(self):
|
||||
|
||||
@@ -54,9 +54,48 @@ class TestProjectProfitabilityCommon(Common):
|
||||
})
|
||||
cls.sale_order.action_confirm()
|
||||
|
||||
cls.analytic_account_nb = cls.env['account.analytic.account'].create({
|
||||
'name': 'Project non billable AA',
|
||||
'code': 'AA-123456',
|
||||
'plan_id': cls.analytic_plan.id,
|
||||
})
|
||||
|
||||
cls.project_non_billable = cls.env['project.project'].with_context(tracking_disable=True).create({
|
||||
'name': "Non Billable Project",
|
||||
'analytic_account_id': cls.analytic_account_nb.id,
|
||||
'allow_billable': False,
|
||||
'partner_id': False,
|
||||
})
|
||||
|
||||
|
||||
@tagged('-at_install', 'post_install')
|
||||
class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommon):
|
||||
|
||||
def test_profitability_of_non_billable_project(self):
|
||||
""" Test no data is found for the project profitability since the project is not billable
|
||||
even if it is linked to a sale order items.
|
||||
"""
|
||||
# Adding an extra cost/revenue to ensure those are not computed either.
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'other revenues line',
|
||||
'account_id': self.project_non_billable.analytic_account_id.id,
|
||||
'amount': 100,
|
||||
}, {
|
||||
'name': 'other costs line',
|
||||
'account_id': self.project_non_billable.analytic_account_id.id,
|
||||
'amount': -100,
|
||||
}])
|
||||
self.assertFalse(self.project_non_billable.allow_billable)
|
||||
panel_data = self.project_non_billable.get_panel_data()
|
||||
self.assertFalse(panel_data.get('profitability_items'))
|
||||
self.assertFalse(panel_data.get('profitability_labels'))
|
||||
self.project_non_billable.write({'sale_line_id': self.sale_order.order_line[0].id})
|
||||
panel_data = self.project_non_billable.get_panel_data()
|
||||
self.assertFalse(panel_data.get('profitability_items'),
|
||||
"Even if the project has a sale order item linked, the project profitability should not be computed since it is not billable.")
|
||||
self.assertFalse(panel_data.get('profitability_labels'),
|
||||
"Even if the project has a sale order item linked, the project profitability should not be computed since it is not billable.")
|
||||
|
||||
def test_project_profitability(self):
|
||||
self.assertFalse(self.project.allow_billable, 'The project should be non billable.')
|
||||
self.assertDictEqual(
|
||||
@@ -81,11 +120,28 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"')
|
||||
sequence_per_invoice_type = self.project._get_profitability_sequence_per_invoice_type()
|
||||
self.assertIn('service_revenues', sequence_per_invoice_type)
|
||||
|
||||
#we're adding extra cost and extra revenues to the analytic account
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'other revenues line',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': 100,
|
||||
}, {
|
||||
'name': 'other costs line',
|
||||
'account_id': self.project.analytic_account_id.id,
|
||||
'amount': -100,
|
||||
}])
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False),
|
||||
{
|
||||
'revenues': {
|
||||
'data': [
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'invoiced': 100.0,
|
||||
'to_invoice': 0.0,
|
||||
},
|
||||
{
|
||||
# id should be equal to "billable_manual" if "sale_timesheet" module is installed otherwise "service_revenues"
|
||||
'id': invoice_type,
|
||||
@@ -96,12 +152,12 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
],
|
||||
'total': {
|
||||
'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice,
|
||||
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
|
||||
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + 100,
|
||||
},
|
||||
},
|
||||
'costs': {
|
||||
'data': [],
|
||||
'total': {'billed': 0.0, 'to_bill': 0.0},
|
||||
'data': [{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0}],
|
||||
'total': {'billed': -100.0, 'to_bill': 0.0},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -132,6 +188,12 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
{
|
||||
'revenues': {
|
||||
'data': [
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'invoiced': 100.0,
|
||||
'to_invoice': 0.0,
|
||||
},
|
||||
{
|
||||
'id': invoice_type,
|
||||
'sequence': sequence_per_invoice_type[invoice_type],
|
||||
@@ -141,12 +203,12 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
],
|
||||
'total': {
|
||||
'to_invoice': 0.0,
|
||||
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
|
||||
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + 100,
|
||||
},
|
||||
},
|
||||
'costs': {
|
||||
'data': [],
|
||||
'total': {'billed': 0.0, 'to_bill': 0.0},
|
||||
'data': [{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0}],
|
||||
'total': {'billed': -100.0, 'to_bill': 0.0},
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -174,6 +236,12 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
{
|
||||
'revenues': {
|
||||
'data': [
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'invoiced': 100.0,
|
||||
'to_invoice': 0.0,
|
||||
},
|
||||
{
|
||||
'id': invoice_type,
|
||||
'sequence': sequence_per_invoice_type[invoice_type],
|
||||
@@ -181,20 +249,20 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')),
|
||||
},
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'id': 'materials',
|
||||
'sequence': sequence_per_invoice_type['materials'],
|
||||
'to_invoice': material_order_line.untaxed_amount_to_invoice,
|
||||
'invoiced': material_order_line.untaxed_amount_invoiced,
|
||||
},
|
||||
],
|
||||
'total': {
|
||||
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice,
|
||||
'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced,
|
||||
'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced + 100,
|
||||
},
|
||||
},
|
||||
'costs': { # no cost because we have no purchase orders.
|
||||
'data': [],
|
||||
'total': {'billed': 0.0, 'to_bill': 0.0},
|
||||
'costs': {
|
||||
'data': [{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0}],
|
||||
'total': {'billed': -100.0, 'to_bill': 0.0},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -210,6 +278,12 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
{
|
||||
'revenues': {
|
||||
'data': [
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'invoiced': 100.0,
|
||||
'to_invoice': 0.0,
|
||||
},
|
||||
{
|
||||
'id': invoice_type,
|
||||
'sequence': sequence_per_invoice_type[invoice_type],
|
||||
@@ -217,20 +291,20 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
'invoiced': manual_service_order_line.untaxed_amount_invoiced,
|
||||
},
|
||||
{
|
||||
'id': 'other_revenues',
|
||||
'sequence': sequence_per_invoice_type['other_revenues'],
|
||||
'id': 'materials',
|
||||
'sequence': sequence_per_invoice_type['materials'],
|
||||
'to_invoice': material_order_line.untaxed_amount_to_invoice,
|
||||
'invoiced': material_order_line.untaxed_amount_invoiced,
|
||||
},
|
||||
],
|
||||
'total': {
|
||||
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice,
|
||||
'invoiced': manual_service_order_line.untaxed_amount_invoiced + material_order_line.untaxed_amount_invoiced,
|
||||
'invoiced': manual_service_order_line.untaxed_amount_invoiced + material_order_line.untaxed_amount_invoiced + 100,
|
||||
},
|
||||
},
|
||||
'costs': { # no cost because we have no purchase orders.
|
||||
'data': [],
|
||||
'total': {'billed': 0.0, 'to_bill': 0.0},
|
||||
'costs': {
|
||||
'data': [{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0}],
|
||||
'total': {'billed': -100.0, 'to_bill': 0.0},
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -238,7 +312,18 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
self.sale_order._action_cancel()
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False),
|
||||
self.project_profitability_items_empty,
|
||||
{
|
||||
# even if the sale order is canceled, if some expenses/revenues were added manually to the account, those lines must appear in the project profitabilty panel
|
||||
'revenues': {
|
||||
'data': [
|
||||
{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0}],
|
||||
'total': {'to_invoice': 0.0, 'invoiced': 100},
|
||||
},
|
||||
'costs': {
|
||||
'data': [{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0}],
|
||||
'total': {'billed': -100.0, 'to_bill': 0.0},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_invoices_without_sale_order_are_accounted_in_profitability(self):
|
||||
@@ -252,7 +337,7 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
# a custom analytic contribution (number between 1 -> 100 included)
|
||||
analytic_distribution = 42
|
||||
analytic_contribution = analytic_distribution / 100.
|
||||
# create a invoice_1 with the AAL
|
||||
# create an invoice_1 with the AAL
|
||||
invoice_1 = self.env['account.move'].create({
|
||||
"name": "Invoice_1",
|
||||
"move_type": "out_invoice",
|
||||
@@ -267,7 +352,7 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
"price_unit": self.product_a.standard_price,
|
||||
})],
|
||||
})
|
||||
# the bill_1 is in draft, therefor it should have the cost "to_invoice" same as the -product_price (untaxed)
|
||||
# the bill_1 is in draft, therefore it should have the cost "to_invoice" same as the -product_price (untaxed)
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['revenues'],
|
||||
{
|
||||
@@ -316,7 +401,7 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
"price_unit": self.product_b.standard_price,
|
||||
})],
|
||||
})
|
||||
# invoice_2 is not posted, therefor its cost should be "to_invoice" = - sum of all product_price * qty for each line
|
||||
# invoice_2 is not posted, therefore its cost should be "to_invoice" = - sum of all product_price * qty for each line
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['revenues'],
|
||||
{
|
||||
@@ -334,7 +419,7 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
)
|
||||
# post invoice_2
|
||||
invoice_2.action_post()
|
||||
# invoice_2 is posted, therefor its revenue should be counting in "invoiced", with the revenues from invoice_1
|
||||
# invoice_2 is posted, therefore its revenue should be counting in "invoiced", with the revenues from invoice_1
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['revenues'],
|
||||
{
|
||||
@@ -350,3 +435,137 @@ class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommo
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def test_bills_without_purchase_order_are_accounted_in_profitability_sale_project(self):
|
||||
"""
|
||||
A bill that has an AAL on one of its line should be taken into account
|
||||
for the profitability of the project.
|
||||
"""
|
||||
# create a bill_1 with the AAL
|
||||
bill_1 = self.env['account.move'].create({
|
||||
"name": "Bill_1 name",
|
||||
"move_type": "in_invoice",
|
||||
"state": "draft",
|
||||
"partner_id": self.partner.id,
|
||||
"invoice_date": datetime.today(),
|
||||
"invoice_line_ids": [Command.create({
|
||||
"analytic_distribution": {self.analytic_account.id: 100},
|
||||
"product_id": self.product_a.id,
|
||||
"quantity": 1,
|
||||
"product_uom_id": self.product_a.uom_id.id,
|
||||
"price_unit": self.product_a.standard_price,
|
||||
})],
|
||||
})
|
||||
# add 2 new AAL to the analytic account. Those costs must be present in the cost data
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'extra costs 1',
|
||||
'account_id': self.analytic_account.id,
|
||||
'amount': -50,
|
||||
}, {
|
||||
'name': 'extra costs 2',
|
||||
'account_id': self.analytic_account.id,
|
||||
'amount': -100,
|
||||
}])
|
||||
# the bill_1 is in draft, therefore it should have the cost "to_bill" same as the -product_price (untaxed)
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': -self.product_a.standard_price,
|
||||
'billed': 0.0,
|
||||
}],
|
||||
'total': {'to_bill': -self.product_a.standard_price, 'billed': -150.0},
|
||||
},
|
||||
)
|
||||
# post bill_1
|
||||
bill_1.action_post()
|
||||
# we posted the bill_1, therefore the cost "billed" should be -product_price, to_bill should be back to 0
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -self.product_a.standard_price,
|
||||
}],
|
||||
'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price - 150},
|
||||
},
|
||||
)
|
||||
# create another bill, with 2 lines, 2 diff products, the second line has 2 as quantity
|
||||
bill_2 = self.env['account.move'].create({
|
||||
"name": "I have 2 lines",
|
||||
"move_type": "in_invoice",
|
||||
"state": "draft",
|
||||
"partner_id": self.partner.id,
|
||||
"invoice_date": datetime.today(),
|
||||
"invoice_line_ids": [Command.create({
|
||||
"analytic_distribution": {self.analytic_account.id: 100},
|
||||
"product_id": self.product_a.id,
|
||||
"quantity": 1,
|
||||
"product_uom_id": self.product_a.uom_id.id,
|
||||
"price_unit": self.product_a.standard_price,
|
||||
}), Command.create({
|
||||
"analytic_distribution": {self.analytic_account.id: 100},
|
||||
"product_id": self.product_b.id,
|
||||
"quantity": 2,
|
||||
"product_uom_id": self.product_b.uom_id.id,
|
||||
"price_unit": self.product_b.standard_price,
|
||||
})],
|
||||
})
|
||||
# bill_2 is not posted, therefore its cost should be "to_billed" = - sum of all product_price * qty for each line
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price),
|
||||
'billed': -self.product_a.standard_price,
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price),
|
||||
'billed': -self.product_a.standard_price - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
# post bill_2
|
||||
bill_2.action_post()
|
||||
# bill_2 is posted, therefore its cost should be counting in "billed", with the cost of bill_1
|
||||
self.assertDictEqual(
|
||||
self.project._get_profitability_items(False)['costs'],
|
||||
{
|
||||
'data': [{
|
||||
'id': 'other_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -150.0,
|
||||
}, {
|
||||
'id': 'other_purchase_costs',
|
||||
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_purchase_costs'],
|
||||
'to_bill': 0.0,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price),
|
||||
}],
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed': -2 * (self.product_a.standard_price + self.product_b.standard_price) - 150,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -483,6 +483,13 @@ class Project(models.Model):
|
||||
)
|
||||
return profitability_items
|
||||
|
||||
def _get_domain_aal_with_no_move_line(self):
|
||||
# we add the tuple 'project_id = False' in the domain to remove the timesheets from the search.
|
||||
return expression.AND([
|
||||
super()._get_domain_aal_with_no_move_line(),
|
||||
[('project_id', '=', False)]
|
||||
])
|
||||
|
||||
def _get_service_policy_to_invoice_type(self):
|
||||
return {
|
||||
**super()._get_service_policy_to_invoice_type(),
|
||||
|
||||
@@ -20,25 +20,9 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
'costs': {'data': [], 'total': {'to_bill': 0.0, 'billed': 0.0}},
|
||||
}
|
||||
|
||||
def test_profitability_of_non_billable_project(self):
|
||||
""" Test no data is found for the project profitability since the project is not billable
|
||||
even if it is linked to a sale order items.
|
||||
"""
|
||||
self.assertFalse(self.project_non_billable.allow_billable)
|
||||
self.assertDictEqual(
|
||||
self.project_non_billable._get_profitability_items(False),
|
||||
self.project_profitability_items_empty,
|
||||
)
|
||||
self.project_non_billable.write({'sale_line_id': self.so.order_line[0].id})
|
||||
self.assertDictEqual(
|
||||
self.project_non_billable._get_profitability_items(False),
|
||||
self.project_profitability_items_empty,
|
||||
"Even if the project has a sale order item linked the project profitability should not be computed since it is not billable."
|
||||
)
|
||||
|
||||
def test_get_project_profitability_items(self):
|
||||
""" Test _get_project_profitability_items method to ensure the project profitability
|
||||
is correctly computed as expected.
|
||||
is computed as expected.
|
||||
"""
|
||||
sale_order = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
|
||||
'partner_id': self.partner_b.id,
|
||||
@@ -83,15 +67,27 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
self.assertEqual(self.task.sale_line_id, delivery_service_order_line)
|
||||
self.assertEqual((timesheet1 + timesheet2).so_line, delivery_service_order_line)
|
||||
self.assertEqual(delivery_service_order_line.qty_delivered, 0.0, 'The service type is not timesheet but manual so the quantity delivered is not increased by the timesheets linked.')
|
||||
|
||||
# Adding an extra cost/revenue to ensure those are computed correctly.
|
||||
self.env['account.analytic.line'].create([{
|
||||
'name': 'other revenues line',
|
||||
'account_id': self.project_task_rate.analytic_account_id.id,
|
||||
'amount': 100,
|
||||
}, {
|
||||
'name': 'other costs line',
|
||||
'account_id': self.project_task_rate.analytic_account_id.id,
|
||||
'amount': -100,
|
||||
}])
|
||||
self.assertDictEqual(
|
||||
self.project_task_rate._get_profitability_items(False),
|
||||
{
|
||||
'revenues': {
|
||||
'data': [],
|
||||
'total': {'to_invoice': 0.0, 'invoiced': 0.0},
|
||||
'data': [{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0}],
|
||||
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
|
||||
},
|
||||
'costs': {
|
||||
'data': [
|
||||
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0},
|
||||
{
|
||||
'id': 'billable_manual',
|
||||
'sequence': sequence_per_invoice_type['billable_manual'],
|
||||
@@ -101,7 +97,7 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
],
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed': (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
'billed': -100 + (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -121,11 +117,12 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
self.project_task_rate._get_profitability_items(False),
|
||||
{
|
||||
'revenues': {
|
||||
'data': [],
|
||||
'total': {'to_invoice': 0.0, 'invoiced': 0.0},
|
||||
'data': [{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0}],
|
||||
'total': {'invoiced': 100.0, 'to_invoice': 0.0},
|
||||
},
|
||||
'costs': {
|
||||
'data': [
|
||||
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0},
|
||||
{
|
||||
'id': 'billable_manual',
|
||||
'sequence': sequence_per_invoice_type['billable_manual'],
|
||||
@@ -142,7 +139,8 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed':
|
||||
(timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
-100
|
||||
+ (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
|
||||
},
|
||||
},
|
||||
@@ -163,12 +161,14 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
{
|
||||
'revenues': {
|
||||
'data': [
|
||||
{'id': 'other_revenues', 'sequence': sequence_per_invoice_type['other_revenues'], 'invoiced': 100.0, 'to_invoice': 0.0},
|
||||
{'id': 'billable_time', 'sequence': sequence_per_invoice_type['billable_time'], 'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
|
||||
],
|
||||
'total': {'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice, 'invoiced': 0.0},
|
||||
'total': {'invoiced': 100.0, 'to_invoice': delivery_timesheet_order_line.untaxed_amount_to_invoice},
|
||||
},
|
||||
'costs': {
|
||||
'data': [
|
||||
{'id': 'other_costs', 'sequence': sequence_per_invoice_type['other_costs'], 'billed': -100.0, 'to_bill': 0.0},
|
||||
{
|
||||
'id': 'billable_time',
|
||||
'sequence': sequence_per_invoice_type['billable_time'],
|
||||
@@ -185,7 +185,8 @@ class TestSaleTimesheetProjectProfitability(TestCommonSaleTimesheet):
|
||||
'total': {
|
||||
'to_bill': 0.0,
|
||||
'billed':
|
||||
(timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
-100
|
||||
+ (timesheet1.unit_amount + timesheet2.unit_amount) * -self.employee_user.hourly_cost
|
||||
+ timesheet3.unit_amount * -self.employee_manager.hourly_cost,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ Generate tasks in Project app from a form published on your website. This module
|
||||
'depends': ['website', 'project'],
|
||||
'data': [
|
||||
'data/website_form_project_data.xml',
|
||||
'views/project_portal_project_task_template.xml',
|
||||
'views/project_portal_project_project_template.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="website_portal_my_project" name="My Project website" inherit_id="project.portal_my_project">
|
||||
<xpath expr="//t[@t-set='title']" position="replace">
|
||||
<t t-set="additional_title" t-value="project.name"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="website_portal_my_task" name="My Task website" inherit_id="project.portal_my_task">
|
||||
<xpath expr="//t[@t-set='title']" position="replace">
|
||||
<t t-set="additional_title" t-value="task.name"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user