[IMP] sale_project,sale_timesheet: compute project revenues from SO
This commit is contained in:
committed by
Laurent Stukkens (LTU)
parent
42ce97a479
commit
50315deaa2
@@ -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'):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user