Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import common
from . import test_child_tasks
from . import test_project_profitability
from . import test_res_config_settings
from . import test_sale_project
from . import test_so_line_milestones

View file

@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.common import TestSaleCommon
class TestSaleProjectCommon(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env['res.config.settings'] \
.create({'group_project_milestone': True}) \
.execute()
cls.uom_hour = cls.env.ref('uom.product_uom_hour')
cls.account_sale = cls.company_data['default_account_revenue']
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan Test',
'company_id': cls.company_data['company'].id,
})
cls.analytic_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'code': 'AA-2030',
'plan_id': cls.analytic_plan.id,
'company_id': cls.company_data['company'].id,
})
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_global = Project.create({
'name': 'Project Global',
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = Project.create({
'name': 'Project TEMPLATE for services',
})
cls.project_template_state = cls.env['project.task.type'].create({
'name': 'Only stage in project template',
'sequence': 1,
'project_ids': [(4, cls.project_template.id)]
})
# -- manual (delivered, manual)
cls.product_delivery_manual1 = cls.env['product.product'].create({
'name': "Service delivered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'manual',
'service_tracking': 'no',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual2 = cls.env['product.product'].create({
'name': "Service delivered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI2',
'service_type': 'manual',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual3 = cls.env['product.product'].create({
'name': "Service delivered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'manual',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual4 = cls.env['product.product'].create({
'name': "Service delivered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
cls.product_delivery_manual5 = cls.env['product.product'].create({
'name': "Service delivered, create project only with template",
'standard_price': 17,
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'manual',
'service_tracking': 'project_only',
'project_id': False,
'project_template_id': cls.project_template.id,
'taxes_id': False,
'property_account_income_id': cls.account_sale.id,
})
# -- devliered_milestones (delivered, milestones)
product_milestone_vals = {
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-MILES',
'service_type': 'milestones',
'service_tracking': 'no',
'property_account_income_id': cls.account_sale.id,
}
cls.product_milestone, cls.product_milestone2 = cls.env['product.product'].create([
{**product_milestone_vals, 'name': 'Milestone Product', 'list_price': 20},
{**product_milestone_vals, 'name': 'Milestone Product 2', 'list_price': 15},
])
def set_project_milestone_feature(self, value):
self.env['res.config.settings'] \
.create({'group_project_milestone': value}) \
.execute()

View file

@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details
from odoo.tests.common import TransactionCase, new_test_user
class TestNestedTaskUpdate(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': "Mur en béton"})
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,
})
product = cls.env['product.product'].create({
'name': "Prepaid Consulting",
'type': 'service',
})
cls.order_line = cls.env['sale.order.line'].create({
'name': "Order line",
'product_id': product.id,
'order_id': sale_order.id,
})
cls.user = new_test_user(cls.env, login='mla')
#----------------------------------
#
# When creating tasks that have a parent_id, they pick some values from their parent
#
#----------------------------------
def test_creating_subtask_user_id_on_parent_dont_go_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'user_ids': False})
self.assertFalse(child.user_ids)
def test_creating_subtask_partner_id_on_parent_goes_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.user.partner_id.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
child._compute_partner_id() # the compute will be triggered since the user set the parent_id.
self.assertEqual(child.partner_id, self.user.partner_id)
# Another case, it is the parent as a default value
child = self.env['project.task'].with_context(default_parent_id=parent.id).create({'name': 'child'})
self.assertEqual(child.partner_id, self.user.partner_id)
def test_creating_subtask_email_from_on_parent_goes_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'email_from': 'a@c.be'})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
self.assertEqual(child.email_from, 'a@c.be')
def test_creating_subtask_sale_line_id_on_parent_goes_on_child_if_same_partner_in_values(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': self.partner.id, 'parent_id': parent.id})
self.assertEqual(child.sale_line_id, parent.sale_line_id)
parent.write({'sale_line_id': False})
self.assertEqual(child.sale_line_id, self.order_line)
def test_creating_subtask_sale_line_id_on_parent_goes_on_child_with_partner_if_not_in_values(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
self.assertEqual(child.partner_id, parent.partner_id)
self.assertEqual(child.sale_line_id, parent.sale_line_id)
def test_creating_subtask_sale_line_id_on_parent_dont_go_on_child_if_other_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': self.user.partner_id.id, 'parent_id': parent.id})
self.assertFalse(child.sale_line_id)
self.assertNotEqual(child.partner_id, parent.partner_id)
def test_creating_subtask_sale_line_id_on_parent_go_on_child_if_same_commercial_partner(self):
commercial_partner = self.env['res.partner'].create({'name': "Jémémy"})
self.partner.parent_id = commercial_partner
self.user.partner_id.parent_id = commercial_partner
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': self.user.partner_id.id, 'parent_id': parent.id})
self.assertEqual(child.sale_line_id, self.order_line, "Sale order line on parent should be transfered to child")
self.assertNotEqual(child.partner_id, parent.partner_id)
#----------------------------------------
#
# When writing on a parent task, some values adapt on their children
#
#----------------------------------------
def test_write_user_id_on_parent_dont_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False, 'parent_id': parent.id})
self.assertFalse(child.user_ids)
parent.write({'user_ids': [(4, self.user.id)]})
self.assertFalse(child.user_ids)
parent.write({'user_ids': False})
self.assertFalse(child.user_ids)
def test_write_partner_id_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': False})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False, 'parent_id': parent.id})
self.assertFalse(child.partner_id)
parent.write({'partner_id': self.user.partner_id.id})
self.assertNotEqual(child.partner_id, parent.partner_id)
parent.write({'partner_id': False})
self.assertNotEqual(child.partner_id, self.user.partner_id)
def test_write_email_from_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent'})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
self.assertFalse(child.email_from)
parent.write({'email_from': 'a@c.be'})
self.assertEqual(child.email_from, parent.email_from)
parent.write({'email_from': ''})
self.assertEqual(child.email_from, 'a@c.be')
def test_write_sale_line_id_on_parent_write_on_child_if_same_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'partner_id': self.partner.id})
self.assertFalse(child.sale_line_id)
parent.write({'sale_line_id': self.order_line.id})
self.assertEqual(child.sale_line_id, parent.sale_line_id)
parent.write({'sale_line_id': False})
self.assertEqual(child.sale_line_id, self.order_line)
def test_write_sale_line_id_on_parent_write_on_child_with_partner_if_not_set(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id})
child._compute_partner_id()
self.assertFalse(child.sale_line_id)
parent.write({'sale_line_id': self.order_line.id})
self.assertEqual(child.sale_line_id, parent.sale_line_id)
self.assertEqual(child.partner_id, self.partner)
parent.write({'sale_line_id': False})
self.assertEqual(child.sale_line_id, self.order_line)
def test_write_sale_line_id_on_parent_dont_write_on_child_if_other_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id})
child = self.env['project.task'].create({'name': 'child', 'parent_id': parent.id, 'partner_id': self.user.partner_id.id})
self.assertFalse(child.sale_line_id)
parent.write({'sale_line_id': self.order_line.id})
self.assertFalse(child.sale_line_id)
#----------------------------------
#
# When linking two existent task, some values go on the child
#
#----------------------------------
def test_linking_user_id_on_parent_dont_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': [(4, self.user.id)]})
child = self.env['project.task'].create({'name': 'child', 'user_ids': False})
self.assertFalse(child.user_ids)
child.write({'parent_id': parent.id})
self.assertFalse(child.user_ids)
def test_linking_partner_id_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.user.partner_id.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False})
self.assertFalse(child.partner_id)
child.write({'parent_id': parent.id})
self.assertEqual(child.partner_id, self.user.partner_id)
def test_linking_email_from_on_parent_write_on_child(self):
parent = self.env['project.task'].create({'name': 'parent', 'email_from': 'a@c.be'})
child = self.env['project.task'].create({'name': 'child', 'email_from': False})
self.assertFalse(child.email_from)
child.write({'parent_id': parent.id})
self.assertEqual(child.email_from, 'a@c.be')
def test_linking_sale_line_id_on_parent_write_on_child_if_same_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': self.partner.id})
self.assertFalse(child.sale_line_id)
child.write({'parent_id': parent.id})
self.assertEqual(child.sale_line_id, parent.sale_line_id)
parent.write({'sale_line_id': False})
self.assertEqual(child.sale_line_id, self.order_line)
def test_linking_sale_line_id_on_parent_write_on_child_with_partner_if_not_set(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': False})
self.assertFalse(child.sale_line_id)
self.assertFalse(child.partner_id)
child.write({'parent_id': parent.id})
self.assertEqual(child.partner_id, parent.partner_id)
self.assertEqual(child.sale_line_id, parent.sale_line_id)
def test_linking_sale_line_id_on_parent_write_dont_child_if_other_partner(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id})
child = self.env['project.task'].create({'name': 'child', 'partner_id': self.user.partner_id.id})
self.assertFalse(child.sale_line_id)
self.assertNotEqual(child.partner_id, parent.partner_id)
child.write({'parent_id': parent.id})
self.assertFalse(child.sale_line_id)
def test_writing_on_parent_with_multiple_tasks(self):
parent = self.env['project.task'].create({'name': 'parent', 'user_ids': False, 'partner_id': self.partner.id})
children_values = [{'name': 'child%s' % i, 'user_ids': False, 'parent_id': parent.id} for i in range(5)]
children = self.env['project.task'].create(children_values)
children._compute_partner_id()
# test writing sale_line_id
for child in children:
self.assertFalse(child.sale_line_id)
parent.write({'sale_line_id': self.order_line.id})
for child in children:
self.assertEqual(child.sale_line_id, self.order_line)
def test_linking_on_parent_with_multiple_tasks(self):
parent = self.env['project.task'].create({'name': 'parent', 'partner_id': self.partner.id, 'sale_line_id': self.order_line.id, 'user_ids': [(4, self.user.id)]})
children_values = [{'name': 'child%s' % i, 'user_ids': False} for i in range(5)]
children = self.env['project.task'].create(children_values)
# test writing user_ids and sale_line_id
for child in children:
self.assertFalse(child.user_ids)
self.assertFalse(child.sale_line_id)
children.write({'parent_id': parent.id})
for child in children:
self.assertEqual(child.sale_line_id, self.order_line)
self.assertFalse(child.user_ids)

View file

@ -0,0 +1,404 @@
# -*- 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.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(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.'
)
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"')
sequence_per_invoice_type = self.project._get_profitability_sequence_per_invoice_type()
self.assertIn('service_revenues', sequence_per_invoice_type)
self.assertDictEqual(
self.project._get_profitability_items(False),
{
'revenues': {
'data': [
{
# 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': 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(False),
{
'revenues': {
'data': [
{
'id': invoice_type,
'sequence': sequence_per_invoice_type[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(False),
{
'revenues': {
'data': [
{
'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': 'other_revenues',
'sequence': sequence_per_invoice_type['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(False),
{
'revenues': {
'data': [
{
'id': invoice_type,
'sequence': sequence_per_invoice_type[invoice_type],
'to_invoice': sum(service_sols.mapped('untaxed_amount_to_invoice')),
'invoiced': manual_service_order_line.untaxed_amount_invoiced,
},
{
'id': 'other_revenues',
'sequence': sequence_per_invoice_type['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(False),
self.project_profitability_items_empty,
)
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
"""
self.project.allow_billable = True
# a custom analytic contribution (number between 1 -> 100 included)
analytic_distribution = 42
analytic_contribution = analytic_distribution / 100.
# create a invoice_1 with the AAL
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.analytic_account.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 bill_1 is in draft, therefor it should have the cost "to_invoice" same as the -product_price (untaxed)
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': self.product_a.standard_price * analytic_contribution,
'invoiced': 0.0,
}],
'total': {'to_invoice': self.product_a.standard_price * analytic_contribution, 'invoiced': 0.0},
},
)
# post invoice_1
invoice_1.action_post()
# we posted the invoice_1, therefore the revenue "invoiced" should be -product_price, to_invoice should be back to 0
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': 0.0,
'invoiced': self.product_a.standard_price * analytic_contribution,
}],
'total': {'to_invoice': 0.0, 'invoiced': self.product_a.standard_price * analytic_contribution},
},
)
# create another invoice, with 2 lines, 2 diff products, the second line has 2 as quantity
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.analytic_account.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.analytic_account.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,
})],
})
# invoice_2 is not posted, therefor its cost should be "to_invoice" = - sum of all product_price * qty for each line
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution,
'invoiced': self.product_a.standard_price * analytic_contribution,
}],
'total': {
'to_invoice': (self.product_a.standard_price + 2 * self.product_b.standard_price) * analytic_contribution,
'invoiced': self.product_a.standard_price * analytic_contribution,
},
},
)
# post invoice_2
invoice_2.action_post()
# invoice_2 is posted, therefor its revenue should be counting in "invoiced", with the revenues from invoice_1
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': 0.0,
'invoiced': 2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
}],
'total': {
'to_invoice': 0.0,
'invoiced': 2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
},
},
)
# invoice with negative subtotal on move line
NEG_AMOUNT = -42
invoice_3 = self.env['account.move'].create({
"name": "I am negative",
"move_type": "out_invoice",
"state": "draft",
"partner_id": self.partner.id,
"invoice_date": datetime.today(),
"invoice_line_ids": [Command.create({
"analytic_distribution": {self.analytic_account.id: analytic_distribution},
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": NEG_AMOUNT,
}), Command.create({
"product_id": self.product_a.id,
"quantity": 1,
"product_uom_id": self.product_a.uom_id.id,
"price_unit": -NEG_AMOUNT, # so the invoice is not negative and we can post it
})],
})
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': NEG_AMOUNT * analytic_contribution,
'invoiced': 2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
}],
'total': {
'to_invoice': NEG_AMOUNT * analytic_contribution,
'invoiced': 2 * (self.product_a.standard_price + self.product_b.standard_price) * analytic_contribution,
},
},
)
invoice_3.action_post()
self.assertDictEqual(
self.project._get_profitability_items(False)['revenues'],
{
'data': [{
'id': 'other_invoice_revenues',
'sequence': self.project._get_profitability_sequence_per_invoice_type()['other_invoice_revenues'],
'to_invoice': 0.0,
'invoiced': (2 * (self.product_a.standard_price + self.product_b.standard_price) + NEG_AMOUNT) * analytic_contribution,
}],
'total': {
'to_invoice': 0.0,
'invoiced': (2 * (self.product_a.standard_price + self.product_b.standard_price) + NEG_AMOUNT) * analytic_contribution,
},
},
)

View file

@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests import tagged
from .common import TestSaleProjectCommon
@tagged('post_install', '-at_install')
class TestResConfigSettings(TestSaleProjectCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.sale_order = cls.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({
'partner_id': cls.partner_b.id,
'partner_invoice_id': cls.partner_b.id,
'partner_shipping_id': cls.partner_b.id,
'order_line': [
Command.create({
'product_id': cls.product_milestone.id,
'product_uom_qty': 3,
}),
Command.create({
'product_id': cls.product_delivery_manual1.id,
'product_uom_qty': 2,
})
]
})
cls.product_milestone_sale_line = cls.sale_order.order_line.filtered(lambda sol: sol.product_id == cls.product_milestone)
cls.product_delivery_manual1_sale_line = cls.sale_order.order_line.filtered(lambda sol: sol.product_id == cls.product_delivery_manual1)
cls.sale_order.action_confirm()
cls.milestone = cls.env['project.milestone'].create({
'name': 'Test Milestone',
'sale_line_id': cls.product_milestone_sale_line.id,
'project_id': cls.project_global.id,
})
def test_disable_and_enable_project_milestone_feature(self):
self.assertTrue(self.env.user.has_group('project.group_project_milestone'), 'The Project Milestones feature should be enabled.')
self.set_project_milestone_feature(False)
self.assertFalse(self.env.user.has_group('project.group_project_milestone'), 'The Project Milestones feature should be disabled.')
product_milestones = self.product_milestone + self.product_milestone2
self.assertEqual(
product_milestones.mapped('service_policy'),
['delivered_manual'] * 2,
'Both milestone products should become a manual product when the project milestones feature is disabled')
self.assertEqual(
product_milestones.mapped('service_type'),
['manual'] * 2,
'Both milestone products should become a manual product when the project milestones feature is disabled')
self.assertEqual(
self.product_milestone_sale_line.qty_delivered_method,
'manual',
'The quantity delivered method of SOL containing milestone product should be changed to manual when the project milestones feature is disabled')
# Since the quantity delivered manual is manual then the user can manually change the quantity delivered
self.product_milestone_sale_line.qty_delivered = 2
# Enable the project milestones feature
self.set_project_milestone_feature(True)
self.assertEqual(
self.product_milestone.service_policy,
'delivered_milestones',
'The product has been updated and considered as milestones product since a SOL containing this product is linked to a milestone.')
self.assertEqual(
self.product_milestone.service_type,
'milestones',
'The product has been updated and considered as milestones product since a SOL containing this product is linked to a milestone.')
self.assertEqual(
self.product_milestone2.service_policy,
'delivered_manual',
'The product should not be updated since we cannot assume this product was a milestone when the feature'
' was enabled because no SOL with this product is linked to a milestone.')
self.assertEqual(
self.product_milestone2.service_type,
'manual',
'The product should not be updated since we cannot assume this product was a milestone when the feature'
' was enabled because no SOL with this product is linked to a milestone.')
self.assertEqual(
self.product_milestone_sale_line.qty_delivered_method,
'manual',
'The quantity delivered method of SOL containing milestone product should keep the same quantity delivered method even if the project milestones feature is renabled.')
self.assertEqual(self.product_milestone_sale_line.qty_delivered, 2, 'The quantity delivered should be the one set by the user.')

View file

@ -0,0 +1,334 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import Command
from odoo.tests.common import users
from odoo.addons.base.tests.common import TransactionCaseWithUserDemo
class TestSaleProject(TransactionCaseWithUserDemo):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.analytic_plan = cls.env['account.analytic.plan'].create({
'name': 'Plan Test',
'company_id': False,
})
cls.analytic_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'plan_id': cls.analytic_plan.id,
'code': 'AA-2030'
})
# Create projects
cls.project_global = cls.env['project.project'].create({
'name': 'Global Project',
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = cls.env['project.project'].create({
'name': 'Project TEMPLATE for services',
})
cls.project_template_state = cls.env['project.task.type'].create({
'name': 'Only stage in project template',
'sequence': 1,
'project_ids': [(4, cls.project_template.id)]
})
# Create service products
uom_hour = cls.env.ref('uom.product_uom_hour')
cls.product_order_service1 = cls.env['product.product'].create({
'name': "Service Ordered, create no task",
'standard_price': 11,
'list_price': 13,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED1',
'service_tracking': 'no',
'project_id': False,
})
cls.product_order_service2 = cls.env['product.product'].create({
'name': "Service Ordered, create task in global project",
'standard_price': 30,
'list_price': 90,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED2',
'service_tracking': 'task_global_project',
'project_id': cls.project_global.id,
})
cls.product_order_service3 = cls.env['product.product'].create({
'name': "Service Ordered, create task in new project",
'standard_price': 10,
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED3',
'service_tracking': 'task_in_project',
'project_id': False, # will create a project
})
cls.product_order_service4 = cls.env['product.product'].create({
'name': "Service Ordered, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-ORDERED4',
'service_tracking': 'project_only',
'project_id': False,
})
# Create partner
cls.partner = cls.env['res.partner'].create({'name': "Mur en béton"})
def test_sale_order_with_project_task(self):
SaleOrder = self.env['sale.order'].with_context(tracking_disable=True)
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
sale_order = SaleOrder.create({
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
so_line_order_no_task = SaleOrderLine.create({
'product_id': self.product_order_service1.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
})
so_line_order_task_in_global = SaleOrderLine.create({
'product_id': self.product_order_service2.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
})
so_line_order_new_task_new_project = SaleOrderLine.create({
'product_id': self.product_order_service3.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
})
so_line_order_only_project = SaleOrderLine.create({
'product_id': self.product_order_service4.id,
'product_uom_qty': 10,
'order_id': sale_order.id,
})
sale_order.action_confirm()
# service_tracking 'no'
self.assertFalse(so_line_order_no_task.project_id, "The project should not be linked to no task product")
self.assertFalse(so_line_order_no_task.task_id, "The task should not be linked to no task product")
# service_tracking 'task_global_project'
self.assertFalse(so_line_order_task_in_global.project_id, "Only task should be created, project should not be linked")
self.assertEqual(self.project_global.tasks.sale_line_id, so_line_order_task_in_global, "Global project's task should be linked to so line")
# service_tracking 'task_in_project'
self.assertTrue(so_line_order_new_task_new_project.project_id, "Sales order line should be linked to newly created project")
self.assertTrue(so_line_order_new_task_new_project.task_id, "Sales order line should be linked to newly created task")
# service_tracking 'project_only'
self.assertFalse(so_line_order_only_project.task_id, "Task should not be created")
self.assertTrue(so_line_order_only_project.project_id, "Sales order line should be linked to newly created project")
self.assertEqual(self.project_global._get_sale_order_items(), self.project_global.sale_line_id | self.project_global.tasks.sale_line_id, 'The _get_sale_order_items should returns all the SOLs linked to the project and its active tasks.')
sale_order_2 = SaleOrder.create({
'partner_id': self.partner.id,
'partner_invoice_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
sale_line_1_order_2 = SaleOrderLine.create({
'product_id': self.product_order_service1.id,
'product_uom_qty': 10,
'product_uom': self.product_order_service1.uom_id.id,
'price_unit': self.product_order_service1.list_price,
'order_id': sale_order_2.id,
})
section_sale_line_order_2 = SaleOrderLine.create({
'display_type': 'line_section',
'name': 'Test Section',
'order_id': sale_order_2.id,
})
note_sale_line_order_2 = SaleOrderLine.create({
'display_type': 'line_note',
'name': 'Test Note',
'order_id': sale_order_2.id,
})
sale_order_2.action_confirm()
task = self.env['project.task'].create({
'name': 'Task',
'sale_line_id': sale_line_1_order_2.id,
'project_id': self.project_global.id,
})
self.assertEqual(task.sale_line_id, sale_line_1_order_2)
self.assertIn(task.sale_line_id, self.project_global._get_sale_order_items())
self.assertEqual(self.project_global._get_sale_orders(), sale_order | sale_order_2)
sale_order_lines = sale_order.order_line + sale_line_1_order_2 # exclude the Section and Note Sales Order Items
sale_items_data = self.project_global._get_sale_items(with_action=False)
self.assertEqual(sale_items_data['total'], len(sale_order_lines - so_line_order_new_task_new_project - so_line_order_only_project),
"Should be all the sale items linked to the global project.")
expected_sale_line_dict = {
sol_read['id']: sol_read
for sol_read in sale_order_lines.read(['display_name', 'product_uom_qty', 'qty_delivered', 'qty_invoiced', 'product_uom'])
}
actual_sol_ids = []
for line in sale_items_data['data']:
sol_id = line['id']
actual_sol_ids.append(sol_id)
self.assertIn(sol_id, expected_sale_line_dict)
self.assertDictEqual(line, expected_sale_line_dict[sol_id])
self.assertNotIn(section_sale_line_order_2.id, actual_sol_ids, 'The section Sales Order Item should not be takken into account in the Sales section of project.')
self.assertNotIn(note_sale_line_order_2.id, actual_sol_ids, 'The note Sales Order Item should not be takken into account in the Sales section of project.')
def test_sol_product_type_update(self):
sale_order = 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,
})
self.product_order_service3.type = 'consu'
sale_order_line = self.env['sale.order.line'].create({
'order_id': sale_order.id,
'name': self.product_order_service3.name,
'product_id': self.product_order_service3.id,
'product_uom_qty': 5,
'product_uom': self.product_order_service3.uom_id.id,
'price_unit': self.product_order_service3.list_price
})
self.assertFalse(sale_order_line.is_service, "As the product is consumable, the SOL should not be a service")
self.product_order_service3.type = 'service'
self.assertTrue(sale_order_line.is_service, "As the product is a service, the SOL should be a service")
@users('demo')
def test_cancel_so_linked_to_project(self):
""" Test that cancelling a SO linked to a project will not raise an error """
# Ensure user don't have edit right access to the project
group_sale_manager = self.env.ref('sales_team.group_sale_manager')
group_project_user = self.env.ref('project.group_project_user')
self.env.user.write({'groups_id': [(6, 0, [group_sale_manager.id, group_project_user.id])]})
sale_order = 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,
'project_id': self.project_global.id,
})
sale_order_line = self.env['sale.order.line'].create({
'name': self.product_order_service2.name,
'product_id': self.product_order_service2.id,
'order_id': sale_order.id,
})
self.assertFalse(self.project_global.tasks.sale_line_id, "The project tasks should not be linked to the SOL")
sale_order.action_confirm()
self.assertEqual(self.project_global.tasks.sale_line_id.id, sale_order_line.id, "The project tasks should be linked to the SOL from the SO")
self.project_global.sale_line_id = sale_order_line
sale_order.with_context({'disable_cancel_warning': True}).action_cancel()
self.assertFalse(self.project_global.sale_line_id, "The project should not be linked to the SOL anymore")
def test_create_task_from_template_line(self):
"""
When we add an SOL from a template that is a service that has a service_policy that will generate a task,
even if default_task_id is present in the context, a new task should be created when confirming the SO.
"""
default_task = self.env['project.task'].with_context(tracking_disable=True).create({
'name': 'Task',
'project_id': self.project_global.id
})
sale_order = self.env['sale.order'].with_context(tracking_disable=True, default_task_id=default_task.id).create({
'partner_id': self.partner.id,
})
quotation_template = self.env['sale.order.template'].create({
'name': 'Test quotation',
})
quotation_template.write({
'sale_order_template_line_ids': [
Command.set(
self.env['sale.order.template.line'].create([{
'name': self.product_order_service2.display_name,
'sale_order_template_id': quotation_template.id,
'product_id': self.product_order_service2.id,
'product_uom_id': self.product_order_service2.uom_id.id,
}, {
'name': self.product_order_service3.display_name,
'sale_order_template_id': quotation_template.id,
'product_id': self.product_order_service3.id,
'product_uom_id': self.product_order_service3.uom_id.id,
}]).ids
)
]
})
sale_order.with_context(default_task_id=default_task.id).write({
'sale_order_template_id': quotation_template.id,
})
sale_order.with_context(default_task_id=default_task.id)._onchange_sale_order_template_id()
self.assertFalse(sale_order.order_line.mapped('task_id'),
"SOL should have no related tasks, because they are from services that generates a task")
sale_order.action_confirm()
self.assertEqual(sale_order.tasks_count, 2, "SO should have 2 related tasks")
self.assertNotIn(default_task, sale_order.tasks_ids, "SO should link to the default task from the context")
def test_include_archived_projects_in_stat_btn_related_view(self):
"""Checks if the project stat-button action includes both archived and active projects."""
# Setup
project_A = self.env['project.project'].create({'name': 'Project_A'})
project_B = self.env['project.project'].create({'name': 'Project_B'})
product_A = self.env['product.product'].create({
'name': 'product A',
'list_price': 1.0,
'type': 'service',
'service_tracking': 'task_global_project',
'project_id':project_A.id,
})
product_B = self.env['product.product'].create({
'name': 'product B',
'list_price': 2.0,
'type': 'service',
'service_tracking': 'task_global_project',
'project_id':project_B.id,
})
sale_order = 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,
})
SaleOrderLine = self.env['sale.order.line'].with_context(tracking_disable=True)
SaleOrderLine.create({
'name': product_A.name,
'product_id': product_A.id,
'product_uom_qty': 10,
'price_unit': product_A.list_price,
'order_id': sale_order.id,
})
SaleOrderLine.create({
'name': product_B.name,
'product_id': product_B.id,
'product_uom_qty': 10,
'price_unit': product_B.list_price,
'order_id': sale_order.id,
})
# Check if button action includes both projects BEFORE archivization
action = sale_order.action_view_project_ids()
self.assertEqual(len(action['domain'][0][2]), 2, "Domain should contain 2 projects.")
# Check if button action includes both projects AFTER archivization
project_B.write({'active': False})
action = sale_order.action_view_project_ids()
self.assertEqual(len(action['domain'][0][2]), 2, "Domain should contain 2 projects. (one archived, one not)")

View file

@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.exceptions import ValidationError
from odoo.tests.common import tagged
from psycopg2 import Error as Psycopg2Error
@tagged('post_install', '-at_install')
class TestSoLineMilestones(TestSaleCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
cls.env['res.config.settings'].create({'group_project_milestone': True}).execute()
uom_hour = cls.env.ref('uom.product_uom_hour')
cls.product_delivery_milestones1 = cls.env['product.product'].create({
'name': "Milestones 1, create project only",
'standard_price': 15,
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'project_only',
})
cls.product_delivery_milestones2 = cls.env['product.product'].create({
'name': "Milestones 2, create project only",
'standard_price':20,
'list_price': 35,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'project_only',
})
cls.product_delivery_milestones3 = cls.env['product.product'].create({
'name': "Milestones 3, create project & task",
'standard_price': 20,
'list_price': 35,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'MILE-DELI4',
'service_type': 'milestones',
'service_tracking': 'task_in_project',
})
cls.sale_order = cls.env['sale.order'].create({
'partner_id': cls.partner_a.id,
'partner_invoice_id': cls.partner_a.id,
'partner_shipping_id': cls.partner_a.id,
})
cls.sol1 = cls.env['sale.order.line'].create({
'product_id': cls.product_delivery_milestones1.id,
'product_uom_qty': 20,
'order_id': cls.sale_order.id,
})
cls.sol2 = cls.env['sale.order.line'].create({
'product_id': cls.product_delivery_milestones2.id,
'product_uom_qty': 30,
'order_id': cls.sale_order.id,
})
cls.sale_order.action_confirm()
cls.project = cls.sol1.project_id
cls.milestone1 = cls.env['project.milestone'].create({
'name': 'Milestone 1',
'project_id': cls.project.id,
'is_reached': False,
'sale_line_id': cls.sol1.id,
'quantity_percentage': 0.5,
})
def test_reached_milestones_delivered_quantity(self):
self.milestone2 = self.env['project.milestone'].create({
'name': 'Milestone 2',
'project_id': self.project.id,
'is_reached': False,
'sale_line_id': self.sol2.id,
'quantity_percentage': 0.2,
})
self.milestone3 = self.env['project.milestone'].create({
'name': 'Milestone 3',
'project_id': self.project.id,
'is_reached': False,
'sale_line_id': self.sol2.id,
'quantity_percentage': 0.4,
})
self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should start at 0")
self.assertEqual(self.sol2.qty_delivered, 0.0, "Delivered quantity should start at 0")
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should update after a milestone is reached")
self.milestone2.is_reached = True
self.assertEqual(self.sol2.qty_delivered, 6.0, "Delivered quantity should update after a milestone is reached")
self.milestone3.is_reached = True
self.assertEqual(self.sol2.qty_delivered, 18.0, "Delivered quantity should update after a milestone is reached")
def test_update_reached_milestone_quantity(self):
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should start at 10")
self.milestone1.quantity_percentage = 0.75
self.assertEqual(self.sol1.qty_delivered, 15.0, "Delivered quantity should update after a milestone's quantity is updated")
def test_remove_reached_milestone(self):
self.milestone1.is_reached = True
self.assertEqual(self.sol1.qty_delivered, 10.0, "Delivered quantity should start at 10")
self.milestone1.unlink()
self.assertEqual(self.sol1.qty_delivered, 0.0, "Delivered quantity should update when a milestone is removed")
def test_compute_sale_line_in_task(self):
task = self.env['project.task'].create({
'name': 'Test Task',
'project_id': self.project.id,
})
self.assertEqual(task.sale_line_id, self.sol1, 'The task should have the one of the project linked')
self.project.sale_line_id = False
task.sale_line_id = False
self.assertFalse(task.sale_line_id)
task.write({'milestone_id': self.milestone1.id})
self.assertEqual(task.sale_line_id, self.milestone1.sale_line_id, 'The task should have the SOL from the milestone.')
self.project.sale_line_id = self.sol2
self.assertEqual(task.sale_line_id, self.sol1, 'The task should keep the SOL linked to the milestone.')
def test_create_milestone_on_project_set_on_sales_order(self):
"""
Regression Test:
If we confirm an SO with a service with a delivery based on milestones,
that creates both a project & task, and we set a project on the SO,
the project for the milestone should be the one set on the SO,
and no ValidationError or NotNullViolation should be raised.
"""
project = self.env['project.project'].create({
'name': 'Test Project For Milestones',
'partner_id': self.partner_a.id
})
sale_order = self.env['sale.order'].create({
'partner_id': self.partner_a.id,
'partner_invoice_id': self.partner_a.id,
'partner_shipping_id': self.partner_a.id,
'project_id': project.id, # the user set a project on the SO
})
self.env['sale.order.line'].create({
'product_id': self.product_delivery_milestones3.id,
'product_uom_qty': 20,
'order_id': sale_order.id,
})
try:
sale_order.action_confirm()
except ValidationError:
self.fail("The sale order should be confirmed, "
"and no ValidationError should be raised, "
"for a missing project on the milestone.")
except Psycopg2Error as e:
# Check if the error is a NOT NULL violation
NotNullViolationPgCode = '23502'
if e.pgcode == NotNullViolationPgCode:
self.fail("The sale order should be confirmed, "
"and no NotNullViolation should be raised, "
"for a missing project on the milestone.")
else:
# Re-raise any other unexpected database error
raise