# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import datetime from odoo import Command from odoo.tests import tagged from odoo.tools.float_utils import float_compare 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 cls.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': cls.uom_hour.id, 'uom_po_id': cls.uom_hour.id, 'default_code': 'SERV-ORDERED2', 'service_tracking': 'task_global_project', 'project_id': cls.project.id, }) cls.down_payment_product = cls.env['product.product'].create({ 'name': "downpayment product, used to simulate down payments", 'standard_price': 30, 'type': 'service', 'service_policy': 'ordered_prepaid', }) 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() 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, }) cls.project_billable_no_company = cls.env['project.project'].create({'name': 'project billable', 'allow_billable': True}) cls.project_billable_no_company._create_analytic_account() @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): foreign_company = self.company_data_2['company'] foreign_company.currency_id = self.foreign_currency companies = foreign_company | self.sale_order.company_id self.project.company_id = False self.assertFalse(self.project.allow_billable, 'The project should be non billable.') self.assertDictEqual( self.project._get_profitability_items(False), 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(False), self.project_profitability_items_empty, 'No data for the project profitability should be found since no product is delivered in the SO linked.' ) # Add 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, }]) # Create and confirm a SO in a foreign company. product_delivery_service_foreign = self.env['product.product'].with_company(foreign_company).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': self.uom_hour.id, 'uom_po_id': self.uom_hour.id, 'default_code': 'SERV-ORDERED2', 'service_tracking': 'task_global_project', 'project_id': self.project.id, }) sale_order_foreign = self.env['sale.order'].with_context(tracking_disable=True).create({ 'partner_id': self.partner.id, 'partner_invoice_id': self.partner.id, 'partner_shipping_id': self.partner.id, 'company_id': foreign_company.id, }) sale_order_foreign.currency_id = self.foreign_currency.id sol_foreign = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=sale_order_foreign.id).create({ 'product_id': product_delivery_service_foreign.id, 'product_uom_qty': 10, 'company_id': foreign_company.id, }) sale_order_foreign.action_confirm() sol_foreign.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"') sequence_per_invoice_type = self.project._get_profitability_sequence_per_invoice_type() # Ensures that when the only SO linked to the project is a foreign SO, the currency used is the default one, and not the currency of the SO. 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, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0, }, ], 'total': { 'to_invoice': sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': 100.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}, }, } ) self.assertNotEqual(sol_foreign.untaxed_amount_to_invoice, 0.0) self.assertEqual(sol_foreign.untaxed_amount_invoiced, 0.0) # Set the qty_delivered of the sol of the main so to 1, this sol should now be computed for the project_profitability. self.delivery_service_order_line.qty_delivered = 1 self.assertIn('service_revenues', sequence_per_invoice_type) 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, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice + sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': 0.0, }, ], 'total': { 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice + sol_foreign.untaxed_amount_to_invoice * 0.2, '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}, }, } ) 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 and post an invoice for the foreign SO. context = { 'active_model': 'sale.order', 'active_ids': sale_order_foreign.ids, 'active_id': sale_order_foreign.id, 'allowed_company_ids': companies.ids, } invoices_foreign = self.env['sale.advance.payment.inv'].with_context(context).create({ 'advance_payment_method': 'delivered', })._create_invoices(sale_order_foreign) invoices_foreign.action_post() # Ensures the foreign SO sols are now computed for the 'invoiced' section, while the sol's from the main SO are still in the 'to_invoice' section 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, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice, 'invoiced': sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice, 'invoiced': 100 + sol_foreign.untaxed_amount_invoiced * 0.2, }, }, '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}, }, } ) self.assertEqual(sol_foreign.qty_invoiced, 1) self.assertEqual(sol_foreign.untaxed_amount_to_invoice, 0.0) self.assertNotEqual(sol_foreign.untaxed_amount_invoiced, 0.0) # Create and post an invoice for the main SO. 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() 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"') # Ensures that the 'to_invoice' section is now empty, and the 'invoiced' section contains the amount from all the sol's. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': 0.0, 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': 0.0, 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + 100 + sol_foreign.untaxed_amount_invoiced * 0.2, }, }, '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}, }, } ) 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) # Add 2 sale order item to the foreign SO. SaleOrderLineForeign = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=sale_order_foreign.id) manual_service_sol_foreign, material_sol_foreign = SaleOrderLineForeign.create([{ 'product_id': self.product_delivery_service.id, 'product_uom_qty': 5, 'qty_delivered': 5, }, { 'product_id': self.material_product.id, 'product_uom_qty': 1, 'qty_delivered': 1, }]) service_sols_foreign = sol_foreign + manual_service_sol_foreign # Ensures that the 'materials' section is now present, and that the new manual sol is computed in the 'to_invoice' section. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) * 0.2, 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) * 0.2, }, { 'id': 'materials', 'sequence': sequence_per_invoice_type['materials'], 'to_invoice': material_sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': material_sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': (sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) + material_sol_foreign.untaxed_amount_to_invoice) * 0.2, 'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced + (sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) + material_sol_foreign.untaxed_amount_invoiced) * 0.2 + 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}, }, }, ) self.assertNotEqual(manual_service_sol_foreign.untaxed_amount_to_invoice, 0.0) self.assertEqual(manual_service_sol_foreign.untaxed_amount_invoiced, 0.0) self.assertNotEqual(material_sol_foreign.untaxed_amount_to_invoice, 0.0) self.assertEqual(material_sol_foreign.untaxed_amount_invoiced, 0.0) # Add 2 sales order items in the main 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"') # Ensures that the 'materials' section contains the material sol from the main company, and that the new manual sol is computed in the 'to_invoice' section. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) * 0.2, }, { 'id': 'materials', 'sequence': sequence_per_invoice_type['materials'], 'to_invoice': material_order_line.untaxed_amount_to_invoice + material_sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': material_order_line.untaxed_amount_invoiced + material_sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice + (sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) + material_sol_foreign.untaxed_amount_to_invoice) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced + (sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) + material_sol_foreign.untaxed_amount_invoiced) * 0.2 + 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}, }, }, ) 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) # Revert the invoice from the foreign SO. credit_notes = invoices_foreign._reverse_moves() credit_notes.action_post() # Ensures that the sols that were invoiced are computed in the 'to_invoice' section again. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) * 0.2, }, { 'id': 'materials', 'sequence': sequence_per_invoice_type['materials'], 'to_invoice': material_order_line.untaxed_amount_to_invoice + material_sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': material_order_line.untaxed_amount_invoiced + material_sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice + (sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) + material_sol_foreign.untaxed_amount_to_invoice) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced + (sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) + material_sol_foreign.untaxed_amount_invoiced) * 0.2 + 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}, }, }, ) self.assertEqual(sol_foreign.qty_invoiced, 0.0) self.assertNotEqual(sol_foreign.untaxed_amount_to_invoice, 0.0) self.assertEqual(sol_foreign.untaxed_amount_invoiced, 0.0) # Revert the invoice from the main SO. credit_notes = invoices._reverse_moves() credit_notes.action_post() # Ensures that the sols that were invoiced are computed in the 'to_invoice' section again. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) * 0.2, }, { 'id': 'materials', 'sequence': sequence_per_invoice_type['materials'], 'to_invoice': material_order_line.untaxed_amount_to_invoice + material_sol_foreign.untaxed_amount_to_invoice * 0.2, 'invoiced': material_order_line.untaxed_amount_invoiced + material_sol_foreign.untaxed_amount_invoiced * 0.2, }, ], 'total': { 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice + (sum(service_sols_foreign.mapped('untaxed_amount_to_invoice')) + material_sol_foreign.untaxed_amount_to_invoice) * 0.2, 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced + (sum(service_sols_foreign.mapped('untaxed_amount_invoiced')) + material_sol_foreign.untaxed_amount_invoiced) * 0.2 + 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}, }, }, ) self.assertEqual(self.delivery_service_order_line.qty_invoiced, 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) # Cancel the foreign SO. sale_order_foreign._action_cancel() # Ensures that the panel now contains only the sols from the main SO. 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': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')), 'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')), }, { '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 + 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}, }, }, ) # Create a down payment for a fixed amount of 115. Downpayment = { 'active_model': 'sale.order', 'active_ids': self.sale_order.ids, 'active_id': self.sale_order.id, 'default_journal_id': self.company_data['default_journal_sale'].id, } downpayment = self.env['sale.advance.payment.inv'].with_context(Downpayment).create({ 'advance_payment_method': 'fixed', 'fixed_amount': 115, 'deposit_account_id': self.company_data['default_account_revenue'].id, }) # When a down payment is created, the default 15% tax is included. The SOL associated it then created by removing the taxed amount. # Therefore, the amount of the dp is higher than the amount of the sol created. down_payment_invoiced = 100.01 downpayment.create_invoices() self.sale_order.invoice_ids[2].action_post() # Ensures the down payment is correctly computed for the project profitability. self._assert_dict_equal(invoice_type, sequence_per_invoice_type, material_order_line, service_sols, manual_service_order_line, down_payment_invoiced) # Create a second down payment for a fixed amount of 115. downpayment = self.env['sale.advance.payment.inv'].with_context(Downpayment).create({ 'advance_payment_method': 'fixed', 'fixed_amount': 115, 'deposit_account_id': self.company_data['default_account_revenue'].id, }) down_payment_invoiced = 2 * down_payment_invoiced downpayment.create_invoices() self.sale_order.invoice_ids[3].action_post() # Ensures the 2 down payments are correctly computed for the project profitability. self._assert_dict_equal(invoice_type, sequence_per_invoice_type, material_order_line, service_sols, manual_service_order_line, down_payment_invoiced) for sol in sale_order_foreign.order_line: self.assertEqual(sol.untaxed_amount_to_invoice, 0.0) self.assertEqual(sol.untaxed_amount_invoiced, 0.0) # Cancel the main SO. self.sale_order._action_cancel() # Ensures that the panel no longer contains any SOL related section self.assertDictEqual( self.project._get_profitability_items(False), { # 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}, }, }, ) #downpayment invoiced amount are not updated when the SO is canceled. for sol in self.sale_order.order_line: if sol.is_downpayment: continue self.assertEqual(sol.untaxed_amount_to_invoice, 0.0) self.assertEqual(sol.untaxed_amount_invoiced, 0.0) def _assert_dict_equal(self, invoice_type, sequence_per_invoice_type, material_order_line, service_sols, manual_service_order_line, down_payment_invoiced): 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': 'downpayments', 'sequence': 20, 'invoiced': down_payment_invoiced, 'to_invoice': -down_payment_invoiced, }, { 'id': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], 'invoiced': manual_service_order_line.untaxed_amount_invoiced, 'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')), }, { 'id': 'materials', 'sequence': sequence_per_invoice_type['materials'], 'invoiced': material_order_line.untaxed_amount_invoiced, 'to_invoice': material_order_line.untaxed_amount_to_invoice, }, ], 'total': { 'invoiced': manual_service_order_line.untaxed_amount_invoiced + material_order_line.untaxed_amount_invoiced + down_payment_invoiced + 100, 'to_invoice': sum(service_sols.mapped( 'untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice - down_payment_invoiced, }, }, '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): """ An invoice that has an AAL on one of its line should be taken into account for the profitability of the project. The contribution of the line should only be dependent on the project's analytic account % that was set on the line """ foreign_company = self.company_data_2['company'] foreign_company.currency_id = self.foreign_currency # a custom analytic contribution (number between 1 -> 100 included) analytic_distribution = 50 analytic_contribution = analytic_distribution / 100. # Create an invoice with a foreign company with the AAL linked to the project account. invoice_1_foreign = self.env['account.move'].create({ "name": "Invoice_1", "move_type": "out_invoice", "state": "draft", "partner_id": self.partner.id, "invoice_date": datetime.today(), "company_id": foreign_company.id, "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_a.id, "quantity": 1, "product_uom_id": self.product_a.uom_id.id, "price_unit": self.product_a.standard_price, "currency_id": self.foreign_currency.id, })], }) # The invoice with foreign company is in draft, therefore its total is in the 'to invoice' section. The total should be update by the choas orb/dollar rate (0.2) self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': self.product_a.standard_price * analytic_contribution * 0.2, 'invoiced': 0.0, }], 'total': {'to_invoice': self.product_a.standard_price * analytic_contribution * 0.2, 'invoiced': 0.0}, }, ) # Create an invoice_1 with the AAL linked to the project account. invoice_1 = self.env['account.move'].create({ "name": "Invoice_1", "move_type": "out_invoice", "state": "draft", "partner_id": self.partner.id, "invoice_date": datetime.today(), "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_a.id, "quantity": 1, "product_uom_id": self.product_a.uom_id.id, "price_unit": self.product_a.standard_price, })], }) # The invoice_1 is in draft, therefore its total should be added to the 'to_invoice' section. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': self.product_a.standard_price * analytic_contribution * 1.2, 'invoiced': 0.0, }], 'total': {'to_invoice': self.product_a.standard_price * analytic_contribution * 1.2, 'invoiced': 0.0}, }, ) # post invoice_1 invoice_1.action_post() # We posted the invoice_1, therefore its total should be in the 'invoiced' section. The 'to_invoice' section should now contain only the foreign invoice. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': self.product_a.standard_price * analytic_contribution * 0.2, 'invoiced': self.product_a.standard_price * analytic_contribution, }], 'total': {'to_invoice': self.product_a.standard_price * analytic_contribution * 0.2, 'invoiced': self.product_a.standard_price * analytic_contribution}, }, ) invoice_1_foreign.action_post() # We posted the foreign invoice 1. Its total should now be in the 'invoiced' section. The 'to_invoice' section should be 0. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': 0.0, 'invoiced': self.product_a.standard_price * analytic_contribution * 1.2, }], 'total': {'to_invoice': 0.0, 'invoiced': self.product_a.standard_price * analytic_contribution * 1.2}, }, ) # Ensures the sale_line_ids from multiple invoices from the same company are correctly computed. # Create another invoice, with 2 lines, 2 diff products, the second line has a quantity of 2, the third line has a negative amount NEG_AMOUNT = -42 invoice_2 = self.env['account.move'].create({ "name": "I have 2 lines", "move_type": "out_invoice", "state": "draft", "partner_id": self.partner.id, "invoice_date": datetime.today(), "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "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.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_b.id, "quantity": 2, "product_uom_id": self.product_b.uom_id.id, "price_unit": self.product_b.standard_price, }), Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_b.id, "quantity": 1, "product_uom_id": self.product_b.uom_id.id, "price_unit": NEG_AMOUNT, })], }) # The invoice_2 is not posted, therefore its cost should be in the "to_invoice" section self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, 'invoiced': self.product_a.standard_price * analytic_contribution * 1.2, }], 'total': { 'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, 'invoiced': self.product_a.standard_price * analytic_contribution * 1.2, }, }, ) # post invoice_2 invoice_2.action_post() # The invoice_2 is posted, therefore its cost should be in the "invoiced" section self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': 0.0, 'invoiced': (2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, }], 'total': { 'to_invoice': 0.0, 'invoiced': (2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, }, }, ) # Create another invoice, with 2 lines, 2 diff products, the second line has a quantity of 2 with a foreign company. invoice_2_foreign = self.env['account.move'].create({ "name": "I have 2 lines", "move_type": "out_invoice", "state": "draft", "partner_id": self.partner.id, "invoice_date": datetime.today(), "company_id": foreign_company.id, "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_a.id, "quantity": 1, "product_uom_id": self.product_a.uom_id.id, "price_unit": self.product_a.standard_price, "currency_id": self.foreign_currency.id, }), Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.id: analytic_distribution}, "product_id": self.product_b.id, "quantity": 2, "product_uom_id": self.product_b.uom_id.id, "price_unit": self.product_b.standard_price, "currency_id": self.foreign_currency.id, })], }) self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['revenues'], { 'data': [{ 'id': 'other_invoice_revenues', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'], 'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution * 0.2, 'invoiced': (2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, }], 'total': { 'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution * 0.2, 'invoiced': (2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price + NEG_AMOUNT) * analytic_contribution, }, }, ) invoice_2_foreign.action_post() # Note : for some reason, the method to round the amount to the rounding of the currency is not 100% reliable. # We use a float_compare in order to ensure the value is close enough to the expected result. This problem has no repercusion on the client side, since # there is also a rounding method on this side to ensure the amount is correctly displayed. items = self.project_billable_no_company._get_profitability_items(False)['revenues'] self.assertEqual(float_compare(((self.product_a.standard_price + self.product_b.standard_price) * 2.4 + NEG_AMOUNT) * analytic_contribution, items['data'][0]['invoiced'], 2), 0) self.assertEqual(float_compare(((self.product_a.standard_price + self.product_b.standard_price) * 2.4 + NEG_AMOUNT) * analytic_contribution, items['total']['invoiced'], 2), 0) self.assertEqual(items['data'][0]['id'], 'other_invoice_revenues') self.assertEqual(items['data'][0]['sequence'], self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_invoice_revenues']) self.assertEqual(items['data'][0]['to_invoice'], 0.0) self.assertEqual(items['total']['to_invoice'], 0.0) 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. """ foreign_company = self.company_data_2['company'] foreign_company.currency_id = self.foreign_currency # Create a bill with its purchase line linked to the AA of the project, and a foreign company. bill_1_foreign = self.env['account.move'].create({ "name": "Bill_1 name", "move_type": "in_invoice", "state": "draft", "partner_id": self.partner.id, "invoice_date": datetime.today(), "company_id": foreign_company.id, "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.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, "currency_id": self.foreign_currency.id })], }) # Add 2 new AAL to the analytic account. Those costs must be present in the 'other_cost' section self.env['account.analytic.line'].create([{ 'name': 'extra costs 1', 'account_id': self.project_billable_no_company.analytic_account_id.id, 'amount': -50, }, { 'name': 'extra costs 2', 'account_id': self.project_billable_no_company.analytic_account_id.id, 'amount': -100, }]) # Ensures that the amount of the 'other_purchase_cost' is correctly scale to the currency of the main company. # Ensures that the 'other_cost' is not mixed within the 'other_purchase_costs' section and vice-versa self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': -self.product_a.standard_price * 0.2, 'billed': 0.0, }], 'total': {'to_bill': -self.product_a.standard_price * 0.2, 'billed': -150.0}, }, ) # Create a bill with its purchase line linked to the AA of the project, and the main company. 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.project_billable_no_company.analytic_account_id.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, })], }) # Ensures that the amount from the bill_1 is in the 'to_bill' section of the 'other_purchase_cost' self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': -self.product_a.standard_price * 1.2, 'billed': 0.0, }], 'total': {'to_bill': -self.product_a.standard_price * 1.2, 'billed': -150.0}, }, ) # post bill_1 bill_1.action_post() # We posted the bill_1, therefore its cost should now be in the 'billed' section. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': -self.product_a.standard_price * 0.2, 'billed': -self.product_a.standard_price, }], 'total': {'to_bill': -self.product_a.standard_price * 0.2, 'billed': -self.product_a.standard_price - 150}, }, ) bill_1_foreign.action_post() # We posted the bill_1_foreign, therefore its cost should now be in the 'billed' section. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': 0.0, 'billed': -self.product_a.standard_price * 1.2, }], 'total': {'to_bill': 0.0, 'billed': -self.product_a.standard_price * 1.2 - 150}, }, ) # Create another bill, with 2 lines, 2 different products and different quantities 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.project_billable_no_company.analytic_account_id.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.project_billable_no_company.analytic_account_id.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, })], }) # Ensures that when there are more than one bill/move_line from one company, all the lines are computed. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._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 * 1.2, }], 'total': { 'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price), 'billed': -self.product_a.standard_price * 1.2 - 150, }, }, ) # post bill_2 bill_2.action_post() # The bill_2 is posted, therefore its cost should now be in the 'billed' section. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': 0.0, 'billed': -(2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price), }], 'total': { 'to_bill': 0.0, 'billed': -(2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price) - 150, }, }, ) # Create another bill, with 2 lines, 2 different products, different quantities and a foreign company. bill_2_foreign = 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(), "company_id": foreign_company.id, "invoice_line_ids": [Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.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, "currency_id": self.foreign_currency.id, }), Command.create({ "analytic_distribution": {self.project_billable_no_company.analytic_account_id.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, "currency_id": self.foreign_currency.id, })], }) # Ensures that when there are more than one bill/move_line from one company, all the lines are computed and correctly scaled with the currency of the main company. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()['other_purchase_costs'], 'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price) * 0.2, 'billed': -(2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price), }], 'total': { 'to_bill': -(self.product_a.standard_price + 2 * self.product_b.standard_price) * 0.2, 'billed': -(2.2 * self.product_a.standard_price + 2 * self.product_b.standard_price) - 150, }, }, ) # post bill_2_foreign bill_2_foreign.action_post() # The bill_2_foreign is posted, therefore its cost should now be in the 'billed' section. self.assertDictEqual( self.project_billable_no_company._get_profitability_items(False)['costs'], { 'data': [{ 'id': 'other_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()[ 'other_costs'], 'to_bill': 0.0, 'billed': -150.0, }, { 'id': 'other_purchase_costs', 'sequence': self.project_billable_no_company._get_profitability_sequence_per_invoice_type()[ 'other_purchase_costs'], 'to_bill': 0.0, 'billed': -2.4 * (self.product_a.standard_price + self.product_b.standard_price), }], 'total': { 'to_bill': 0.0, 'billed': -2.4 * (self.product_a.standard_price + self.product_b.standard_price) - 150, }, }, )