diff --git a/addons/sale_margin/__manifest__.py b/addons/sale_margin/__manifest__.py
index 294e6d6d95b..98dcbe943bd 100644
--- a/addons/sale_margin/__manifest__.py
+++ b/addons/sale_margin/__manifest__.py
@@ -13,7 +13,11 @@ This gives the profitability by calculating the difference between the Unit
Price and Cost Price.
""",
'depends':['sale_management'],
- 'demo':['data/sale_margin_demo.xml'],
- 'data':['security/ir.model.access.csv','views/sale_margin_view.xml'],
+ 'demo':[
+ 'data/sale_margin_demo.xml',
+ ],
+ 'data':[
+ 'views/sale_order_views.xml',
+ ],
'license': 'LGPL-3',
}
diff --git a/addons/sale_margin/data/sale_margin_demo.xml b/addons/sale_margin/data/sale_margin_demo.xml
index 1a4e6d698e1..c68aa030d69 100644
--- a/addons/sale_margin/data/sale_margin_demo.xml
+++ b/addons/sale_margin/data/sale_margin_demo.xml
@@ -1,6 +1,5 @@
-
-
+
2870.00
@@ -34,5 +33,4 @@
13.00
-
diff --git a/addons/sale_margin/models/__init__.py b/addons/sale_margin/models/__init__.py
index 00b62d54b4a..9b5fa8c35d8 100644
--- a/addons/sale_margin/models/__init__.py
+++ b/addons/sale_margin/models/__init__.py
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import sale_order
+from . import sale_order_line
diff --git a/addons/sale_margin/models/sale_order.py b/addons/sale_margin/models/sale_order.py
index e1d0696862a..e5dfa129896 100644
--- a/addons/sale_margin/models/sale_order.py
+++ b/addons/sale_margin/models/sale_order.py
@@ -1,63 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
from odoo import api, fields, models
-class SaleOrderLine(models.Model):
- _inherit = "sale.order.line"
-
- margin = fields.Float(
- "Margin", compute='_compute_margin',
- digits='Product Price', store=True, groups="base.group_user", precompute=True)
- margin_percent = fields.Float(
- "Margin (%)", compute='_compute_margin', store=True, groups="base.group_user", precompute=True)
- purchase_price = fields.Float(
- string='Cost', compute="_compute_purchase_price",
- digits='Product Price', store=True, readonly=False, precompute=True,
- groups="base.group_user")
-
- @api.depends('product_id', 'company_id', 'currency_id', 'product_uom')
- def _compute_purchase_price(self):
- for line in self:
- if not line.product_id:
- line.purchase_price = 0.0
- continue
- line = line.with_company(line.company_id)
- product_cost = line.product_id.standard_price
- line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)
-
- @api.depends('price_subtotal', 'product_uom_qty', 'purchase_price')
- def _compute_margin(self):
- for line in self:
- line.margin = line.price_subtotal - (line.purchase_price * line.product_uom_qty)
- line.margin_percent = line.price_subtotal and line.margin/line.price_subtotal
-
- def _convert_price(self, product_cost, from_uom):
- self.ensure_one()
- if not product_cost:
- # If the standard_price is 0
- # Avoid unnecessary computations
- # and currency conversions
- if not self.purchase_price:
- return product_cost
- from_currency = self.product_id.cost_currency_id
- to_cur = self.currency_id or self.order_id.currency_id
- to_uom = self.product_uom
- if to_uom and to_uom != from_uom:
- product_cost = from_uom._compute_price(
- product_cost,
- to_uom,
- )
- return from_currency._convert(
- from_amount=product_cost,
- to_currency=to_cur,
- company=self.company_id or self.env.company,
- date=self.order_id.date_order or fields.Date.today(),
- round=False,
- ) if to_cur and product_cost else product_cost
- # The pricelist may not have been set, therefore no conversion
- # is needed because we don't know the target currency..
-
class SaleOrder(models.Model):
_inherit = "sale.order"
diff --git a/addons/sale_margin/models/sale_order_line.py b/addons/sale_margin/models/sale_order_line.py
new file mode 100644
index 00000000000..8e03988ceac
--- /dev/null
+++ b/addons/sale_margin/models/sale_order_line.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ margin = fields.Float(
+ "Margin", compute='_compute_margin',
+ digits='Product Price', store=True, groups="base.group_user", precompute=True)
+ margin_percent = fields.Float(
+ "Margin (%)", compute='_compute_margin', store=True, groups="base.group_user", precompute=True)
+ purchase_price = fields.Float(
+ string='Cost', compute="_compute_purchase_price",
+ digits='Product Price', store=True, readonly=False, precompute=True,
+ groups="base.group_user")
+
+ @api.depends('product_id', 'company_id', 'currency_id', 'product_uom')
+ def _compute_purchase_price(self):
+ for line in self:
+ if not line.product_id:
+ line.purchase_price = 0.0
+ continue
+ line = line.with_company(line.company_id)
+ product_cost = line.product_id.standard_price
+ line.purchase_price = line._convert_price(product_cost, line.product_id.uom_id)
+
+ @api.depends('price_subtotal', 'product_uom_qty', 'purchase_price')
+ def _compute_margin(self):
+ for line in self:
+ line.margin = line.price_subtotal - (line.purchase_price * line.product_uom_qty)
+ line.margin_percent = line.price_subtotal and line.margin/line.price_subtotal
+
+ def _convert_price(self, product_cost, from_uom):
+ self.ensure_one()
+ if not product_cost:
+ # If the standard_price is 0
+ # Avoid unnecessary computations
+ # and currency conversions
+ if not self.purchase_price:
+ return product_cost
+ from_currency = self.product_id.cost_currency_id
+ to_cur = self.currency_id or self.order_id.currency_id
+ to_uom = self.product_uom
+ if to_uom and to_uom != from_uom:
+ product_cost = from_uom._compute_price(
+ product_cost,
+ to_uom,
+ )
+ return from_currency._convert(
+ from_amount=product_cost,
+ to_currency=to_cur,
+ company=self.company_id or self.env.company,
+ date=self.order_id.date_order or fields.Date.today(),
+ round=False,
+ ) if to_cur and product_cost else product_cost
+ # The pricelist may not have been set, therefore no conversion
+ # is needed because we don't know the target currency..
diff --git a/addons/sale_margin/security/ir.model.access.csv b/addons/sale_margin/security/ir.model.access.csv
deleted file mode 100644
index 4a08a4aa60d..00000000000
--- a/addons/sale_margin/security/ir.model.access.csv
+++ /dev/null
@@ -1 +0,0 @@
-id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
diff --git a/addons/sale_margin/tests/test_sale_margin.py b/addons/sale_margin/tests/test_sale_margin.py
index 01bb16380ed..69047e7580c 100644
--- a/addons/sale_margin/tests/test_sale_margin.py
+++ b/addons/sale_margin/tests/test_sale_margin.py
@@ -7,20 +7,21 @@ from datetime import datetime
class TestSaleMargin(common.TransactionCase):
- def setUp(self):
- super(TestSaleMargin, self).setUp()
- self.SaleOrder = self.env['sale.order']
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.SaleOrder = cls.env['sale.order']
- self.product_uom_id = self.ref('uom.product_uom_unit')
- self.product = self.env['product.product'].create({'name': 'Individual Workplace'})
- self.product_id = self.product.id
- self.partner_id = self.env['res.partner'].create({'name': 'A test partner'}).id
- self.partner_invoice_address_id = self.env['res.partner'].create({
+ cls.product_uom_id = cls.env.ref('uom.product_uom_unit').id
+ cls.product = cls.env['product.product'].create({'name': 'Individual Workplace'})
+ cls.product_id = cls.product.id
+ cls.partner_id = cls.env['res.partner'].create({'name': 'A test partner'}).id
+ cls.partner_invoice_address_id = cls.env['res.partner'].create({
'name': 'A test partner address',
- 'parent_id': self.partner_id,
+ 'parent_id': cls.partner_id,
}).id
- self.pricelist_id = self.ref('product.list0')
- self.pricelist = self.env.ref('product.list0')
+ cls.pricelist = cls.env.ref('product.list0')
+ cls.pricelist_id = cls.pricelist.id
def test_sale_margin(self):
""" Test the sale_margin module in Odoo. """
diff --git a/addons/sale_margin/views/sale_margin_view.xml b/addons/sale_margin/views/sale_order_views.xml
similarity index 100%
rename from addons/sale_margin/views/sale_margin_view.xml
rename to addons/sale_margin/views/sale_order_views.xml
diff --git a/addons/sale_mrp/models/__init__.py b/addons/sale_mrp/models/__init__.py
index db9a5396202..27bbff39492 100644
--- a/addons/sale_mrp/models/__init__.py
+++ b/addons/sale_mrp/models/__init__.py
@@ -1,7 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from . import sale
from . import account_move
from . import mrp_production
+from . import sale_order
+from . import sale_order_line
from . import stock_move
diff --git a/addons/sale_mrp/models/sale_order.py b/addons/sale_mrp/models/sale_order.py
new file mode 100644
index 00000000000..c74e7237aaf
--- /dev/null
+++ b/addons/sale_mrp/models/sale_order.py
@@ -0,0 +1,54 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ mrp_production_count = fields.Integer(
+ "Count of MO generated",
+ compute='_compute_mrp_production_ids',
+ groups='mrp.group_mrp_user')
+ mrp_production_ids = fields.Many2many(
+ 'mrp.production',
+ compute='_compute_mrp_production_ids',
+ string='Manufacturing orders associated with this sales order.',
+ groups='mrp.group_mrp_user')
+
+ @api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
+ def _compute_mrp_production_ids(self):
+ data = self.env['procurement.group'].read_group([('sale_id', 'in', self.ids)], ['ids:array_agg(id)'], ['sale_id'])
+ mrp_productions = dict()
+ for item in data:
+ procurement_groups = self.env['procurement.group'].browse(item['ids'])
+ mrp_productions[item['sale_id'][0]] = procurement_groups.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids | procurement_groups.mrp_production_ids
+ for sale in self:
+ mrp_production_ids = mrp_productions.get(sale.id, self.env['mrp.production'])
+ sale.mrp_production_count = len(mrp_production_ids)
+ sale.mrp_production_ids = mrp_production_ids
+
+ def action_view_mrp_production(self):
+ self.ensure_one()
+ action = {
+ 'res_model': 'mrp.production',
+ 'type': 'ir.actions.act_window',
+ }
+ if len(self.mrp_production_ids) == 1:
+ action.update({
+ 'view_mode': 'form',
+ 'res_id': self.mrp_production_ids.id,
+ })
+ else:
+ action.update({
+ 'name': _("Manufacturing Orders Generated by %s", self.name),
+ 'domain': [('id', 'in', self.mrp_production_ids.ids)],
+ 'view_mode': 'tree,form',
+ })
+ return action
+
+ def _get_additional_order_page_values(self):
+ rendering_context_values = super()._get_additional_order_page_values()
+ rendering_context_values['mrp_production_ids'] = self.mrp_production_ids
+ return rendering_context_values
diff --git a/addons/sale_mrp/models/sale.py b/addons/sale_mrp/models/sale_order_line.py
similarity index 75%
rename from addons/sale_mrp/models/sale.py
rename to addons/sale_mrp/models/sale_order_line.py
index 45146b92046..af82273ea73 100644
--- a/addons/sale_mrp/models/sale.py
+++ b/addons/sale_mrp/models/sale_order_line.py
@@ -1,58 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, fields, models, _
+from odoo import api, models
-class SaleOrder(models.Model):
- _inherit = 'sale.order'
-
- mrp_production_count = fields.Integer(
- "Count of MO generated",
- compute='_compute_mrp_production_ids',
- groups='mrp.group_mrp_user')
- mrp_production_ids = fields.Many2many(
- 'mrp.production',
- compute='_compute_mrp_production_ids',
- string='Manufacturing orders associated with this sales order.',
- groups='mrp.group_mrp_user')
-
- @api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids')
- def _compute_mrp_production_ids(self):
- data = self.env['procurement.group'].read_group([('sale_id', 'in', self.ids)], ['ids:array_agg(id)'], ['sale_id'])
- mrp_productions = dict()
- for item in data:
- procurement_groups = self.env['procurement.group'].browse(item['ids'])
- mrp_productions[item['sale_id'][0]] = procurement_groups.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids | procurement_groups.mrp_production_ids
- for sale in self:
- mrp_production_ids = mrp_productions.get(sale.id, self.env['mrp.production'])
- sale.mrp_production_count = len(mrp_production_ids)
- sale.mrp_production_ids = mrp_production_ids
-
- def action_view_mrp_production(self):
- self.ensure_one()
- action = {
- 'res_model': 'mrp.production',
- 'type': 'ir.actions.act_window',
- }
- if len(self.mrp_production_ids) == 1:
- action.update({
- 'view_mode': 'form',
- 'res_id': self.mrp_production_ids.id,
- })
- else:
- action.update({
- 'name': _("Manufacturing Orders Generated by %s", self.name),
- 'domain': [('id', 'in', self.mrp_production_ids.ids)],
- 'view_mode': 'tree,form',
- })
- return action
-
- def _get_additional_order_page_values(self):
- rendering_context_values = super()._get_additional_order_page_values()
- rendering_context_values['mrp_production_ids'] = self.mrp_production_ids
- return rendering_context_values
-
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
diff --git a/addons/sale_product_configurator/__init__.py b/addons/sale_product_configurator/__init__.py
index 1f4530082b3..bbc55806ace 100644
--- a/addons/sale_product_configurator/__init__.py
+++ b/addons/sale_product_configurator/__init__.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-from . import models
from . import controllers
+from . import models
from . import wizard
diff --git a/addons/sale_product_configurator/__manifest__.py b/addons/sale_product_configurator/__manifest__.py
index 978f063e888..95cb28d45f3 100644
--- a/addons/sale_product_configurator/__manifest__.py
+++ b/addons/sale_product_configurator/__manifest__.py
@@ -16,8 +16,11 @@ It also enables the "optional products" feature.
'depends': ['sale'],
'data': [
'security/ir.model.access.csv',
+
+ 'views/product_template_views.xml',
+ 'views/sale_order_views.xml',
'views/templates.xml',
- 'views/sale_views.xml',
+
'wizard/sale_product_configurator_views.xml',
],
'demo': [
diff --git a/addons/sale_product_configurator/data/sale_demo.xml b/addons/sale_product_configurator/data/sale_demo.xml
index 589f8ef8497..41247ef7294 100644
--- a/addons/sale_product_configurator/data/sale_demo.xml
+++ b/addons/sale_product_configurator/data/sale_demo.xml
@@ -1,25 +1,25 @@
-
-
-
- Chair floor protection
-
- 12.0
- 0.01
-
-
- Office chairs can harm your floor: protect it.
-
-
+
+
+
+ Chair floor protection
+
+ 12.0
+ 0.01
+
+
+ Office chairs can harm your floor: protect it.
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
diff --git a/addons/sale_product_configurator/models/__init__.py b/addons/sale_product_configurator/models/__init__.py
index 3873985e687..519b41d0ea2 100644
--- a/addons/sale_product_configurator/models/__init__.py
+++ b/addons/sale_product_configurator/models/__init__.py
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
+from . import product_template
from . import sale_order_line
-from . import product
diff --git a/addons/sale_product_configurator/models/product.py b/addons/sale_product_configurator/models/product_template.py
similarity index 77%
rename from addons/sale_product_configurator/models/product.py
rename to addons/sale_product_configurator/models/product_template.py
index 9fea6201d68..51b52c6bc0c 100644
--- a/addons/sale_product_configurator/models/product.py
+++ b/addons/sale_product_configurator/models/product_template.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import fields, models, api
+from odoo import api, fields, models
class ProductTemplate(models.Model):
@@ -21,9 +21,11 @@ class ProductTemplate(models.Model):
- It has any attribute line with at least 2 attribute values configured
- It has at least one custom attribute value """
for product in self:
- product.has_configurable_attributes = any(attribute.create_variant == 'dynamic' for attribute in product.mapped('attribute_line_ids.attribute_id')) \
- or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids) \
- or any(attribute_value.is_custom for attribute_value in product.mapped('attribute_line_ids.value_ids'))
+ product.has_configurable_attributes = (
+ any(attribute.create_variant == 'dynamic' for attribute in product.attribute_line_ids.attribute_id)
+ or any(len(attribute_line_id.value_ids) >= 2 for attribute_line_id in product.attribute_line_ids)
+ or any(attribute_value.is_custom for attribute_value in product.attribute_line_ids.value_ids)
+ )
def get_single_product_variant(self):
""" Method used by the product configurator to check if the product is configurable or not.
@@ -31,12 +33,13 @@ class ProductTemplate(models.Model):
We need to open the product configurator if the product:
- is configurable (see has_configurable_attributes)
- has optional products """
- self.ensure_one()
- res = super(ProductTemplate, self).get_single_product_variant()
+ res = super().get_single_product_variant()
if res.get('product_id', False):
has_optional_products = False
for optional_product in self.product_variant_id.optional_product_ids:
- if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(self.product_variant_id.product_template_attribute_value_ids):
+ if optional_product.has_dynamic_attributes() or optional_product._get_possible_variants(
+ self.product_variant_id.product_template_attribute_value_ids
+ ):
has_optional_products = True
break
res.update({'has_optional_products': has_optional_products})
diff --git a/addons/sale_product_configurator/views/product_template_views.xml b/addons/sale_product_configurator/views/product_template_views.xml
new file mode 100644
index 00000000000..e3cdbfc7583
--- /dev/null
+++ b/addons/sale_product_configurator/views/product_template_views.xml
@@ -0,0 +1,20 @@
+
+
+
+
+ product.template.form.inherit.sale.product.configurator
+ product.template
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale_product_configurator/views/sale_views.xml b/addons/sale_product_configurator/views/sale_order_views.xml
similarity index 62%
rename from addons/sale_product_configurator/views/sale_views.xml
rename to addons/sale_product_configurator/views/sale_order_views.xml
index a9aa988e9de..81d4c46d5ac 100644
--- a/addons/sale_product_configurator/views/sale_views.xml
+++ b/addons/sale_product_configurator/views/sale_order_views.xml
@@ -1,20 +1,5 @@
-
- product.template.form.inherit.sale.product.configurator
- product.template
-
-
-
-
-
-
-
sale.order.form.inherit.sale.product.configurator
@@ -41,4 +26,5 @@
+
diff --git a/addons/sale_product_matrix/__manifest__.py b/addons/sale_product_matrix/__manifest__.py
index bf7951da35a..d83fcbff18a 100644
--- a/addons/sale_product_matrix/__manifest__.py
+++ b/addons/sale_product_matrix/__manifest__.py
@@ -12,7 +12,7 @@
'depends': ['sale', 'product_matrix', 'sale_product_configurator'],
'data': [
'views/product_template_views.xml',
- 'views/sale_views.xml',
+ 'views/sale_order_views.xml',
'report/sale_report_templates.xml',
],
'demo': [
diff --git a/addons/sale_product_matrix/views/sale_order_views.xml b/addons/sale_product_matrix/views/sale_order_views.xml
new file mode 100644
index 00000000000..71261563ef6
--- /dev/null
+++ b/addons/sale_product_matrix/views/sale_order_views.xml
@@ -0,0 +1,19 @@
+
+
+
+ sale.order.line.variant.grid
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale_product_matrix/views/sale_views.xml b/addons/sale_product_matrix/views/sale_views.xml
deleted file mode 100644
index 1dc4f422d0c..00000000000
--- a/addons/sale_product_matrix/views/sale_views.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
- sale.order.line.variant.grid
- sale.order
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/addons/sale_project/controllers/portal.py b/addons/sale_project/controllers/portal.py
index e6cf35c8a63..2a6029e40bf 100644
--- a/addons/sale_project/controllers/portal.py
+++ b/addons/sale_project/controllers/portal.py
@@ -1,12 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from operator import itemgetter
-
from odoo import _
-from odoo.http import request
-from odoo.tools import groupby as groupbyelem
-
from odoo.osv.expression import OR
from odoo.addons.project.controllers.portal import ProjectCustomerPortal
diff --git a/addons/sale_project/models/__init__.py b/addons/sale_project/models/__init__.py
index 27c511ccd68..4c1f1f6332d 100644
--- a/addons/sale_project/models/__init__.py
+++ b/addons/sale_project/models/__init__.py
@@ -3,3 +3,4 @@
from . import product
from . import project
from . import sale_order
+from . import sale_order_line
diff --git a/addons/sale_project/models/sale_order.py b/addons/sale_project/models/sale_order.py
index 7df1c457e88..ad9346916a7 100644
--- a/addons/sale_project/models/sale_order.py
+++ b/addons/sale_project/models/sale_order.py
@@ -1,10 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import api, Command, fields, models, _
-
+from odoo import api, fields, models, _
from odoo.tools.safe_eval import safe_eval
-from odoo.tools.sql import column_exists, create_column
class SaleOrder(models.Model):
@@ -114,283 +112,3 @@ class SaleOrder(models.Model):
if 'state' in values and values['state'] == 'cancel':
self.project_id.sale_line_id = False
return super(SaleOrder, self).write(values)
-
-
-class SaleOrderLine(models.Model):
- _inherit = "sale.order.line"
-
- project_id = fields.Many2one(
- 'project.project', 'Generated Project',
- index=True, copy=False, help="Project generated by the sales order item")
- task_id = fields.Many2one(
- 'project.task', 'Generated Task',
- index=True, copy=False, help="Task generated by the sales order item")
- is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
-
- @api.depends('product_id')
- def _compute_is_service(self):
- for so_line in self:
- so_line.is_service = so_line.product_id.type == 'service'
-
- @api.depends('product_id')
- def _compute_product_updatable(self):
- for line in self:
- if line.product_id.type == 'service' and line.state == 'sale':
- line.product_updatable = False
- else:
- super(SaleOrderLine, line)._compute_product_updatable()
-
- def _auto_init(self):
- """
- Create column to stop ORM from computing it himself (too slow)
- """
- if not column_exists(self.env.cr, 'sale_order_line', 'is_service'):
- create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool')
- self.env.cr.execute("""
- UPDATE sale_order_line line
- SET is_service = (pt.type = 'service')
- FROM product_product pp
- LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
- WHERE pp.id = line.product_id
- """)
- return super()._auto_init()
-
- @api.model_create_multi
- def create(self, vals_list):
- lines = super().create(vals_list)
- # Do not generate task/project when expense SO line, but allow
- # generate task with hours=0.
- for line in lines:
- if line.state == 'sale' and not line.is_expense:
- line.sudo()._timesheet_service_generation()
- # if the SO line created a task, post a message on the order
- if line.task_id:
- msg_body = _("Task Created (%s): %s") % (line.product_id.name, line.task_id.id, line.task_id.name)
- line.order_id.message_post(body=msg_body)
- return lines
-
- def write(self, values):
- result = super().write(values)
- # changing the ordered quantity should change the planned hours on the
- # task, whatever the SO state. It will be blocked by the super in case
- # of a locked sale order.
- if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
- for line in self:
- if line.task_id and line.product_id.type == 'service':
- planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
- line.task_id.write({'planned_hours': planned_hours})
- return result
-
- ###########################################
- # Service : Project and task generation
- ###########################################
-
- def _convert_qty_company_hours(self, dest_company):
- return self.product_uom_qty
-
- def _timesheet_create_project_prepare_values(self):
- """Generate project values"""
- account = self.order_id.analytic_account_id
- if not account:
- service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
- default_code = service_products.default_code if len(service_products) == 1 else None
- self.order_id._create_analytic_account(prefix=default_code)
- account = self.order_id.analytic_account_id
-
- # create the project or duplicate one
- return {
- 'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
- 'analytic_account_id': account.id,
- 'analytic_tag_ids': [Command.set(self.analytic_tag_ids.ids)],
- 'partner_id': self.order_id.partner_id.id,
- 'sale_line_id': self.id,
- 'active': True,
- 'company_id': self.company_id.id,
- }
-
- def _timesheet_create_project(self):
- """ Generate project for the given so line, and link it.
- :param project: record of project.project in which the task should be created
- :return task: record of the created task
- """
- self.ensure_one()
- values = self._timesheet_create_project_prepare_values()
- if self.product_id.project_template_id:
- values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
- project = self.product_id.project_template_id.copy(values)
- project.tasks.write({
- 'sale_line_id': self.id,
- 'partner_id': self.order_id.partner_id.id,
- 'email_from': self.order_id.partner_id.email,
- })
- # duplicating a project doesn't set the SO on sub-tasks
- project.tasks.filtered(lambda task: task.parent_id != False).write({
- 'sale_line_id': self.id,
- 'sale_order_id': self.order_id,
- })
- else:
- project_only_sol_count = self.env['sale.order.line'].search_count([
- ('order_id', '=', self.order_id.id),
- ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']),
- ])
- if project_only_sol_count == 1:
- values['name'] = "%s - [%s] %s" % (values['name'], self.product_id.default_code, self.product_id.name) if self.product_id.default_code else "%s - %s" % (values['name'], self.product_id.name)
- project = self.env['project.project'].create(values)
-
- # Avoid new tasks to go to 'Undefined Stage'
- if not project.type_ids:
- project.type_ids = self.env['project.task.type'].create({'name': _('New')})
-
- # link project as generated by current so line
- self.write({'project_id': project.id})
- return project
-
- def _timesheet_create_task_prepare_values(self, project):
- self.ensure_one()
- planned_hours = self._convert_qty_company_hours(self.company_id)
- sale_line_name_parts = self.name.split('\n')
- title = sale_line_name_parts[0] or self.product_id.name
- description = '
'.join(sale_line_name_parts[1:])
- return {
- 'name': title if project.sale_line_id else '%s - %s' % (self.order_id.name or '', title),
- 'planned_hours': planned_hours,
- 'partner_id': self.order_id.partner_id.id,
- 'email_from': self.order_id.partner_id.email,
- 'description': description,
- 'project_id': project.id,
- 'sale_line_id': self.id,
- 'sale_order_id': self.order_id.id,
- 'company_id': project.company_id.id,
- 'user_ids': False, # force non assigned task, as created as sudo()
- }
-
- def _timesheet_create_task(self, project):
- """ Generate task for the given so line, and link it.
- :param project: record of project.project in which the task should be created
- :return task: record of the created task
- """
- values = self._timesheet_create_task_prepare_values(project)
- task = self.env['project.task'].sudo().create(values)
- self.write({'task_id': task.id})
- # post message on task
- task_msg = _("This task has been created from: %s (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name)
- task.message_post(body=task_msg)
- return task
-
- def _timesheet_service_generation(self):
- """ For service lines, create the task or the project. If already exists, it simply links
- the existing one to the line.
- Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
- new project/task. This explains the searches on 'sale_line_id' on project/task. This also
- implied if so line of generated task has been modified, we may regenerate it.
- """
- so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
- so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
-
- # search so lines from SO of current so lines having their project generated, in order to check if the current one can
- # create its own project, or reuse the one of its order.
- map_so_project = {}
- if so_line_new_project:
- order_ids = self.mapped('order_id').ids
- so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)])
- map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
- so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)])
- map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}
-
- # search the global project of current SO lines, in which create their task
- map_sol_project = {}
- if so_line_task_global_project:
- map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project}
-
- def _can_create_project(sol):
- if not sol.project_id:
- if sol.product_id.project_template_id:
- return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates
- elif sol.order_id.id not in map_so_project:
- return True
- return False
-
- def _determine_project(so_line):
- """Determine the project for this sale order line.
- Rules are different based on the service_tracking:
-
- - 'project_only': the project_id can only come from the sale order line itself
- - 'task_in_project': the project_id comes from the sale order line only if no project_id was configured
- on the parent sale order"""
-
- if so_line.product_id.service_tracking == 'project_only':
- return so_line.project_id
- elif so_line.product_id.service_tracking == 'task_in_project':
- return so_line.order_id.project_id or so_line.project_id
-
- return False
-
- # task_global_project: create task in global project
- for so_line in so_line_task_global_project:
- if not so_line.task_id:
- if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0:
- so_line._timesheet_create_task(project=map_sol_project[so_line.id])
-
- # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too.
- # if 'task_in_project' and project_id configured on SO, use that one instead
- for so_line in so_line_new_project:
- project = _determine_project(so_line)
- if not project and _can_create_project(so_line):
- project = so_line._timesheet_create_project()
- if so_line.product_id.project_template_id:
- map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
- else:
- map_so_project[so_line.order_id.id] = project
- elif not project:
- # Attach subsequent SO lines to the created project
- so_line.project_id = (
- map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id))
- or map_so_project.get(so_line.order_id.id)
- )
- if so_line.product_id.service_tracking == 'task_in_project':
- if not project:
- if so_line.product_id.project_template_id:
- project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
- else:
- project = map_so_project[so_line.order_id.id]
- if not so_line.task_id:
- so_line._timesheet_create_task(project=project)
-
- def _prepare_invoice_line(self, **optional_values):
- """
- If the sale order line isn't linked to a sale order which already have a default analytic account,
- this method allows to retrieve the analytic account which is linked to project or task directly linked
- to this sale order line, or the analytic account of the project which uses this sale order line, if it exists.
- """
- values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values)
- if not values.get('analytic_account_id'):
- if self.task_id.analytic_account_id:
- values['analytic_account_id'] = self.task_id._get_task_analytic_account_id().id
- elif self.project_id.analytic_account_id:
- values['analytic_account_id'] = self.project_id.analytic_account_id.id
- elif self.is_service and not self.is_expense:
- task_analytic_account_id = self.env['project.task'].read_group([
- ('sale_line_id', '=', self.id),
- ('analytic_account_id', '!=', False),
- ], ['analytic_account_id'], ['analytic_account_id'])
- project_analytic_account_id = self.env['project.project'].read_group([
- ('analytic_account_id', '!=', False),
- '|',
- ('sale_line_id', '=', self.id),
- '&',
- ('tasks.sale_line_id', '=', self.id),
- ('tasks.analytic_account_id', '=', False)
- ], ['analytic_account_id'], ['analytic_account_id'])
- analytic_account_ids = {rec['analytic_account_id'][0] for rec in (task_analytic_account_id + project_analytic_account_id)}
- if len(analytic_account_ids) == 1:
- values['analytic_account_id'] = analytic_account_ids.pop()
- if self.task_id.analytic_tag_ids:
- values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids]
- if self.task_id.analytic_tag_ids or self.task_id.project_id.analytic_tag_ids:
- values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids | self.task_id.project_id.analytic_tag_ids]
- elif self.is_service and not self.is_expense:
- tag_ids = self.env['account.analytic.tag'].search([
- '|', ('task_ids.sale_line_id', '=', self.id), ('project_ids.sale_line_id', '=', self.id)
- ])
- values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in tag_ids]
- return values
diff --git a/addons/sale_project/models/sale_order_line.py b/addons/sale_project/models/sale_order_line.py
new file mode 100644
index 00000000000..4bde2b18a76
--- /dev/null
+++ b/addons/sale_project/models/sale_order_line.py
@@ -0,0 +1,285 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, Command, fields, models, _
+from odoo.tools.sql import column_exists, create_column
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ project_id = fields.Many2one(
+ 'project.project', 'Generated Project',
+ index=True, copy=False, help="Project generated by the sales order item")
+ task_id = fields.Many2one(
+ 'project.task', 'Generated Task',
+ index=True, copy=False, help="Task generated by the sales order item")
+ is_service = fields.Boolean("Is a Service", compute='_compute_is_service', store=True, compute_sudo=True, help="Sales Order item should generate a task and/or a project, depending on the product settings.")
+
+ @api.depends('product_id')
+ def _compute_is_service(self):
+ for so_line in self:
+ so_line.is_service = so_line.product_id.type == 'service'
+
+ @api.depends('product_id')
+ def _compute_product_updatable(self):
+ for line in self:
+ if line.product_id.type == 'service' and line.state == 'sale':
+ line.product_updatable = False
+ else:
+ super(SaleOrderLine, line)._compute_product_updatable()
+
+ def _auto_init(self):
+ """
+ Create column to stop ORM from computing it himself (too slow)
+ """
+ if not column_exists(self.env.cr, 'sale_order_line', 'is_service'):
+ create_column(self.env.cr, 'sale_order_line', 'is_service', 'bool')
+ self.env.cr.execute("""
+ UPDATE sale_order_line line
+ SET is_service = (pt.type = 'service')
+ FROM product_product pp
+ LEFT JOIN product_template pt ON pt.id = pp.product_tmpl_id
+ WHERE pp.id = line.product_id
+ """)
+ return super()._auto_init()
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ lines = super().create(vals_list)
+ # Do not generate task/project when expense SO line, but allow
+ # generate task with hours=0.
+ for line in lines:
+ if line.state == 'sale' and not line.is_expense:
+ line.sudo()._timesheet_service_generation()
+ # if the SO line created a task, post a message on the order
+ if line.task_id:
+ msg_body = _("Task Created (%s): %s") % (line.product_id.name, line.task_id.id, line.task_id.name)
+ line.order_id.message_post(body=msg_body)
+ return lines
+
+ def write(self, values):
+ result = super().write(values)
+ # changing the ordered quantity should change the planned hours on the
+ # task, whatever the SO state. It will be blocked by the super in case
+ # of a locked sale order.
+ if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
+ for line in self:
+ if line.task_id and line.product_id.type == 'service':
+ planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
+ line.task_id.write({'planned_hours': planned_hours})
+ return result
+
+ ###########################################
+ # Service : Project and task generation
+ ###########################################
+
+ def _convert_qty_company_hours(self, dest_company):
+ return self.product_uom_qty
+
+ def _timesheet_create_project_prepare_values(self):
+ """Generate project values"""
+ account = self.order_id.analytic_account_id
+ if not account:
+ service_products = self.order_id.order_line.product_id.filtered(lambda p: p.type == 'service' and p.default_code)
+ default_code = service_products.default_code if len(service_products) == 1 else None
+ self.order_id._create_analytic_account(prefix=default_code)
+ account = self.order_id.analytic_account_id
+
+ # create the project or duplicate one
+ return {
+ 'name': '%s - %s' % (self.order_id.client_order_ref, self.order_id.name) if self.order_id.client_order_ref else self.order_id.name,
+ 'analytic_account_id': account.id,
+ 'analytic_tag_ids': [Command.set(self.analytic_tag_ids.ids)],
+ 'partner_id': self.order_id.partner_id.id,
+ 'sale_line_id': self.id,
+ 'active': True,
+ 'company_id': self.company_id.id,
+ }
+
+ def _timesheet_create_project(self):
+ """ Generate project for the given so line, and link it.
+ :param project: record of project.project in which the task should be created
+ :return task: record of the created task
+ """
+ self.ensure_one()
+ values = self._timesheet_create_project_prepare_values()
+ if self.product_id.project_template_id:
+ values['name'] = "%s - %s" % (values['name'], self.product_id.project_template_id.name)
+ project = self.product_id.project_template_id.copy(values)
+ project.tasks.write({
+ 'sale_line_id': self.id,
+ 'partner_id': self.order_id.partner_id.id,
+ 'email_from': self.order_id.partner_id.email,
+ })
+ # duplicating a project doesn't set the SO on sub-tasks
+ project.tasks.filtered('parent_id').write({
+ 'sale_line_id': self.id,
+ 'sale_order_id': self.order_id,
+ })
+ else:
+ project_only_sol_count = self.env['sale.order.line'].search_count([
+ ('order_id', '=', self.order_id.id),
+ ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']),
+ ])
+ if project_only_sol_count == 1:
+ values['name'] = "%s - [%s] %s" % (values['name'], self.product_id.default_code, self.product_id.name) if self.product_id.default_code else "%s - %s" % (values['name'], self.product_id.name)
+ project = self.env['project.project'].create(values)
+
+ # Avoid new tasks to go to 'Undefined Stage'
+ if not project.type_ids:
+ project.type_ids = self.env['project.task.type'].create({'name': _('New')})
+
+ # link project as generated by current so line
+ self.write({'project_id': project.id})
+ return project
+
+ def _timesheet_create_task_prepare_values(self, project):
+ self.ensure_one()
+ planned_hours = self._convert_qty_company_hours(self.company_id)
+ sale_line_name_parts = self.name.split('\n')
+ title = sale_line_name_parts[0] or self.product_id.name
+ description = '
'.join(sale_line_name_parts[1:])
+ return {
+ 'name': title if project.sale_line_id else '%s - %s' % (self.order_id.name or '', title),
+ 'planned_hours': planned_hours,
+ 'partner_id': self.order_id.partner_id.id,
+ 'email_from': self.order_id.partner_id.email,
+ 'description': description,
+ 'project_id': project.id,
+ 'sale_line_id': self.id,
+ 'sale_order_id': self.order_id.id,
+ 'company_id': project.company_id.id,
+ 'user_ids': False, # force non assigned task, as created as sudo()
+ }
+
+ def _timesheet_create_task(self, project):
+ """ Generate task for the given so line, and link it.
+ :param project: record of project.project in which the task should be created
+ :return task: record of the created task
+ """
+ values = self._timesheet_create_task_prepare_values(project)
+ task = self.env['project.task'].sudo().create(values)
+ self.write({'task_id': task.id})
+ # post message on task
+ task_msg = _("This task has been created from: %s (%s)") % (self.order_id.id, self.order_id.name, self.product_id.name)
+ task.message_post(body=task_msg)
+ return task
+
+ def _timesheet_service_generation(self):
+ """ For service lines, create the task or the project. If already exists, it simply links
+ the existing one to the line.
+ Note: If the SO was confirmed, cancelled, set to draft then confirmed, avoid creating a
+ new project/task. This explains the searches on 'sale_line_id' on project/task. This also
+ implied if so line of generated task has been modified, we may regenerate it.
+ """
+ so_line_task_global_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking == 'task_global_project')
+ so_line_new_project = self.filtered(lambda sol: sol.is_service and sol.product_id.service_tracking in ['project_only', 'task_in_project'])
+
+ # search so lines from SO of current so lines having their project generated, in order to check if the current one can
+ # create its own project, or reuse the one of its order.
+ map_so_project = {}
+ if so_line_new_project:
+ order_ids = self.mapped('order_id').ids
+ so_lines_with_project = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '=', False)])
+ map_so_project = {sol.order_id.id: sol.project_id for sol in so_lines_with_project}
+ so_lines_with_project_templates = self.search([('order_id', 'in', order_ids), ('project_id', '!=', False), ('product_id.service_tracking', 'in', ['project_only', 'task_in_project']), ('product_id.project_template_id', '!=', False)])
+ map_so_project_templates = {(sol.order_id.id, sol.product_id.project_template_id.id): sol.project_id for sol in so_lines_with_project_templates}
+
+ # search the global project of current SO lines, in which create their task
+ map_sol_project = {}
+ if so_line_task_global_project:
+ map_sol_project = {sol.id: sol.product_id.with_company(sol.company_id).project_id for sol in so_line_task_global_project}
+
+ def _can_create_project(sol):
+ if not sol.project_id:
+ if sol.product_id.project_template_id:
+ return (sol.order_id.id, sol.product_id.project_template_id.id) not in map_so_project_templates
+ elif sol.order_id.id not in map_so_project:
+ return True
+ return False
+
+ def _determine_project(so_line):
+ """Determine the project for this sale order line.
+ Rules are different based on the service_tracking:
+
+ - 'project_only': the project_id can only come from the sale order line itself
+ - 'task_in_project': the project_id comes from the sale order line only if no project_id was configured
+ on the parent sale order"""
+
+ if so_line.product_id.service_tracking == 'project_only':
+ return so_line.project_id
+ elif so_line.product_id.service_tracking == 'task_in_project':
+ return so_line.order_id.project_id or so_line.project_id
+
+ return False
+
+ # task_global_project: create task in global project
+ for so_line in so_line_task_global_project:
+ if not so_line.task_id:
+ if map_sol_project.get(so_line.id) and so_line.product_uom_qty > 0:
+ so_line._timesheet_create_task(project=map_sol_project[so_line.id])
+
+ # project_only, task_in_project: create a new project, based or not on a template (1 per SO). May be create a task too.
+ # if 'task_in_project' and project_id configured on SO, use that one instead
+ for so_line in so_line_new_project:
+ project = _determine_project(so_line)
+ if not project and _can_create_project(so_line):
+ project = so_line._timesheet_create_project()
+ if so_line.product_id.project_template_id:
+ map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)] = project
+ else:
+ map_so_project[so_line.order_id.id] = project
+ elif not project:
+ # Attach subsequent SO lines to the created project
+ so_line.project_id = (
+ map_so_project_templates.get((so_line.order_id.id, so_line.product_id.project_template_id.id))
+ or map_so_project.get(so_line.order_id.id)
+ )
+ if so_line.product_id.service_tracking == 'task_in_project':
+ if not project:
+ if so_line.product_id.project_template_id:
+ project = map_so_project_templates[(so_line.order_id.id, so_line.product_id.project_template_id.id)]
+ else:
+ project = map_so_project[so_line.order_id.id]
+ if not so_line.task_id:
+ so_line._timesheet_create_task(project=project)
+
+ def _prepare_invoice_line(self, **optional_values):
+ """
+ If the sale order line isn't linked to a sale order which already have a default analytic account,
+ this method allows to retrieve the analytic account which is linked to project or task directly linked
+ to this sale order line, or the analytic account of the project which uses this sale order line, if it exists.
+ """
+ values = super(SaleOrderLine, self)._prepare_invoice_line(**optional_values)
+ if not values.get('analytic_account_id'):
+ if self.task_id.analytic_account_id:
+ values['analytic_account_id'] = self.task_id._get_task_analytic_account_id().id
+ elif self.project_id.analytic_account_id:
+ values['analytic_account_id'] = self.project_id.analytic_account_id.id
+ elif self.is_service and not self.is_expense:
+ task_analytic_account_id = self.env['project.task'].read_group([
+ ('sale_line_id', '=', self.id),
+ ('analytic_account_id', '!=', False),
+ ], ['analytic_account_id'], ['analytic_account_id'])
+ project_analytic_account_id = self.env['project.project'].read_group([
+ ('analytic_account_id', '!=', False),
+ '|',
+ ('sale_line_id', '=', self.id),
+ '&',
+ ('tasks.sale_line_id', '=', self.id),
+ ('tasks.analytic_account_id', '=', False)
+ ], ['analytic_account_id'], ['analytic_account_id'])
+ analytic_account_ids = {rec['analytic_account_id'][0] for rec in (task_analytic_account_id + project_analytic_account_id)}
+ if len(analytic_account_ids) == 1:
+ values['analytic_account_id'] = analytic_account_ids.pop()
+ if self.task_id.analytic_tag_ids:
+ values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids]
+ if self.task_id.analytic_tag_ids or self.task_id.project_id.analytic_tag_ids:
+ values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in self.task_id.analytic_tag_ids | self.task_id.project_id.analytic_tag_ids]
+ elif self.is_service and not self.is_expense:
+ tag_ids = self.env['account.analytic.tag'].search([
+ '|', ('task_ids.sale_line_id', '=', self.id), ('project_ids.sale_line_id', '=', self.id)
+ ])
+ values['analytic_tag_ids'] += [Command.link(tag_id.id) for tag_id in tag_ids]
+ return values
diff --git a/addons/sale_purchase/models/__init__.py b/addons/sale_purchase/models/__init__.py
index 99593ce83f2..422be9e3f1f 100644
--- a/addons/sale_purchase/models/__init__.py
+++ b/addons/sale_purchase/models/__init__.py
@@ -3,4 +3,5 @@
from . import product_template
from . import sale_order
+from . import sale_order_line
from . import purchase_order
diff --git a/addons/sale_purchase/models/sale_order.py b/addons/sale_purchase/models/sale_order.py
index 004295edfea..d5528f5df17 100644
--- a/addons/sale_purchase/models/sale_order.py
+++ b/addons/sale_purchase/models/sale_order.py
@@ -1,11 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from dateutil.relativedelta import relativedelta
-
from odoo import api, fields, models, _
-from odoo.exceptions import UserError
-from odoo.tools import float_compare
class SaleOrder(models.Model):
@@ -77,239 +73,3 @@ class SaleOrder(models.Model):
'sale_orders': sale_order_lines.mapped('order_id'),
'sale_order_lines': sale_order_lines,
})
-
-
-class SaleOrderLine(models.Model):
- _inherit = 'sale.order.line'
-
- purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.")
- purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count')
-
- @api.depends('purchase_line_ids')
- def _compute_purchase_count(self):
- database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
- mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data])
- for line in self:
- line.purchase_line_count = mapped_data.get(line.id, 0)
-
- @api.onchange('product_uom_qty')
- def _onchange_service_product_uom_qty(self):
- if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase:
- if self.product_uom_qty < self._origin.product_uom_qty:
- if self.product_uom_qty < self.qty_delivered:
- return {}
- warning_mess = {
- 'title': _('Ordered quantity decreased!'),
- 'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'),
- }
- return {'warning': warning_mess}
- return {}
-
- # --------------------------
- # CRUD
- # --------------------------
-
- @api.model_create_multi
- def create(self, values):
- lines = super(SaleOrderLine, self).create(values)
- # Do not generate purchase when expense SO line since the product is already delivered
- lines.filtered(
- lambda line: line.state == 'sale' and not line.is_expense
- )._purchase_service_generation()
- return lines
-
- def write(self, values):
- increased_lines = None
- decreased_lines = None
- increased_values = {}
- decreased_values = {}
- if 'product_uom_qty' in values:
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- increased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
- decreased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1)
- increased_values = {line.id: line.product_uom_qty for line in increased_lines}
- decreased_values = {line.id: line.product_uom_qty for line in decreased_lines}
-
- result = super(SaleOrderLine, self).write(values)
-
- if increased_lines:
- increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values)
- if decreased_lines:
- decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values)
- return result
-
- # --------------------------
- # Business Methods
- # --------------------------
-
- def _purchase_decrease_ordered_qty(self, new_qty, origin_values):
- """ Decrease the quantity from SO line will add a next acitivities on the related purchase order
- :param new_qty: new quantity (lower than the current one on SO line), expressed
- in UoM of SO line.
- :param origin_values: map from sale line id to old value for the ordered quantity (dict)
- """
- purchase_to_notify_map = {} # map PO -> set(SOL)
- last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)])
- for purchase_line in last_purchase_lines:
- purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line'])
- purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id
-
- # create next activity
- for purchase_order, sale_lines in purchase_to_notify_map.items():
- render_context = {
- 'sale_lines': sale_lines,
- 'sale_orders': sale_lines.mapped('order_id'),
- 'origin_values': origin_values,
- }
- purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning',
- user_id=purchase_order.user_id.id or self.env.uid,
- views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased',
- render_context=render_context)
-
- def _purchase_increase_ordered_qty(self, new_qty, origin_values):
- """ Increase the quantity on the related purchase lines
- :param new_qty: new quantity (higher than the current one on SO line), expressed
- in UoM of SO line.
- :param origin_values: map from sale line id to old value for the ordered quantity (dict)
- """
- for line in self:
- last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1)
- if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines
- quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom)
- last_purchase_line.write({'product_qty': quantity})
- elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line
- quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom)
- line._purchase_service_create(quantity=quantity)
-
- def _purchase_get_date_order(self, supplierinfo):
- """ return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """
- commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now())
- return commitment_date - relativedelta(days=int(supplierinfo.delay))
-
- def _purchase_service_prepare_order_values(self, supplierinfo):
- """ Returns the values to create the purchase order from the current SO line.
- :param supplierinfo: record of product.supplierinfo
- :rtype: dict
- """
- self.ensure_one()
- partner_supplier = supplierinfo.partner_id
- fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner_supplier)
- date_order = self._purchase_get_date_order(supplierinfo)
- return {
- 'partner_id': partner_supplier.id,
- 'partner_ref': partner_supplier.ref,
- 'company_id': self.company_id.id,
- 'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.company.currency_id.id,
- 'dest_address_id': False, # False since only supported in stock
- 'origin': self.order_id.name,
- 'payment_term_id': partner_supplier.property_supplier_payment_term_id.id,
- 'date_order': date_order,
- 'fiscal_position_id': fpos.id,
- }
-
- def _purchase_service_prepare_line_values(self, purchase_order, quantity=False):
- """ Returns the values to create the purchase order line from the current SO line.
- :param purchase_order: record of purchase.order
- :rtype: dict
- :param quantity: the quantity to force on the PO line, expressed in SO line UoM
- """
- self.ensure_one()
- # compute quantity from SO line UoM
- product_quantity = self.product_uom_qty
- if quantity:
- product_quantity = quantity
-
- purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id)
-
- # determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...)
- # Note: one partner can have multiple supplier info for the same product
- supplierinfo = self.product_id._select_seller(
- partner_id=purchase_order.partner_id,
- quantity=purchase_qty_uom,
- date=purchase_order.date_order and purchase_order.date_order.date(), # and purchase_order.date_order[:10],
- uom_id=self.product_id.uom_po_id
- )
- supplier_taxes = self.product_id.supplier_taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id)
- taxes = purchase_order.fiscal_position_id.map_tax(supplier_taxes)
-
- # compute unit price
- price_unit = 0.0
- if supplierinfo:
- price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(
- supplierinfo.price, supplier_taxes, taxes, self.company_id)
- if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id:
- price_unit = supplierinfo.currency_id._convert(price_unit, purchase_order.currency_id, purchase_order.company_id, fields.datetime.today())
-
- return {
- 'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name,
- 'product_qty': purchase_qty_uom,
- 'product_id': self.product_id.id,
- 'product_uom': self.product_id.uom_po_id.id,
- 'price_unit': price_unit,
- 'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)),
- 'taxes_id': [(6, 0, taxes.ids)],
- 'order_id': purchase_order.id,
- 'sale_line_id': self.id,
- }
-
- def _purchase_service_create(self, quantity=False):
- """ On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order.
- If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds
- a new PO line. The created purchase order line will be linked to the SO line.
- :param quantity: the quantity to force on the PO line, expressed in SO line UoM
- """
- PurchaseOrder = self.env['purchase.order']
- supplier_po_map = {}
- sale_line_purchase_map = {}
- for line in self:
- line = line.with_company(line.company_id)
- # determine vendor of the order (take the first matching company and product)
- suppliers = line.product_id._select_seller(quantity=line.product_uom_qty, uom_id=line.product_uom)
- if not suppliers:
- raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,))
- supplierinfo = suppliers[0]
- partner_supplier = supplierinfo.partner_id
-
- # determine (or create) PO
- purchase_order = supplier_po_map.get(partner_supplier.id)
- if not purchase_order:
- purchase_order = PurchaseOrder.search([
- ('partner_id', '=', partner_supplier.id),
- ('state', '=', 'draft'),
- ('company_id', '=', line.company_id.id),
- ], limit=1)
- if not purchase_order:
- values = line._purchase_service_prepare_order_values(supplierinfo)
- purchase_order = PurchaseOrder.create(values)
- else: # update origin of existing PO
- so_name = line.order_id.name
- origins = []
- if purchase_order.origin:
- origins = purchase_order.origin.split(', ') + origins
- if so_name not in origins:
- origins += [so_name]
- purchase_order.write({
- 'origin': ', '.join(origins)
- })
- supplier_po_map[partner_supplier.id] = purchase_order
-
- # add a PO line to the PO
- values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity)
- purchase_line = line.env['purchase.order.line'].create(values)
-
- # link the generated purchase to the SO line
- sale_line_purchase_map.setdefault(line, line.env['purchase.order.line'])
- sale_line_purchase_map[line] |= purchase_line
- return sale_line_purchase_map
-
- def _purchase_service_generation(self):
- """ Create a Purchase for the first time from the sale line. If the SO line already created a PO, it
- will not create a second one.
- """
- sale_line_purchase_map = {}
- for line in self:
- # Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case)
- if line.product_id.service_to_purchase and not line.purchase_line_count:
- result = line._purchase_service_create()
- sale_line_purchase_map.update(result)
- return sale_line_purchase_map
diff --git a/addons/sale_purchase/models/sale_order_line.py b/addons/sale_purchase/models/sale_order_line.py
new file mode 100644
index 00000000000..cfe2bee6b58
--- /dev/null
+++ b/addons/sale_purchase/models/sale_order_line.py
@@ -0,0 +1,244 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.tools import float_compare
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ purchase_line_ids = fields.One2many('purchase.order.line', 'sale_line_id', string="Generated Purchase Lines", readonly=True, help="Purchase line generated by this Sales item on order confirmation, or when the quantity was increased.")
+ purchase_line_count = fields.Integer("Number of generated purchase items", compute='_compute_purchase_count')
+
+ @api.depends('purchase_line_ids')
+ def _compute_purchase_count(self):
+ database_data = self.env['purchase.order.line'].sudo().read_group([('sale_line_id', 'in', self.ids)], ['sale_line_id'], ['sale_line_id'])
+ mapped_data = dict([(db['sale_line_id'][0], db['sale_line_id_count']) for db in database_data])
+ for line in self:
+ line.purchase_line_count = mapped_data.get(line.id, 0)
+
+ @api.onchange('product_uom_qty')
+ def _onchange_service_product_uom_qty(self):
+ if self.state == 'sale' and self.product_id.type == 'service' and self.product_id.service_to_purchase:
+ if self.product_uom_qty < self._origin.product_uom_qty:
+ if self.product_uom_qty < self.qty_delivered:
+ return {}
+ warning_mess = {
+ 'title': _('Ordered quantity decreased!'),
+ 'message': _('You are decreasing the ordered quantity! Do not forget to manually update the purchase order if needed.'),
+ }
+ return {'warning': warning_mess}
+ return {}
+
+ # --------------------------
+ # CRUD
+ # --------------------------
+
+ @api.model_create_multi
+ def create(self, values):
+ lines = super(SaleOrderLine, self).create(values)
+ # Do not generate purchase when expense SO line since the product is already delivered
+ lines.filtered(
+ lambda line: line.state == 'sale' and not line.is_expense
+ )._purchase_service_generation()
+ return lines
+
+ def write(self, values):
+ increased_lines = None
+ decreased_lines = None
+ increased_values = {}
+ decreased_values = {}
+ if 'product_uom_qty' in values:
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ increased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1)
+ decreased_lines = self.sudo().filtered(lambda r: r.product_id.service_to_purchase and r.purchase_line_count and float_compare(r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == 1)
+ increased_values = {line.id: line.product_uom_qty for line in increased_lines}
+ decreased_values = {line.id: line.product_uom_qty for line in decreased_lines}
+
+ result = super(SaleOrderLine, self).write(values)
+
+ if increased_lines:
+ increased_lines._purchase_increase_ordered_qty(values['product_uom_qty'], increased_values)
+ if decreased_lines:
+ decreased_lines._purchase_decrease_ordered_qty(values['product_uom_qty'], decreased_values)
+ return result
+
+ # --------------------------
+ # Business Methods
+ # --------------------------
+
+ def _purchase_decrease_ordered_qty(self, new_qty, origin_values):
+ """ Decrease the quantity from SO line will add a next acitivities on the related purchase order
+ :param new_qty: new quantity (lower than the current one on SO line), expressed
+ in UoM of SO line.
+ :param origin_values: map from sale line id to old value for the ordered quantity (dict)
+ """
+ purchase_to_notify_map = {} # map PO -> set(SOL)
+ last_purchase_lines = self.env['purchase.order.line'].search([('sale_line_id', 'in', self.ids)])
+ for purchase_line in last_purchase_lines:
+ purchase_to_notify_map.setdefault(purchase_line.order_id, self.env['sale.order.line'])
+ purchase_to_notify_map[purchase_line.order_id] |= purchase_line.sale_line_id
+
+ # create next activity
+ for purchase_order, sale_lines in purchase_to_notify_map.items():
+ render_context = {
+ 'sale_lines': sale_lines,
+ 'sale_orders': sale_lines.mapped('order_id'),
+ 'origin_values': origin_values,
+ }
+ purchase_order._activity_schedule_with_view('mail.mail_activity_data_warning',
+ user_id=purchase_order.user_id.id or self.env.uid,
+ views_or_xmlid='sale_purchase.exception_purchase_on_sale_quantity_decreased',
+ render_context=render_context)
+
+ def _purchase_increase_ordered_qty(self, new_qty, origin_values):
+ """ Increase the quantity on the related purchase lines
+ :param new_qty: new quantity (higher than the current one on SO line), expressed
+ in UoM of SO line.
+ :param origin_values: map from sale line id to old value for the ordered quantity (dict)
+ """
+ for line in self:
+ last_purchase_line = self.env['purchase.order.line'].search([('sale_line_id', '=', line.id)], order='create_date DESC', limit=1)
+ if last_purchase_line.state in ['draft', 'sent', 'to approve']: # update qty for draft PO lines
+ quantity = line.product_uom._compute_quantity(new_qty, last_purchase_line.product_uom)
+ last_purchase_line.write({'product_qty': quantity})
+ elif last_purchase_line.state in ['purchase', 'done', 'cancel']: # create new PO, by forcing the quantity as the difference from SO line
+ quantity = line.product_uom._compute_quantity(new_qty - origin_values.get(line.id, 0.0), last_purchase_line.product_uom)
+ line._purchase_service_create(quantity=quantity)
+
+ def _purchase_get_date_order(self, supplierinfo):
+ """ return the ordered date for the purchase order, computed as : SO commitment date - supplier delay """
+ commitment_date = fields.Datetime.from_string(self.order_id.commitment_date or fields.Datetime.now())
+ return commitment_date - relativedelta(days=int(supplierinfo.delay))
+
+ def _purchase_service_prepare_order_values(self, supplierinfo):
+ """ Returns the values to create the purchase order from the current SO line.
+ :param supplierinfo: record of product.supplierinfo
+ :rtype: dict
+ """
+ self.ensure_one()
+ partner_supplier = supplierinfo.partner_id
+ fpos = self.env['account.fiscal.position'].sudo()._get_fiscal_position(partner_supplier)
+ date_order = self._purchase_get_date_order(supplierinfo)
+ return {
+ 'partner_id': partner_supplier.id,
+ 'partner_ref': partner_supplier.ref,
+ 'company_id': self.company_id.id,
+ 'currency_id': partner_supplier.property_purchase_currency_id.id or self.env.company.currency_id.id,
+ 'dest_address_id': False, # False since only supported in stock
+ 'origin': self.order_id.name,
+ 'payment_term_id': partner_supplier.property_supplier_payment_term_id.id,
+ 'date_order': date_order,
+ 'fiscal_position_id': fpos.id,
+ }
+
+ def _purchase_service_prepare_line_values(self, purchase_order, quantity=False):
+ """ Returns the values to create the purchase order line from the current SO line.
+ :param purchase_order: record of purchase.order
+ :rtype: dict
+ :param quantity: the quantity to force on the PO line, expressed in SO line UoM
+ """
+ self.ensure_one()
+ # compute quantity from SO line UoM
+ product_quantity = self.product_uom_qty
+ if quantity:
+ product_quantity = quantity
+
+ purchase_qty_uom = self.product_uom._compute_quantity(product_quantity, self.product_id.uom_po_id)
+
+ # determine vendor (real supplier, sharing the same partner as the one from the PO, but with more accurate informations like validity, quantity, ...)
+ # Note: one partner can have multiple supplier info for the same product
+ supplierinfo = self.product_id._select_seller(
+ partner_id=purchase_order.partner_id,
+ quantity=purchase_qty_uom,
+ date=purchase_order.date_order and purchase_order.date_order.date(), # and purchase_order.date_order[:10],
+ uom_id=self.product_id.uom_po_id
+ )
+ supplier_taxes = self.product_id.supplier_taxes_id.filtered(lambda t: t.company_id.id == self.company_id.id)
+ taxes = purchase_order.fiscal_position_id.map_tax(supplier_taxes)
+
+ # compute unit price
+ price_unit = 0.0
+ if supplierinfo:
+ price_unit = self.env['account.tax'].sudo()._fix_tax_included_price_company(
+ supplierinfo.price, supplier_taxes, taxes, self.company_id)
+ if purchase_order.currency_id and supplierinfo.currency_id != purchase_order.currency_id:
+ price_unit = supplierinfo.currency_id._convert(price_unit, purchase_order.currency_id, purchase_order.company_id, fields.datetime.today())
+
+ return {
+ 'name': '[%s] %s' % (self.product_id.default_code, self.name) if self.product_id.default_code else self.name,
+ 'product_qty': purchase_qty_uom,
+ 'product_id': self.product_id.id,
+ 'product_uom': self.product_id.uom_po_id.id,
+ 'price_unit': price_unit,
+ 'date_planned': fields.Date.from_string(purchase_order.date_order) + relativedelta(days=int(supplierinfo.delay)),
+ 'taxes_id': [(6, 0, taxes.ids)],
+ 'order_id': purchase_order.id,
+ 'sale_line_id': self.id,
+ }
+
+ def _purchase_service_create(self, quantity=False):
+ """ On Sales Order confirmation, some lines (services ones) can create a purchase order line and maybe a purchase order.
+ If a line should create a RFQ, it will check for existing PO. If no one is find, the SO line will create one, then adds
+ a new PO line. The created purchase order line will be linked to the SO line.
+ :param quantity: the quantity to force on the PO line, expressed in SO line UoM
+ """
+ PurchaseOrder = self.env['purchase.order']
+ supplier_po_map = {}
+ sale_line_purchase_map = {}
+ for line in self:
+ line = line.with_company(line.company_id)
+ # determine vendor of the order (take the first matching company and product)
+ suppliers = line.product_id._select_seller(quantity=line.product_uom_qty, uom_id=line.product_uom)
+ if not suppliers:
+ raise UserError(_("There is no vendor associated to the product %s. Please define a vendor for this product.") % (line.product_id.display_name,))
+ supplierinfo = suppliers[0]
+ partner_supplier = supplierinfo.partner_id
+
+ # determine (or create) PO
+ purchase_order = supplier_po_map.get(partner_supplier.id)
+ if not purchase_order:
+ purchase_order = PurchaseOrder.search([
+ ('partner_id', '=', partner_supplier.id),
+ ('state', '=', 'draft'),
+ ('company_id', '=', line.company_id.id),
+ ], limit=1)
+ if not purchase_order:
+ values = line._purchase_service_prepare_order_values(supplierinfo)
+ purchase_order = PurchaseOrder.create(values)
+ else: # update origin of existing PO
+ so_name = line.order_id.name
+ origins = []
+ if purchase_order.origin:
+ origins = purchase_order.origin.split(', ') + origins
+ if so_name not in origins:
+ origins += [so_name]
+ purchase_order.write({
+ 'origin': ', '.join(origins)
+ })
+ supplier_po_map[partner_supplier.id] = purchase_order
+
+ # add a PO line to the PO
+ values = line._purchase_service_prepare_line_values(purchase_order, quantity=quantity)
+ purchase_line = line.env['purchase.order.line'].create(values)
+
+ # link the generated purchase to the SO line
+ sale_line_purchase_map.setdefault(line, line.env['purchase.order.line'])
+ sale_line_purchase_map[line] |= purchase_line
+ return sale_line_purchase_map
+
+ def _purchase_service_generation(self):
+ """ Create a Purchase for the first time from the sale line. If the SO line already created a PO, it
+ will not create a second one.
+ """
+ sale_line_purchase_map = {}
+ for line in self:
+ # Do not regenerate PO line if the SO line has already created one in the past (SO cancel/reconfirmation case)
+ if line.product_id.service_to_purchase and not line.purchase_line_count:
+ result = line._purchase_service_create()
+ sale_line_purchase_map.update(result)
+ return sale_line_purchase_map
diff --git a/addons/sale_stock/models/__init__.py b/addons/sale_stock/models/__init__.py
index e2983e92c78..58ad5cc9cc2 100644
--- a/addons/sale_stock/models/__init__.py
+++ b/addons/sale_stock/models/__init__.py
@@ -2,9 +2,10 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_move
-from . import res_company
-from . import sale_order
-from . import res_config_settings
-from . import stock
from . import product_template
+from . import res_company
+from . import res_config_settings
from . import res_users
+from . import sale_order
+from . import sale_order_line
+from . import stock
diff --git a/addons/sale_stock/models/sale_order.py b/addons/sale_stock/models/sale_order.py
index dda25d646f6..3ef0cfd6605 100644
--- a/addons/sale_stock/models/sale_order.py
+++ b/addons/sale_stock/models/sale_order.py
@@ -3,13 +3,9 @@
import json
import logging
-from datetime import timedelta
-from collections import defaultdict
from odoo import api, fields, models, _
from odoo.tools import float_compare
-from odoo.exceptions import UserError
-
_logger = logging.getLogger(__name__)
@@ -246,343 +242,3 @@ class SaleOrder(models.Model):
if any(picking.state == 'done' for picking in order.picking_ids) and not order._context.get('disable_cancel_warning'):
return True
return res
-
-class SaleOrderLine(models.Model):
- _inherit = 'sale.order.line'
-
- qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
- route_id = fields.Many2one('stock.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
- move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
- product_type = fields.Selection(related='product_id.detailed_type')
- virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
- scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
- forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
- free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
- qty_available_today = fields.Float(compute='_compute_qty_at_date')
- warehouse_id = fields.Many2one(related='order_id.warehouse_id')
- qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
- is_mto = fields.Boolean(compute='_compute_is_mto')
- display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
- customer_lead = fields.Float(
- compute='_compute_customer_lead', store=True, readonly=False, precompute=True,
- inverse='_inverse_customer_lead')
-
- @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
- def _compute_qty_to_deliver(self):
- """Compute the visibility of the inventory widget."""
- for line in self:
- line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
- if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
- if line.state == 'sale' and not line.move_ids:
- line.display_qty_widget = False
- else:
- line.display_qty_widget = True
- else:
- line.display_qty_widget = False
-
- @api.depends(
- 'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
- 'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
- def _compute_qty_at_date(self):
- """ Compute the quantity forecasted of product at delivery date. There are
- two cases:
- 1. The quotation has a commitment_date, we take it as delivery date
- 2. The quotation hasn't commitment_date, we compute the estimated delivery
- date based on lead time"""
- treated = self.browse()
- # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
- # Then used the forecasted data of the related stock.move
- for line in self.filtered(lambda l: l.state == 'sale'):
- if not line.display_qty_widget:
- continue
- moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id)
- line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
- line.qty_available_today = 0
- line.free_qty_today = 0
- for move in moves:
- line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
- line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
- line.scheduled_date = line.order_id.commitment_date or line._expected_date()
- line.virtual_available_at_date = False
- treated |= line
-
- qty_processed_per_product = defaultdict(lambda: 0)
- grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
- # We first loop over the SO lines to group them by warehouse and schedule
- # date in order to batch the read of the quantities computed field.
- for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
- if not (line.product_id and line.display_qty_widget):
- continue
- grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
-
- for (warehouse, scheduled_date), lines in grouped_lines.items():
- product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
- 'qty_available',
- 'free_qty',
- 'virtual_available',
- ])
- qties_per_product = {
- product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
- for product in product_qties
- }
- for line in lines:
- line.scheduled_date = scheduled_date
- qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
- line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
- line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
- line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
- line.forecast_expected_date = False
- product_qty = line.product_uom_qty
- if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
- line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
- line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
- line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
- product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
- qty_processed_per_product[line.product_id.id] += product_qty
- treated |= lines
- remaining = (self - treated)
- remaining.virtual_available_at_date = False
- remaining.scheduled_date = False
- remaining.forecast_expected_date = False
- remaining.free_qty_today = False
- remaining.qty_available_today = False
-
- @api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
- def _compute_is_mto(self):
- """ Verify the route of the product based on the warehouse
- set 'is_available' at True if the product availability in stock does
- not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
- """
- self.is_mto = False
- for line in self:
- if not line.display_qty_widget:
- continue
- product = line.product_id
- product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
-
- # Check MTO
- mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
- if not mto_route:
- try:
- mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
- except UserError:
- # if route MTO not found in ir_model_data, we treat the product as in MTS
- pass
-
- if mto_route and mto_route in product_routes:
- line.is_mto = True
- else:
- line.is_mto = False
-
- @api.depends('product_id')
- def _compute_qty_delivered_method(self):
- """ Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
- For SO line coming from expense, no picking should be generate: we don't manage stock for
- those lines, even if the product is a storable.
- """
- super(SaleOrderLine, self)._compute_qty_delivered_method()
-
- for line in self:
- if not line.is_expense and line.product_id.type in ['consu', 'product']:
- line.qty_delivered_method = 'stock_move'
-
- @api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
- def _compute_qty_delivered(self):
- super(SaleOrderLine, self)._compute_qty_delivered()
-
- for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
- if line.qty_delivered_method == 'stock_move':
- qty = 0.0
- outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves()
- for move in outgoing_moves:
- if move.state != 'done':
- continue
- qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
- for move in incoming_moves:
- if move.state != 'done':
- continue
- qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
- line.qty_delivered = qty
-
- @api.model_create_multi
- def create(self, vals_list):
- lines = super(SaleOrderLine, self).create(vals_list)
- lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
- return lines
-
- def write(self, values):
- lines = self.env['sale.order.line']
- if 'product_uom_qty' in values:
- lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
-
- previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
- res = super(SaleOrderLine, self).write(values)
- if lines:
- lines._action_launch_stock_rule(previous_product_uom_qty)
- return res
-
- @api.depends('order_id.state')
- def _compute_invoice_status(self):
- def check_moves_state(moves):
- # All moves states are either 'done' or 'cancel', and there is at least one 'done'
- at_least_one_done = False
- for move in moves:
- if move.state not in ['done', 'cancel']:
- return False
- at_least_one_done = at_least_one_done or move.state == 'done'
- return at_least_one_done
- super(SaleOrderLine, self)._compute_invoice_status()
- for line in self:
- # We handle the following specific situation: a physical product is partially delivered,
- # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
- # products sold by weight, where the delivered quantity rarely matches exactly the
- # quantity ordered.
- if line.order_id.state == 'done'\
- and line.invoice_status == 'no'\
- and line.product_id.type in ['consu', 'product']\
- and line.product_id.invoice_policy == 'delivery'\
- and line.move_ids \
- and check_moves_state(line.move_ids):
- line.invoice_status = 'invoiced'
-
- @api.depends('move_ids')
- def _compute_product_updatable(self):
- for line in self:
- if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
- super(SaleOrderLine, line)._compute_product_updatable()
- else:
- line.product_updatable = False
-
- @api.depends('product_id')
- def _compute_customer_lead(self):
- super()._compute_customer_lead() # Reset customer_lead when the product is modified
- for line in self:
- line.customer_lead = line.product_id.sale_delay
-
- def _inverse_customer_lead(self):
- for line in self:
- if line.state == 'sale' and not line.order_id.commitment_date:
- # Propagate deadline on related stock move
- line.move_ids.date_deadline = line.order_id.date_order + timedelta(days=line.customer_lead or 0.0)
-
- def _prepare_procurement_values(self, group_id=False):
- """ Prepare specific key for moves or other components that will be created from a stock rule
- coming from a sale order line. This method could be override in order to add other custom key that could
- be used in move/po creation.
- """
- values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
- self.ensure_one()
- # Use the delivery date if there is else use date_order and lead time
- date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0))
- date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
- values.update({
- 'group_id': group_id,
- 'sale_line_id': self.id,
- 'date_planned': date_planned,
- 'date_deadline': date_deadline,
- 'route_ids': self.route_id,
- 'warehouse_id': self.order_id.warehouse_id or False,
- 'partner_id': self.order_id.partner_shipping_id.id,
- 'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
- 'company_id': self.order_id.company_id,
- 'product_packaging_id': self.product_packaging_id,
- })
- return values
-
- def _get_qty_procurement(self, previous_product_uom_qty=False):
- self.ensure_one()
- qty = 0.0
- outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
- for move in outgoing_moves:
- qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
- for move in incoming_moves:
- qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
- return qty
-
- def _get_outgoing_incoming_moves(self):
- outgoing_moves = self.env['stock.move']
- incoming_moves = self.env['stock.move']
-
- moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id)
- if self._context.get('accrual_entry_date'):
- moves = moves.filtered(lambda r: fields.Date.to_date(r.date) <= self._context['accrual_entry_date'])
-
- for move in moves:
- if move.location_dest_id.usage == "customer":
- if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
- outgoing_moves |= move
- elif move.location_dest_id.usage != "customer" and move.to_refund:
- incoming_moves |= move
-
- return outgoing_moves, incoming_moves
-
- def _get_procurement_group(self):
- return self.order_id.procurement_group_id
-
- def _prepare_procurement_group_vals(self):
- return {
- 'name': self.order_id.name,
- 'move_type': self.order_id.picking_policy,
- 'sale_id': self.order_id.id,
- 'partner_id': self.order_id.partner_shipping_id.id,
- }
-
- def _action_launch_stock_rule(self, previous_product_uom_qty=False):
- """
- Launch procurement group run method with required/custom fields generated by a
- sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
- depending on the sale order line product rule.
- """
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- procurements = []
- for line in self:
- line = line.with_company(line.company_id)
- if line.state != 'sale' or not line.product_id.type in ('consu','product'):
- continue
- qty = line._get_qty_procurement(previous_product_uom_qty)
- if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
- continue
-
- group_id = line._get_procurement_group()
- if not group_id:
- group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
- line.order_id.procurement_group_id = group_id
- else:
- # In case the procurement group is already created and the order was
- # cancelled, we need to update certain values of the group.
- updated_vals = {}
- if group_id.partner_id != line.order_id.partner_shipping_id:
- updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
- if group_id.move_type != line.order_id.picking_policy:
- updated_vals.update({'move_type': line.order_id.picking_policy})
- if updated_vals:
- group_id.write(updated_vals)
-
- values = line._prepare_procurement_values(group_id=group_id)
- product_qty = line.product_uom_qty - qty
-
- line_uom = line.product_uom
- quant_uom = line.product_id.uom_id
- product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
- procurements.append(self.env['procurement.group'].Procurement(
- line.product_id, product_qty, procurement_uom,
- line.order_id.partner_shipping_id.property_stock_customer,
- line.name, line.order_id.name, line.order_id.company_id, values))
- if procurements:
- self.env['procurement.group'].run(procurements)
-
- # This next block is currently needed only because the scheduler trigger is done by picking confirmation rather than stock.move confirmation
- orders = self.mapped('order_id')
- for order in orders:
- pickings_to_confirm = order.picking_ids.filtered(lambda p: p.state not in ['cancel', 'done'])
- if pickings_to_confirm:
- # Trigger the Scheduler for Pickings
- pickings_to_confirm.action_confirm()
- return True
-
- def _update_line_quantity(self, values):
- precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
- line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
- if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
- raise UserError(_('The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
- super(SaleOrderLine, self)._update_line_quantity(values)
diff --git a/addons/sale_stock/models/sale_order_line.py b/addons/sale_stock/models/sale_order_line.py
new file mode 100644
index 00000000000..3ac1fab0e6f
--- /dev/null
+++ b/addons/sale_stock/models/sale_order_line.py
@@ -0,0 +1,350 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import timedelta
+from collections import defaultdict
+
+from odoo import api, fields, models, _
+from odoo.tools import float_compare
+from odoo.exceptions import UserError
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ qty_delivered_method = fields.Selection(selection_add=[('stock_move', 'Stock Moves')])
+ route_id = fields.Many2one('stock.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict', check_company=True)
+ move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves')
+ product_type = fields.Selection(related='product_id.detailed_type')
+ virtual_available_at_date = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
+ scheduled_date = fields.Datetime(compute='_compute_qty_at_date')
+ forecast_expected_date = fields.Datetime(compute='_compute_qty_at_date')
+ free_qty_today = fields.Float(compute='_compute_qty_at_date', digits='Product Unit of Measure')
+ qty_available_today = fields.Float(compute='_compute_qty_at_date')
+ warehouse_id = fields.Many2one(related='order_id.warehouse_id')
+ qty_to_deliver = fields.Float(compute='_compute_qty_to_deliver', digits='Product Unit of Measure')
+ is_mto = fields.Boolean(compute='_compute_is_mto')
+ display_qty_widget = fields.Boolean(compute='_compute_qty_to_deliver')
+ customer_lead = fields.Float(
+ compute='_compute_customer_lead', store=True, readonly=False, precompute=True,
+ inverse='_inverse_customer_lead')
+
+ @api.depends('product_type', 'product_uom_qty', 'qty_delivered', 'state', 'move_ids', 'product_uom')
+ def _compute_qty_to_deliver(self):
+ """Compute the visibility of the inventory widget."""
+ for line in self:
+ line.qty_to_deliver = line.product_uom_qty - line.qty_delivered
+ if line.state in ('draft', 'sent', 'sale') and line.product_type == 'product' and line.product_uom and line.qty_to_deliver > 0:
+ if line.state == 'sale' and not line.move_ids:
+ line.display_qty_widget = False
+ else:
+ line.display_qty_widget = True
+ else:
+ line.display_qty_widget = False
+
+ @api.depends(
+ 'product_id', 'customer_lead', 'product_uom_qty', 'product_uom', 'order_id.commitment_date',
+ 'move_ids', 'move_ids.forecast_expected_date', 'move_ids.forecast_availability')
+ def _compute_qty_at_date(self):
+ """ Compute the quantity forecasted of product at delivery date. There are
+ two cases:
+ 1. The quotation has a commitment_date, we take it as delivery date
+ 2. The quotation hasn't commitment_date, we compute the estimated delivery
+ date based on lead time"""
+ treated = self.browse()
+ # If the state is already in sale the picking is created and a simple forecasted quantity isn't enough
+ # Then used the forecasted data of the related stock.move
+ for line in self.filtered(lambda l: l.state == 'sale'):
+ if not line.display_qty_widget:
+ continue
+ moves = line.move_ids.filtered(lambda m: m.product_id == line.product_id)
+ line.forecast_expected_date = max(moves.filtered("forecast_expected_date").mapped("forecast_expected_date"), default=False)
+ line.qty_available_today = 0
+ line.free_qty_today = 0
+ for move in moves:
+ line.qty_available_today += move.product_uom._compute_quantity(move.reserved_availability, line.product_uom)
+ line.free_qty_today += move.product_id.uom_id._compute_quantity(move.forecast_availability, line.product_uom)
+ line.scheduled_date = line.order_id.commitment_date or line._expected_date()
+ line.virtual_available_at_date = False
+ treated |= line
+
+ qty_processed_per_product = defaultdict(lambda: 0)
+ grouped_lines = defaultdict(lambda: self.env['sale.order.line'])
+ # We first loop over the SO lines to group them by warehouse and schedule
+ # date in order to batch the read of the quantities computed field.
+ for line in self.filtered(lambda l: l.state in ('draft', 'sent')):
+ if not (line.product_id and line.display_qty_widget):
+ continue
+ grouped_lines[(line.warehouse_id.id, line.order_id.commitment_date or line._expected_date())] |= line
+
+ for (warehouse, scheduled_date), lines in grouped_lines.items():
+ product_qties = lines.mapped('product_id').with_context(to_date=scheduled_date, warehouse=warehouse).read([
+ 'qty_available',
+ 'free_qty',
+ 'virtual_available',
+ ])
+ qties_per_product = {
+ product['id']: (product['qty_available'], product['free_qty'], product['virtual_available'])
+ for product in product_qties
+ }
+ for line in lines:
+ line.scheduled_date = scheduled_date
+ qty_available_today, free_qty_today, virtual_available_at_date = qties_per_product[line.product_id.id]
+ line.qty_available_today = qty_available_today - qty_processed_per_product[line.product_id.id]
+ line.free_qty_today = free_qty_today - qty_processed_per_product[line.product_id.id]
+ line.virtual_available_at_date = virtual_available_at_date - qty_processed_per_product[line.product_id.id]
+ line.forecast_expected_date = False
+ product_qty = line.product_uom_qty
+ if line.product_uom and line.product_id.uom_id and line.product_uom != line.product_id.uom_id:
+ line.qty_available_today = line.product_id.uom_id._compute_quantity(line.qty_available_today, line.product_uom)
+ line.free_qty_today = line.product_id.uom_id._compute_quantity(line.free_qty_today, line.product_uom)
+ line.virtual_available_at_date = line.product_id.uom_id._compute_quantity(line.virtual_available_at_date, line.product_uom)
+ product_qty = line.product_uom._compute_quantity(product_qty, line.product_id.uom_id)
+ qty_processed_per_product[line.product_id.id] += product_qty
+ treated |= lines
+ remaining = (self - treated)
+ remaining.virtual_available_at_date = False
+ remaining.scheduled_date = False
+ remaining.forecast_expected_date = False
+ remaining.free_qty_today = False
+ remaining.qty_available_today = False
+
+ @api.depends('product_id', 'route_id', 'order_id.warehouse_id', 'product_id.route_ids')
+ def _compute_is_mto(self):
+ """ Verify the route of the product based on the warehouse
+ set 'is_available' at True if the product availability in stock does
+ not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping
+ """
+ self.is_mto = False
+ for line in self:
+ if not line.display_qty_widget:
+ continue
+ product = line.product_id
+ product_routes = line.route_id or (product.route_ids + product.categ_id.total_route_ids)
+
+ # Check MTO
+ mto_route = line.order_id.warehouse_id.mto_pull_id.route_id
+ if not mto_route:
+ try:
+ mto_route = self.env['stock.warehouse']._find_global_route('stock.route_warehouse0_mto', _('Make To Order'))
+ except UserError:
+ # if route MTO not found in ir_model_data, we treat the product as in MTS
+ pass
+
+ if mto_route and mto_route in product_routes:
+ line.is_mto = True
+ else:
+ line.is_mto = False
+
+ @api.depends('product_id')
+ def _compute_qty_delivered_method(self):
+ """ Stock module compute delivered qty for product [('type', 'in', ['consu', 'product'])]
+ For SO line coming from expense, no picking should be generate: we don't manage stock for
+ those lines, even if the product is a storable.
+ """
+ super(SaleOrderLine, self)._compute_qty_delivered_method()
+
+ for line in self:
+ if not line.is_expense and line.product_id.type in ['consu', 'product']:
+ line.qty_delivered_method = 'stock_move'
+
+ @api.depends('move_ids.state', 'move_ids.scrapped', 'move_ids.product_uom_qty', 'move_ids.product_uom')
+ def _compute_qty_delivered(self):
+ super(SaleOrderLine, self)._compute_qty_delivered()
+
+ for line in self: # TODO: maybe one day, this should be done in SQL for performance sake
+ if line.qty_delivered_method == 'stock_move':
+ qty = 0.0
+ outgoing_moves, incoming_moves = line._get_outgoing_incoming_moves()
+ for move in outgoing_moves:
+ if move.state != 'done':
+ continue
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
+ for move in incoming_moves:
+ if move.state != 'done':
+ continue
+ qty -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom, rounding_method='HALF-UP')
+ line.qty_delivered = qty
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ lines = super(SaleOrderLine, self).create(vals_list)
+ lines.filtered(lambda line: line.state == 'sale')._action_launch_stock_rule()
+ return lines
+
+ def write(self, values):
+ lines = self.env['sale.order.line']
+ if 'product_uom_qty' in values:
+ lines = self.filtered(lambda r: r.state == 'sale' and not r.is_expense)
+
+ previous_product_uom_qty = {line.id: line.product_uom_qty for line in lines}
+ res = super(SaleOrderLine, self).write(values)
+ if lines:
+ lines._action_launch_stock_rule(previous_product_uom_qty)
+ return res
+
+ @api.depends('order_id.state')
+ def _compute_invoice_status(self):
+ def check_moves_state(moves):
+ # All moves states are either 'done' or 'cancel', and there is at least one 'done'
+ at_least_one_done = False
+ for move in moves:
+ if move.state not in ['done', 'cancel']:
+ return False
+ at_least_one_done = at_least_one_done or move.state == 'done'
+ return at_least_one_done
+ super(SaleOrderLine, self)._compute_invoice_status()
+ for line in self:
+ # We handle the following specific situation: a physical product is partially delivered,
+ # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for
+ # products sold by weight, where the delivered quantity rarely matches exactly the
+ # quantity ordered.
+ if line.order_id.state == 'done'\
+ and line.invoice_status == 'no'\
+ and line.product_id.type in ['consu', 'product']\
+ and line.product_id.invoice_policy == 'delivery'\
+ and line.move_ids \
+ and check_moves_state(line.move_ids):
+ line.invoice_status = 'invoiced'
+
+ @api.depends('move_ids')
+ def _compute_product_updatable(self):
+ for line in self:
+ if not line.move_ids.filtered(lambda m: m.state != 'cancel'):
+ super(SaleOrderLine, line)._compute_product_updatable()
+ else:
+ line.product_updatable = False
+
+ @api.depends('product_id')
+ def _compute_customer_lead(self):
+ super()._compute_customer_lead() # Reset customer_lead when the product is modified
+ for line in self:
+ line.customer_lead = line.product_id.sale_delay
+
+ def _inverse_customer_lead(self):
+ for line in self:
+ if line.state == 'sale' and not line.order_id.commitment_date:
+ # Propagate deadline on related stock move
+ line.move_ids.date_deadline = line.order_id.date_order + timedelta(days=line.customer_lead or 0.0)
+
+ def _prepare_procurement_values(self, group_id=False):
+ """ Prepare specific key for moves or other components that will be created from a stock rule
+ coming from a sale order line. This method could be override in order to add other custom key that could
+ be used in move/po creation.
+ """
+ values = super(SaleOrderLine, self)._prepare_procurement_values(group_id)
+ self.ensure_one()
+ # Use the delivery date if there is else use date_order and lead time
+ date_deadline = self.order_id.commitment_date or (self.order_id.date_order + timedelta(days=self.customer_lead or 0.0))
+ date_planned = date_deadline - timedelta(days=self.order_id.company_id.security_lead)
+ values.update({
+ 'group_id': group_id,
+ 'sale_line_id': self.id,
+ 'date_planned': date_planned,
+ 'date_deadline': date_deadline,
+ 'route_ids': self.route_id,
+ 'warehouse_id': self.order_id.warehouse_id or False,
+ 'partner_id': self.order_id.partner_shipping_id.id,
+ 'product_description_variants': self._get_sale_order_line_multiline_description_variants(),
+ 'company_id': self.order_id.company_id,
+ 'product_packaging_id': self.product_packaging_id,
+ })
+ return values
+
+ def _get_qty_procurement(self, previous_product_uom_qty=False):
+ self.ensure_one()
+ qty = 0.0
+ outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves()
+ for move in outgoing_moves:
+ qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
+ for move in incoming_moves:
+ qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP')
+ return qty
+
+ def _get_outgoing_incoming_moves(self):
+ outgoing_moves = self.env['stock.move']
+ incoming_moves = self.env['stock.move']
+
+ moves = self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id)
+ if self._context.get('accrual_entry_date'):
+ moves = moves.filtered(lambda r: fields.Date.to_date(r.date) <= self._context['accrual_entry_date'])
+
+ for move in moves:
+ if move.location_dest_id.usage == "customer":
+ if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund):
+ outgoing_moves |= move
+ elif move.location_dest_id.usage != "customer" and move.to_refund:
+ incoming_moves |= move
+
+ return outgoing_moves, incoming_moves
+
+ def _get_procurement_group(self):
+ return self.order_id.procurement_group_id
+
+ def _prepare_procurement_group_vals(self):
+ return {
+ 'name': self.order_id.name,
+ 'move_type': self.order_id.picking_policy,
+ 'sale_id': self.order_id.id,
+ 'partner_id': self.order_id.partner_shipping_id.id,
+ }
+
+ def _action_launch_stock_rule(self, previous_product_uom_qty=False):
+ """
+ Launch procurement group run method with required/custom fields generated by a
+ sale order line. procurement group will launch '_run_pull', '_run_buy' or '_run_manufacture'
+ depending on the sale order line product rule.
+ """
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ procurements = []
+ for line in self:
+ line = line.with_company(line.company_id)
+ if line.state != 'sale' or not line.product_id.type in ('consu', 'product'):
+ continue
+ qty = line._get_qty_procurement(previous_product_uom_qty)
+ if float_compare(qty, line.product_uom_qty, precision_digits=precision) == 0:
+ continue
+
+ group_id = line._get_procurement_group()
+ if not group_id:
+ group_id = self.env['procurement.group'].create(line._prepare_procurement_group_vals())
+ line.order_id.procurement_group_id = group_id
+ else:
+ # In case the procurement group is already created and the order was
+ # cancelled, we need to update certain values of the group.
+ updated_vals = {}
+ if group_id.partner_id != line.order_id.partner_shipping_id:
+ updated_vals.update({'partner_id': line.order_id.partner_shipping_id.id})
+ if group_id.move_type != line.order_id.picking_policy:
+ updated_vals.update({'move_type': line.order_id.picking_policy})
+ if updated_vals:
+ group_id.write(updated_vals)
+
+ values = line._prepare_procurement_values(group_id=group_id)
+ product_qty = line.product_uom_qty - qty
+
+ line_uom = line.product_uom
+ quant_uom = line.product_id.uom_id
+ product_qty, procurement_uom = line_uom._adjust_uom_quantities(product_qty, quant_uom)
+ procurements.append(self.env['procurement.group'].Procurement(
+ line.product_id, product_qty, procurement_uom,
+ line.order_id.partner_shipping_id.property_stock_customer,
+ line.name, line.order_id.name, line.order_id.company_id, values))
+ if procurements:
+ self.env['procurement.group'].run(procurements)
+
+ # This next block is currently needed only because the scheduler trigger is done by picking confirmation rather than stock.move confirmation
+ orders = self.mapped('order_id')
+ for order in orders:
+ pickings_to_confirm = order.picking_ids.filtered(lambda p: p.state not in ['cancel', 'done'])
+ if pickings_to_confirm:
+ # Trigger the Scheduler for Pickings
+ pickings_to_confirm.action_confirm()
+ return True
+
+ def _update_line_quantity(self, values):
+ precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
+ line_products = self.filtered(lambda l: l.product_id.type in ['product', 'consu'])
+ if line_products.mapped('qty_delivered') and float_compare(values['product_uom_qty'], max(line_products.mapped('qty_delivered')), precision_digits=precision) == -1:
+ raise UserError(_('The ordered quantity cannot be decreased below the amount already delivered. Instead, create a return in your inventory.'))
+ super(SaleOrderLine, self)._update_line_quantity(values)