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)