From 50315deaa22572ca5e0bee2fb2a52cc3545f47fe Mon Sep 17 00:00:00 2001 From: "Xavier BOL (xbo)" Date: Mon, 7 Mar 2022 08:00:48 +0100 Subject: [PATCH] [IMP] sale_project,sale_timesheet: compute project revenues from SO --- addons/sale_project/models/project.py | 67 +++++ addons/sale_project/tests/__init__.py | 1 + .../tests/test_project_profitability.py | 231 ++++++++++++++++++ addons/sale_timesheet/models/project.py | 9 + 4 files changed, 308 insertions(+) create mode 100644 addons/sale_project/tests/test_project_profitability.py diff --git a/addons/sale_project/models/project.py b/addons/sale_project/models/project.py index 0e63cdc0ac6..0f7f7572526 100644 --- a/addons/sale_project/models/project.py +++ b/addons/sale_project/models/project.py @@ -275,6 +275,73 @@ class Project(models.Model): self.ensure_one() return self.allow_billable and super()._show_profitability() + def _get_service_policy_to_invoice_type(self): + return { + 'ordered_prepaid': 'service_revenues', + 'delivered_milestones': 'service_revenues', + 'delivered_manual': 'service_revenues', + } + + def _get_revenues_items_from_sol(self): + sale_line_read_group = self.env['sale.order.line'].read_group( + [ + ('order_id', 'in', self._get_sale_orders().ids), + ('product_id', '!=', False), + ('is_expense', '=', False), + ('is_downpayment', '=', False), + ('state', 'in', ['sale', 'done']), + '|', ('qty_to_invoice', '>', 0), ('qty_invoiced', '>', 0), + ], + ['product_id', 'untaxed_amount_to_invoice', 'untaxed_amount_invoiced'], + ['product_id'], + ) + revenues_dict = {} + total_to_invoice = total_invoiced = 0.0 + if sale_line_read_group: + sols_per_product = { + res['product_id'][0]: ( + res['untaxed_amount_to_invoice'], + res['untaxed_amount_invoiced'], + res['ids'], + ) for res in sale_line_read_group + } + product_read_group = self.env['product.product'].read_group( + [('id', 'in', list(sols_per_product)), ('expense_policy', '=', 'no')], + ['invoice_policy', 'service_type', 'type', 'ids:array_agg(id)'], + ['invoice_policy', 'service_type', 'type'], + lazy=False, + ) + service_policy_to_invoice_type = self._get_service_policy_to_invoice_type() + general_to_service_map = self.env['product.template']._get_general_to_service_map() + for res in product_read_group: + product_ids = res['ids'] + service_policy = None + if res['type'] == 'service': + service_policy = general_to_service_map.get( + (res['invoice_policy'], res['service_type']), + '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') + 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 + return { + 'data': [{'id': invoice_type, **vals} for invoice_type, vals in revenues_dict.items()], + 'total': {'to_invoice': total_to_invoice, 'invoiced': total_invoiced}, + } + + def _get_profitability_items(self): + profitability_items = super()._get_profitability_items() + revenue_items_from_sol = self._get_revenues_items_from_sol() + 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'] + return profitability_items + def _get_stat_buttons(self): buttons = super(Project, self)._get_stat_buttons() if self.user_has_groups('sales_team.group_sale_salesman_all_leads'): diff --git a/addons/sale_project/tests/__init__.py b/addons/sale_project/tests/__init__.py index eb5cd703b1b..89bae4682d8 100644 --- a/addons/sale_project/tests/__init__.py +++ b/addons/sale_project/tests/__init__.py @@ -2,5 +2,6 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import test_child_tasks +from . import test_project_profitability from . import test_sale_project from . import test_so_line_milestones diff --git a/addons/sale_project/tests/test_project_profitability.py b/addons/sale_project/tests/test_project_profitability.py new file mode 100644 index 00000000000..14dcda4242a --- /dev/null +++ b/addons/sale_project/tests/test_project_profitability.py @@ -0,0 +1,231 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from odoo.addons.sale.tests.common import TestSaleCommon +from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon as Common + + +class TestProjectProfitabilityCommon(Common): + @classmethod + def setUpClass(cls): + super().setUpClass() + uom_unit_id = cls.env.ref('uom.product_uom_unit').id + + # Create material product + cls.material_product = cls.env['product.product'].create({ + 'name': 'Material', + 'type': 'consu', + 'standard_price': 5, + 'list_price': 10, + 'invoice_policy': 'order', + 'uom_id': uom_unit_id, + 'uom_po_id': uom_unit_id, + }) + + # Create service products + uom_hour = cls.env.ref('uom.product_uom_hour') + cls.product_delivery_service = cls.env['product.product'].create({ + 'name': "Service Delivery, create task in global project", + 'standard_price': 30, + 'list_price': 90, + 'type': 'service', + 'invoice_policy': 'delivery', + 'service_type': 'manual', + 'uom_id': uom_hour.id, + 'uom_po_id': uom_hour.id, + 'default_code': 'SERV-ORDERED2', + 'service_tracking': 'task_global_project', + 'project_id': cls.project.id, + }) + cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({ + 'partner_id': cls.partner.id, + 'partner_invoice_id': cls.partner.id, + 'partner_shipping_id': cls.partner.id, + }) + SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=cls.sale_order.id) + cls.delivery_service_order_line = SaleOrderLine.create({ + 'product_id': cls.product_delivery_service.id, + 'product_uom_qty': 10, + }) + cls.sale_order.action_confirm() + + +@tagged('-at_install', 'post_install') +class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommon): + def test_project_profitability(self): + self.assertFalse(self.project.allow_billable, 'The project should be non billable.') + self.assertDictEqual( + self.project._get_profitability_items(), + self.project_profitability_items_empty, + 'No data for the project profitability should be found since the project is not billable, so no SOL is linked to the project.' + ) + self.project.write({'allow_billable': True}) + self.assertTrue(self.project.allow_billable, 'The project should be billable.') + self.project.sale_line_id = self.delivery_service_order_line + self.assertDictEqual( + self.project._get_profitability_items(), + self.project_profitability_items_empty, + 'No data for the project profitability should be found since no product is delivered in the SO linked.' + ) + self.delivery_service_order_line.qty_delivered = 1 + service_policy_to_invoice_type = self.project._get_service_policy_to_invoice_type() + invoice_type = service_policy_to_invoice_type[self.delivery_service_order_line.product_id.service_policy] + self.assertIn( + invoice_type, + ['billable_manual', 'service_revenues'], + 'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"') + self.assertDictEqual( + self.project._get_profitability_items(), + { + 'revenues': { + 'data': [ + { + # id should be equal to "billable_manual" if "sale_timesheet" module is installed otherwise "service_revenues" + 'id': invoice_type, + 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice, + 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced, + }, + ], + 'total': { + 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice, + 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced, + }, + }, + 'costs': { + 'data': [], + 'total': {'billed': 0.0, 'to_bill': 0.0}, + }, + } + ) + self.assertNotEqual(self.delivery_service_order_line.untaxed_amount_to_invoice, 0.0) + self.assertEqual(self.delivery_service_order_line.untaxed_amount_invoiced, 0.0) + + # create an invoice + context = { + 'active_model': 'sale.order', + 'active_ids': self.sale_order.ids, + 'active_id': self.sale_order.id, + } + invoices = self.env['sale.advance.payment.inv'].with_context(context).create({ + 'advance_payment_method': 'delivered', + })._create_invoices(self.sale_order) + invoices.action_post() + + self.assertEqual(self.delivery_service_order_line.qty_invoiced, 1) + self.assertEqual(self.delivery_service_order_line.untaxed_amount_to_invoice, 0.0) + self.assertNotEqual(self.delivery_service_order_line.untaxed_amount_invoiced, 0.0) + invoice_type = service_policy_to_invoice_type[self.delivery_service_order_line.product_id.service_policy] + self.assertIn( + invoice_type, + ['billable_manual', 'service_revenues'], + 'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"') + self.assertDictEqual( + self.project._get_profitability_items(), + { + 'revenues': { + 'data': [ + { + 'id': invoice_type, + 'to_invoice': 0.0, + 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced, + }, + ], + 'total': { + 'to_invoice': 0.0, + 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced, + }, + }, + 'costs': { + 'data': [], + 'total': {'billed': 0.0, 'to_bill': 0.0}, + }, + } + ) + + # Add 2 sales order items in the SO + SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=self.sale_order.id) + manual_service_order_line = SaleOrderLine.create({ + 'product_id': self.product_delivery_service.id, + 'product_uom_qty': 5, + 'qty_delivered': 5, + }) + material_order_line = SaleOrderLine.create({ + 'product_id': self.material_product.id, + 'product_uom_qty': 1, + 'qty_delivered': 1, + }) + service_sols = self.delivery_service_order_line + manual_service_order_line + invoice_type = service_policy_to_invoice_type[manual_service_order_line.product_id.service_policy] + self.assertIn( + invoice_type, + ['billable_manual', 'service_revenues'], + 'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"') + self.assertDictEqual( + self.project._get_profitability_items(), + { + 'revenues': { + 'data': [ + { + 'id': invoice_type, + 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')), + 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')), + }, + { + 'id': 'other_revenues', + '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, + }, + }, + 'costs': { # no cost because we have no purchase orders. + 'data': [], + 'total': {'billed': 0.0, 'to_bill': 0.0}, + }, + }, + ) + self.assertNotEqual(manual_service_order_line.untaxed_amount_to_invoice, 0.0) + self.assertEqual(manual_service_order_line.untaxed_amount_invoiced, 0.0) + self.assertNotEqual(material_order_line.untaxed_amount_to_invoice, 0.0) + self.assertEqual(material_order_line.untaxed_amount_invoiced, 0.0) + + credit_notes = invoices._reverse_moves() + credit_notes.action_post() + self.assertDictEqual( + self.project._get_profitability_items(), + { + 'revenues': { + 'data': [ + { + 'id': invoice_type, + 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')), + 'invoiced': manual_service_order_line.untaxed_amount_invoiced, + }, + { + 'id': 'other_revenues', + '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, + }, + }, + 'costs': { # no cost because we have no purchase orders. + 'data': [], + 'total': {'billed': 0.0, 'to_bill': 0.0}, + }, + }, + ) + + self.sale_order._action_cancel() + self.assertDictEqual( + self.project._get_profitability_items(), + self.project_profitability_items_empty, + ) diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index bf934f76042..10d5968fe6f 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -421,6 +421,15 @@ class Project(models.Model): }) return result + def _get_service_policy_to_invoice_type(self): + return { + **super()._get_service_policy_to_invoice_type(), + 'ordered_prepaid': 'billable_fixed', + 'delivered_milestones': 'billable_milestones', + 'delivered_timesheet': 'billable_time', + 'delivered_manual': 'billable_manual', + } + class ProjectTask(models.Model): _inherit = "project.task"