[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:
damr
2023-04-06 17:20:44 +02:00
committed by Xavier Bol (xbo)
parent 5f5487d266
commit 5ad0aa5c2a
24 changed files with 688 additions and 149 deletions
+3
View File
@@ -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&amp;id=%s&amp;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&amp;id=%s&amp;action=%s&amp;view_type=form' % (task.id, task.env.ref('project.action_view_my_task').id)"/>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
+18
View File
@@ -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}},
}
+3
View File
@@ -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):
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -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,
},
},
)
+2 -2
View File
@@ -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',
+56 -38
View File
@@ -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,
},
},
)
+7
View File
@@ -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>