[IMP] sale_{project,timesheet}: remove 'delivered_milestone' in invoice_policy

Before this commit, when the `Milestones` feature is disable, the user
can always defined a service product with invoice policy equals to
'Based on Milestones' but since the feature is disabled he cannot add
this product on a milestone and so this configuratiom is useless without
the feature.

This commit removes the item into the invoice_policy field Selection
when the feature is disabled. Moreover, when the feature is disabled,
the existing products with this setting should be updated to be a
service product with `invoice_policy='delivered_manual'`. The quantity
delivered method of the existing SOLs containing those products should
also be updated to allow the user to manually edit the quantity
delivered of those SOLs when the `Milestones` feature is disabled.
However, when the `Milestones` feature is enabled again, only the
products contained in SOL linked to a milestone will be updated because
we cannot assume all service products with
`invoice_policy='delivered_manual'` were a service products with invoice
policy based on Milestones. Also the existing SOLs containing the
products updated will not be updated too to not impact the quantity
delivered.

task-2829542
This commit is contained in:
Xavier BOL (xbo)
2022-05-31 10:29:09 +02:00
parent d923f8ae4a
commit 50ffc33999
10 changed files with 302 additions and 145 deletions
+5
View File
@@ -7,6 +7,11 @@
<field name="groups_id" eval="[Command.link(ref('group_project_user'))]"/>
</record>
<!-- Groups : Add milestones feature by default -->
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[Command.link(ref('group_project_milestone'))]"/>
</record>
<!-- Categories -->
<record id="project_tags_00" model="project.tags">
<field name="name">Bug</field>
+1
View File
@@ -5,3 +5,4 @@ from . import project
from . import project_milestone
from . import sale_order
from . import sale_order_line
from . import res_config_settings
+12 -9
View File
@@ -5,17 +5,20 @@ from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
SERVICE_POLICY = [
# (service_policy, string)
('ordered_prepaid', 'Prepaid/Fixed Price'),
('delivered_milestones', 'Based on Milestones'),
('delivered_manual', 'Based on Delivered Quantity (Manual)'),
]
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model
def _selection_service_policy(self):
service_policies = [
# (service_policy, string)
('ordered_prepaid', 'Prepaid/Fixed Price'),
('delivered_manual', 'Based on Delivered Quantity (Manual)'),
]
if self.user_has_groups('project.group_project_milestone'):
service_policies.insert(1, ('delivered_milestones', 'Based on Milestones'))
return service_policies
service_tracking = fields.Selection(
selection=[
('no', 'Nothing'),
@@ -36,7 +39,7 @@ class ProductTemplate(models.Model):
'project.project', 'Project Template', company_dependent=True, copy=True,
domain="[('company_id', '=', current_company_id)]",
help='Select a billable project to be the skeleton of the new created project when selling the current product. Its stages and tasks will be duplicated.')
service_policy = fields.Selection(SERVICE_POLICY, string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_policy = fields.Selection('_selection_service_policy', string="Service Invoicing Policy", compute='_compute_service_policy', inverse='_inverse_service_policy')
service_type = fields.Selection(selection_add=[
('milestones', 'Project Milestones'),
])
@@ -0,0 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
def set_values(self):
super().set_values()
if self.group_project_milestone:
# Search the milestones containing a SOL and change the qty_delivered_method field of the SOL and the
# service_policy field set on the product to convert from manual to milestones.
milestone_read_group = self.env['project.milestone'].read_group(
[('sale_line_id', '!=', False)],
['sale_line_ids:array_agg(sale_line_id)'],
[],
)
sale_line_ids = milestone_read_group[0]['sale_line_ids'] if milestone_read_group else []
sale_lines = self.env['sale.order.line'].browse(sale_line_ids)
sale_lines.product_id.service_policy = 'delivered_milestones'
else:
product_domain = [('type', '=', 'service'), ('service_type', '=', 'milestones')]
products = self.env['product.product'].search(product_domain)
products.service_policy = 'delivered_manual'
self.env['sale.order.line'].search([('product_id', 'in', products.ids)]).qty_delivered_method = 'manual'
+2
View File
@@ -1,7 +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
+136
View File
@@ -0,0 +1,136 @@
# -*- 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_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'code': 'AA-2030',
'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()
@@ -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.')
@@ -111,7 +111,7 @@
<field name="type">service</field>
<field name="uom_id" ref="uom.product_uom_unit"/>
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="service_policy">delivered_milestones</field>
<field name="service_type">milestones</field>
<field name="service_tracking">no</field>
</record>
+5 -6
View File
@@ -6,16 +6,15 @@ import threading
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.addons.sale_project.models.product import SERVICE_POLICY
SERVICE_POLICY = [policy[:1] for policy in SERVICE_POLICY]
SERVICE_POLICY.insert(1, ('delivered_timesheet', 'Based on Timesheets'))
class ProductTemplate(models.Model):
_inherit = 'product.template'
service_policy = fields.Selection(selection_add=SERVICE_POLICY)
def _selection_service_policy(self):
service_policies = super()._selection_service_policy()
service_policies.insert(1, ('delivered_timesheet', 'Based on Timesheets'))
return service_policies
service_type = fields.Selection(selection_add=[
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set manual'})
+25 -129
View File
@@ -2,10 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.mail.tests.common import mail_new_test_user
from odoo.addons.sale.tests.common import TestSaleCommon
from odoo.addons.sale_project.tests.common import TestSaleProjectCommon
class TestCommonSaleTimesheet(TestSaleCommon):
class TestCommonSaleTimesheet(TestSaleProjectCommon):
@classmethod
def setUpClass(cls, chart_template_ref=None):
@@ -54,12 +54,7 @@ class TestCommonSaleTimesheet(TestSaleCommon):
})
# Account and project
cls.account_sale = cls.company_data['default_account_revenue']
cls.analytic_account_sale = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet - AA',
'code': 'AA-2030',
'company_id': cls.company_data['company'].id,
})
cls.analytic_account_sale.name = 'Project for selling timesheet - AA'
cls.analytic_account_sale_company_B = cls.env['account.analytic.account'].create({
'name': 'Project for selling timesheet Company B - AA',
'code': 'AA-2030',
@@ -68,20 +63,12 @@ class TestCommonSaleTimesheet(TestSaleCommon):
# Create projects
Project = cls.env['project.project'].with_context(tracking_disable=True)
cls.project_global = Project.create({
cls.project_global.write({
'name': 'Project for selling timesheets',
'allow_timesheets': True,
'analytic_account_id': cls.analytic_account_sale.id,
'allow_billable': True,
})
cls.project_template = Project.create({
cls.project_template.write({
'name': 'Project TEMPLATE for services',
'allow_timesheets': True,
})
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)]
})
# Projects: at least one per billable type
cls.project_task_rate = Project.create({
@@ -106,7 +93,6 @@ class TestCommonSaleTimesheet(TestSaleCommon):
})
# Create service products
uom_hour = cls.env.ref('uom.product_uom_hour')
# -- ordered quantities (ordered, timesheet)
cls.product_order_timesheet1 = cls.env['product.product'].create({
@@ -115,8 +101,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 13,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED1',
'service_type': 'timesheet',
'service_tracking': 'no',
@@ -130,8 +116,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 90,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
@@ -145,8 +131,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 20,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED3',
'service_type': 'timesheet',
'service_tracking': 'task_in_project',
@@ -160,8 +146,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 30,
'type': 'service',
'invoice_policy': 'order',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -175,8 +161,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 34,
'type': 'service',
'invoice_policy': 'order',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-ORDERED4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -193,8 +179,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 13,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI1',
'service_type': 'timesheet',
'service_tracking': 'no',
@@ -208,8 +194,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 90,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI2',
'service_type': 'timesheet',
'service_tracking': 'task_global_project',
@@ -223,8 +209,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI3',
'service_type': 'timesheet',
'service_tracking': 'task_in_project',
@@ -238,8 +224,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 30,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI4',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -253,8 +239,8 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'list_price': 34,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': cls.env.ref('uom.product_uom_hour').id,
'uom_po_id': cls.env.ref('uom.product_uom_hour').id,
'uom_id': cls.uom_hour.id,
'uom_po_id': cls.uom_hour.id,
'default_code': 'SERV-DELI5',
'service_type': 'timesheet',
'service_tracking': 'project_only',
@@ -264,96 +250,6 @@ class TestCommonSaleTimesheet(TestSaleCommon):
'property_account_income_id': cls.account_sale.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': uom_hour.id,
'uom_po_id': 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': uom_hour.id,
'uom_po_id': 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': uom_hour.id,
'uom_po_id': 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': uom_hour.id,
'uom_po_id': 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': uom_hour.id,
'uom_po_id': 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,
})
cls.product_milestone = cls.env['product.product'].create({
'name': 'Milestone Product',
'list_price': 20,
'type': 'service',
'invoice_policy': 'delivery',
'uom_id': uom_hour.id,
'uom_po_id': uom_hour.id,
'default_code': 'SERV-MILES',
'service_type': 'milestones',
'service_tracking': 'no',
'property_account_income_id': cls.account_sale.id,
})
def setUp(self):
super().setUp()
self.so = self.env['sale.order'].with_context(mail_notrack=True, mail_create_nolog=True).create({