From 2731e7cb0822256aa2fe56e8e5e4d5a0c9274b0b Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Sat, 5 Mar 2022 12:41:19 +0100 Subject: [PATCH] [ADD] project_sale_expense: add reinvoice expense in project profitability Before this commit, the reinvoice expenses are not takken into account for the project profitability. This commit adds a new module called `project_sale_expense` to make the bridge between the sale_expense and project_hr_expense and so in this module, the revenues impacted by the reinvoice expenses will be computed and takken into account for the project profitability. task-2710808 --- addons/project_sale_expense/__init__.py | 4 + addons/project_sale_expense/__manifest__.py | 15 +++ .../project_sale_expense/models/__init__.py | 4 + .../models/project_project.py | 67 +++++++++ .../tests/test_project_profitability.py | 127 ++++++++++++++++++ 5 files changed, 217 insertions(+) create mode 100644 addons/project_sale_expense/__init__.py create mode 100644 addons/project_sale_expense/__manifest__.py create mode 100644 addons/project_sale_expense/models/__init__.py create mode 100644 addons/project_sale_expense/models/project_project.py create mode 100644 addons/project_sale_expense/tests/test_project_profitability.py diff --git a/addons/project_sale_expense/__init__.py b/addons/project_sale_expense/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/project_sale_expense/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/project_sale_expense/__manifest__.py b/addons/project_sale_expense/__manifest__.py new file mode 100644 index 00000000000..788ef90355f --- /dev/null +++ b/addons/project_sale_expense/__manifest__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Project - Sale - Expense', + 'version': '1.0', + 'description': 'Adds a full traceability of reinvoice expenses on the profitability report.', + 'license': 'LGPL-3', + 'category': 'Hidden', + 'depends': ['sale_project', 'sale_expense', 'project_hr_expense'], + 'data': [], + 'demo': [], + 'auto_install': True, + 'application': False, +} diff --git a/addons/project_sale_expense/models/__init__.py b/addons/project_sale_expense/models/__init__.py new file mode 100644 index 00000000000..446c740072d --- /dev/null +++ b/addons/project_sale_expense/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import project_project diff --git a/addons/project_sale_expense/models/project_project.py b/addons/project_sale_expense/models/project_project.py new file mode 100644 index 00000000000..cc4481055d3 --- /dev/null +++ b/addons/project_sale_expense/models/project_project.py @@ -0,0 +1,67 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, _ + + +class Project(models.Model): + _inherit = 'project.project' + + def _get_expenses_profitability_items(self): + if not self.analytic_account_id: + return {} + expenses_read_group = self.env['hr.expense'].sudo()._read_group( + [ + ('analytic_account_id', 'in', self.analytic_account_id.ids), + ('is_refused', '=', False), + ('state', 'in', ['approved', 'done']), + ], + ['sale_order_id', 'product_id', 'untaxed_amount'], + ['sale_order_id', 'product_id'], + lazy=False, + ) + if not expenses_read_group: + return {} + expenses_per_so_id = {} + amount_billed = 0.0 + for res in expenses_read_group: + so_id = res['sale_order_id'] and res['sale_order_id'][0] + product_id = res['product_id'] and res['product_id'][0] + expenses_per_so_id.setdefault(so_id, {})[product_id] = res['ids'] + amount_billed += res['untaxed_amount'] + sol_read_group = self.env['sale.order.line'].sudo()._read_group( + [ + ('order_id', 'in', list(expenses_per_so_id.keys())), + ('is_expense', '=', True), + ('state', 'in', ['sale', 'done']), + ], + ['order_id', 'product_id', 'untaxed_amount_to_invoice', 'untaxed_amount_invoiced'], + ['order_id', 'product_id'], + lazy=False) + total_amount_expense_invoiced = total_amount_expense_to_invoice = 0.0 + reinvoice_expense_ids = [] + for res in sol_read_group: + expense_data_per_product_id = expenses_per_so_id[res['order_id'][0]] + product_id = res['product_id'][0] + if product_id in expense_data_per_product_id: + total_amount_expense_to_invoice += res['untaxed_amount_to_invoice'] + total_amount_expense_invoiced += res['untaxed_amount_invoiced'] + reinvoice_expense_ids += expense_data_per_product_id[product_id] + expense_name = _('Expenses') + section_id = 'expenses' + expense_data = { + 'costs': { + 'id': section_id, + 'name': expense_name, + 'billed': -amount_billed, + 'to_bill': 0.0, + }, + } + if reinvoice_expense_ids: + expense_data['revenues'] = { + 'id': section_id, + 'name': expense_name, + 'invoiced': total_amount_expense_invoiced, + 'to_invoice': total_amount_expense_to_invoice, + } + return expense_data diff --git a/addons/project_sale_expense/tests/test_project_profitability.py b/addons/project_sale_expense/tests/test_project_profitability.py new file mode 100644 index 00000000000..2bd7fe074e4 --- /dev/null +++ b/addons/project_sale_expense/tests/test_project_profitability.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from odoo.addons.project_hr_expense.tests.test_project_profitability import TestProjectHrExpenseProfitabilityCommon +from odoo.addons.sale.tests.common import TestSaleCommon +from odoo.addons.sale_project.tests.test_project_profitability import TestProjectProfitabilityCommon + + +@tagged('-at_install', 'post_install') +class TestProjectSaleExpenseProfitability(TestProjectProfitabilityCommon, TestProjectHrExpenseProfitabilityCommon, TestSaleCommon): + def test_project_profitability(self): + expense = self.env['hr.expense'].create({ + 'name': 'expense', + 'product_id': self.company_data['product_order_sales_price'].id, + 'unit_amount': self.company_data['product_order_sales_price'].list_price, + 'employee_id': self.expense_employee.id, + 'analytic_account_id': self.project.analytic_account_id.id, + 'sale_order_id': self.sale_order.id, + }) + + # See method definition in `project_hr_expense.tests.test_project_profitability` + expense_sheet = self.check_project_profitability_before_creating_and_approving_expense_sheet( + expense, + self.project, + self.project_profitability_items_empty) + + expense_profitability = self.project._get_expenses_profitability_items() + + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {}, + ) + self.assertDictEqual( + expense_profitability['costs'], + {'id': 'expenses', 'billed': -280.0, 'to_bill': 0.0}, + ) + + expense_sheet.action_sheet_move_create() + + self.assertRecordValues(self.sale_order.order_line, [ + # Original SO line: + { + 'product_id': self.product_delivery_service.id, + 'qty_delivered': 0.0, + 'product_uom_qty': 10, + 'is_expense': False, + }, + { + 'product_id': self.company_data['product_order_sales_price'].id, + 'qty_delivered': 1.0, + 'product_uom_qty': 1.0, + 'is_expense': True, + }, + ]) + expense_sol = self.sale_order.order_line.filtered(lambda sol: sol.product_id == self.company_data['product_order_sales_price']) + + expense_profitability = self.project._get_expenses_profitability_items() + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {'id': 'expenses', 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice}, + ) + self.assertDictEqual( + expense_profitability['costs'], + {'id': 'expenses', 'billed': -280.0, 'to_bill': 0.0}, + ) + + self.assertDictEqual( + self.project._get_profitability_items(), + { + 'revenues': { + 'data': [expense_profitability['revenues']], + 'total': {k: v for k, v in expense_profitability['revenues'].items() if k in ['to_invoice', 'invoiced']}, + }, + 'costs': { + 'data': [expense_profitability['costs']], + 'total': {k: v for k, v in expense_profitability['costs'].items() if k in ['to_bill', 'billed']}, + }, + } + ) + + invoice = self.env['sale.advance.payment.inv'] \ + .with_context({ + 'active_model': 'sale.order', + 'active_id': self.sale_order.id, + }).create({ + 'advance_payment_method': 'delivered', + })._create_invoices(self.sale_order) + invoice.action_post() + + expense_profitability = self.project._get_expenses_profitability_items() + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {'id': 'expenses', 'invoiced': expense_sol.untaxed_amount_invoiced, 'to_invoice': 0.0}, + ) + + credit_note = invoice._reverse_moves() + credit_note.action_post() + + expense_profitability = self.project._get_expenses_profitability_items() + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {'id': 'expenses', 'invoiced': 0.0, 'to_invoice': expense_sol.untaxed_amount_to_invoice}, + ) + + self.sale_order.action_cancel() + expense_profitability = self.project._get_expenses_profitability_items() + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {}, + ) + self.assertDictEqual( + expense_profitability['costs'], + {'id': 'expenses', 'billed': -280.0, 'to_bill': 0.0}, + ) + + expense_sheet.refuse_sheet('Test Cancel Expense') + expense_profitability = self.project._get_expenses_profitability_items() + self.assertDictEqual( + expense_profitability.get('revenues', {}), + {}, + ) + self.assertDictEqual( + expense_profitability.get('costs', {}), + {}, + )