1160 lines
62 KiB
Python
1160 lines
62 KiB
Python
# -*- 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,
|
|
},
|
|
},
|
|
)
|