[IMP] sale_project,sale_timesheet: compute project revenues from SO

This commit is contained in:
Xavier BOL (xbo)
2022-04-01 15:33:00 +02:00
committed by Laurent Stukkens (LTU)
parent 42ce97a479
commit 50315deaa2
4 changed files with 308 additions and 0 deletions
+67
View File
@@ -275,6 +275,73 @@ class Project(models.Model):
self.ensure_one()
return self.allow_billable and super()._show_profitability()
def _get_service_policy_to_invoice_type(self):
return {
'ordered_prepaid': 'service_revenues',
'delivered_milestones': 'service_revenues',
'delivered_manual': 'service_revenues',
}
def _get_revenues_items_from_sol(self):
sale_line_read_group = self.env['sale.order.line'].read_group(
[
('order_id', 'in', self._get_sale_orders().ids),
('product_id', '!=', False),
('is_expense', '=', False),
('is_downpayment', '=', False),
('state', 'in', ['sale', 'done']),
'|', ('qty_to_invoice', '>', 0), ('qty_invoiced', '>', 0),
],
['product_id', 'untaxed_amount_to_invoice', 'untaxed_amount_invoiced'],
['product_id'],
)
revenues_dict = {}
total_to_invoice = total_invoiced = 0.0
if sale_line_read_group:
sols_per_product = {
res['product_id'][0]: (
res['untaxed_amount_to_invoice'],
res['untaxed_amount_invoiced'],
res['ids'],
) for res in sale_line_read_group
}
product_read_group = self.env['product.product'].read_group(
[('id', 'in', list(sols_per_product)), ('expense_policy', '=', 'no')],
['invoice_policy', 'service_type', 'type', 'ids:array_agg(id)'],
['invoice_policy', 'service_type', 'type'],
lazy=False,
)
service_policy_to_invoice_type = self._get_service_policy_to_invoice_type()
general_to_service_map = self.env['product.template']._get_general_to_service_map()
for res in product_read_group:
product_ids = res['ids']
service_policy = None
if res['type'] == 'service':
service_policy = general_to_service_map.get(
(res['invoice_policy'], res['service_type']),
'ordered_prepaid')
for product_id, (amount_to_invoice, amount_invoiced, sol_ids) in sols_per_product.items():
if product_id in product_ids:
invoice_type = service_policy_to_invoice_type.get(service_policy, 'other_revenues')
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
revenue['to_invoice'] += amount_to_invoice
total_to_invoice += amount_to_invoice
revenue['invoiced'] += amount_invoiced
total_invoiced += amount_invoiced
return {
'data': [{'id': invoice_type, **vals} for invoice_type, vals in revenues_dict.items()],
'total': {'to_invoice': total_to_invoice, 'invoiced': total_invoiced},
}
def _get_profitability_items(self):
profitability_items = super()._get_profitability_items()
revenue_items_from_sol = self._get_revenues_items_from_sol()
revenues = profitability_items['revenues']
revenues['data'] += revenue_items_from_sol['data']
revenues['total']['to_invoice'] += revenue_items_from_sol['total']['to_invoice']
revenues['total']['invoiced'] += revenue_items_from_sol['total']['invoiced']
return profitability_items
def _get_stat_buttons(self):
buttons = super(Project, self)._get_stat_buttons()
if self.user_has_groups('sales_team.group_sale_salesman_all_leads'):
+1
View File
@@ -2,5 +2,6 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_child_tasks
from . import test_project_profitability
from . import test_sale_project
from . import test_so_line_milestones
@@ -0,0 +1,231 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.addons.project.tests.test_project_profitability import TestProjectProfitabilityCommon as Common
class TestProjectProfitabilityCommon(Common):
@classmethod
def setUpClass(cls):
super().setUpClass()
uom_unit_id = cls.env.ref('uom.product_uom_unit').id
# Create material product
cls.material_product = cls.env['product.product'].create({
'name': 'Material',
'type': 'consu',
'standard_price': 5,
'list_price': 10,
'invoice_policy': 'order',
'uom_id': uom_unit_id,
'uom_po_id': uom_unit_id,
})
# Create service products
uom_hour = cls.env.ref('uom.product_uom_hour')
cls.product_delivery_service = cls.env['product.product'].create({
'name': "Service Delivery, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'service_type': 'manual',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED2',
'service_tracking': 'task_global_project',
'project_id': cls.project.id,
})
cls.sale_order = cls.env['sale.order'].with_context(tracking_disable=True).create({
'partner_id': cls.partner.id,
'partner_invoice_id': cls.partner.id,
'partner_shipping_id': cls.partner.id,
})
SaleOrderLine = cls.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=cls.sale_order.id)
cls.delivery_service_order_line = SaleOrderLine.create({
'product_id': cls.product_delivery_service.id,
'product_uom_qty': 10,
})
cls.sale_order.action_confirm()
@tagged('-at_install', 'post_install')
class TestSaleProjectProfitability(TestProjectProfitabilityCommon, TestSaleCommon):
def test_project_profitability(self):
self.assertFalse(self.project.allow_billable, 'The project should be non billable.')
self.assertDictEqual(
self.project._get_profitability_items(),
self.project_profitability_items_empty,
'No data for the project profitability should be found since the project is not billable, so no SOL is linked to the project.'
)
self.project.write({'allow_billable': True})
self.assertTrue(self.project.allow_billable, 'The project should be billable.')
self.project.sale_line_id = self.delivery_service_order_line
self.assertDictEqual(
self.project._get_profitability_items(),
self.project_profitability_items_empty,
'No data for the project profitability should be found since no product is delivered in the SO linked.'
)
self.delivery_service_order_line.qty_delivered = 1
service_policy_to_invoice_type = self.project._get_service_policy_to_invoice_type()
invoice_type = service_policy_to_invoice_type[self.delivery_service_order_line.product_id.service_policy]
self.assertIn(
invoice_type,
['billable_manual', 'service_revenues'],
'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"')
self.assertDictEqual(
self.project._get_profitability_items(),
{
'revenues': {
'data': [
{
# id should be equal to "billable_manual" if "sale_timesheet" module is installed otherwise "service_revenues"
'id': invoice_type,
'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice,
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
},
],
'total': {
'to_invoice': self.delivery_service_order_line.untaxed_amount_to_invoice,
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
},
},
'costs': {
'data': [],
'total': {'billed': 0.0, 'to_bill': 0.0},
},
}
)
self.assertNotEqual(self.delivery_service_order_line.untaxed_amount_to_invoice, 0.0)
self.assertEqual(self.delivery_service_order_line.untaxed_amount_invoiced, 0.0)
# create an invoice
context = {
'active_model': 'sale.order',
'active_ids': self.sale_order.ids,
'active_id': self.sale_order.id,
}
invoices = self.env['sale.advance.payment.inv'].with_context(context).create({
'advance_payment_method': 'delivered',
})._create_invoices(self.sale_order)
invoices.action_post()
self.assertEqual(self.delivery_service_order_line.qty_invoiced, 1)
self.assertEqual(self.delivery_service_order_line.untaxed_amount_to_invoice, 0.0)
self.assertNotEqual(self.delivery_service_order_line.untaxed_amount_invoiced, 0.0)
invoice_type = service_policy_to_invoice_type[self.delivery_service_order_line.product_id.service_policy]
self.assertIn(
invoice_type,
['billable_manual', 'service_revenues'],
'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"')
self.assertDictEqual(
self.project._get_profitability_items(),
{
'revenues': {
'data': [
{
'id': invoice_type,
'to_invoice': 0.0,
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
},
],
'total': {
'to_invoice': 0.0,
'invoiced': self.delivery_service_order_line.untaxed_amount_invoiced,
},
},
'costs': {
'data': [],
'total': {'billed': 0.0, 'to_bill': 0.0},
},
}
)
# Add 2 sales order items in the SO
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True, default_order_id=self.sale_order.id)
manual_service_order_line = SaleOrderLine.create({
'product_id': self.product_delivery_service.id,
'product_uom_qty': 5,
'qty_delivered': 5,
})
material_order_line = SaleOrderLine.create({
'product_id': self.material_product.id,
'product_uom_qty': 1,
'qty_delivered': 1,
})
service_sols = self.delivery_service_order_line + manual_service_order_line
invoice_type = service_policy_to_invoice_type[manual_service_order_line.product_id.service_policy]
self.assertIn(
invoice_type,
['billable_manual', 'service_revenues'],
'invoice_type="billable_manual" if sale_timesheet is installed otherwise it is equal to "service_revenues"')
self.assertDictEqual(
self.project._get_profitability_items(),
{
'revenues': {
'data': [
{
'id': invoice_type,
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')),
'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')),
},
{
'id': 'other_revenues',
'to_invoice': material_order_line.untaxed_amount_to_invoice,
'invoiced': material_order_line.untaxed_amount_invoiced,
},
],
'total': {
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice,
'invoiced': sum(service_sols.mapped('untaxed_amount_invoiced')) + material_order_line.untaxed_amount_invoiced,
},
},
'costs': { # no cost because we have no purchase orders.
'data': [],
'total': {'billed': 0.0, 'to_bill': 0.0},
},
},
)
self.assertNotEqual(manual_service_order_line.untaxed_amount_to_invoice, 0.0)
self.assertEqual(manual_service_order_line.untaxed_amount_invoiced, 0.0)
self.assertNotEqual(material_order_line.untaxed_amount_to_invoice, 0.0)
self.assertEqual(material_order_line.untaxed_amount_invoiced, 0.0)
credit_notes = invoices._reverse_moves()
credit_notes.action_post()
self.assertDictEqual(
self.project._get_profitability_items(),
{
'revenues': {
'data': [
{
'id': invoice_type,
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')),
'invoiced': manual_service_order_line.untaxed_amount_invoiced,
},
{
'id': 'other_revenues',
'to_invoice': material_order_line.untaxed_amount_to_invoice,
'invoiced': material_order_line.untaxed_amount_invoiced,
},
],
'total': {
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')) + material_order_line.untaxed_amount_to_invoice,
'invoiced': manual_service_order_line.untaxed_amount_invoiced + material_order_line.untaxed_amount_invoiced,
},
},
'costs': { # no cost because we have no purchase orders.
'data': [],
'total': {'billed': 0.0, 'to_bill': 0.0},
},
},
)
self.sale_order._action_cancel()
self.assertDictEqual(
self.project._get_profitability_items(),
self.project_profitability_items_empty,
)
+9
View File
@@ -421,6 +421,15 @@ class Project(models.Model):
})
return result
def _get_service_policy_to_invoice_type(self):
return {
**super()._get_service_policy_to_invoice_type(),
'ordered_prepaid': 'billable_fixed',
'delivered_milestones': 'billable_milestones',
'delivered_timesheet': 'billable_time',
'delivered_manual': 'billable_manual',
}
class ProjectTask(models.Model):
_inherit = "project.task"