diff --git a/addons/product/models/product.py b/addons/product/models/product.py index 3c2450de3fe..91920e7c00b 100644 --- a/addons/product/models/product.py +++ b/addons/product/models/product.py @@ -9,7 +9,7 @@ from odoo.exceptions import UserError, ValidationError from odoo.osv import expression -from odoo.tools import float_compare +from odoo.tools import float_compare, float_round _logger = logging.getLogger(__name__) @@ -719,17 +719,59 @@ class ProductProduct(models.Model): class ProductPackaging(models.Model): _name = "product.packaging" _description = "Product Packaging" - _order = 'sequence' + _order = 'product_id, sequence, id' _check_company_auto = True - name = fields.Char('Package Type', required=True) + name = fields.Char('Product Packaging', required=True) sequence = fields.Integer('Sequence', default=1, help="The first in the sequence is the default one.") product_id = fields.Many2one('product.product', string='Product', check_company=True) - qty = fields.Float('Contained Quantity', help="Quantity of products contained in the packaging.") + qty = fields.Float('Contained Quantity', default=1, help="Quantity of products contained in the packaging.") barcode = fields.Char('Barcode', copy=False, help="Barcode used for packaging identification. Scan this packaging barcode from a transfer in the Barcode app to move all the contained units") product_uom_id = fields.Many2one('uom.uom', related='product_id.uom_id', readonly=True) company_id = fields.Many2one('res.company', 'Company', index=True) + _sql_constraints = [ + ('positive_qty', 'CHECK(qty > 0)', 'Contained Quantity should be positive.') + ] + + def _check_qty(self, product_qty, uom_id, rounding_method="HALF-UP"): + """Check if product_qty in given uom is a multiple of the packaging qty. + If not, rounding the product_qty to closest multiple of the packaging qty + according to the rounding_method "UP", "HALF-UP or "DOWN". + """ + self.ensure_one() + default_uom = self.product_id.uom_id + packaging_qty = default_uom._compute_quantity(self.qty, uom_id) + # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity + # per package might be a float, leading to incorrect results. For example: + # 8 % 1.6 = 1.5999999999999996 + # 5.4 % 1.8 = 2.220446049250313e-16 + if ( + product_qty + and packaging_qty + and float_compare( + product_qty / packaging_qty, + float_round(product_qty / packaging_qty, precision_rounding=1.0), + precision_rounding=default_uom.rounding + ) + != 0 + ): + return float_round( + product_qty / packaging_qty, precision_rounding=1.0, rounding_method=rounding_method + ) * packaging_qty + return product_qty + + def _find_suitable_product_packaging(self, product_qty, uom_id): + """ try find in `self` if a packaging's qty in given uom is a divisor of + the given product_qty. If so, return the one with greatest divisor. + """ + packagings = self.sorted(lambda p: p.qty, reverse=True) + for packaging in packagings: + new_qty = packaging._check_qty(product_qty, uom_id) + if new_qty == product_qty: + return packaging + return self.env['product.packaging'] + class SupplierInfo(models.Model): _name = "product.supplierinfo" diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml index d88c1858d73..bde301935f6 100644 --- a/addons/product/views/product_views.xml +++ b/addons/product/views/product_views.xml @@ -116,7 +116,7 @@ colspan="4" attrs="{'invisible':['|', ('type', 'not in', ['product', 'consu']), ('product_variant_count', '>', 1), ('is_product_variant', '=', False)]}" groups="product.group_stock_packaging"> - + @@ -268,7 +268,7 @@ + context="{'tree_view_ref':'product.product_packaging_tree_view2'}"/> @@ -501,13 +501,14 @@ product.packaging.tree.view product.packaging - + - + + @@ -519,7 +520,9 @@ - + + bottom + diff --git a/addons/purchase/__manifest__.py b/addons/purchase/__manifest__.py index b9fb5018127..b20e222498a 100644 --- a/addons/purchase/__manifest__.py +++ b/addons/purchase/__manifest__.py @@ -29,6 +29,7 @@ 'views/portal_templates.xml', 'report/purchase_order_templates.xml', 'report/purchase_quotation_templates.xml', + 'views/product_packaging_views.xml', ], 'demo': [ 'data/purchase_demo.xml', diff --git a/addons/purchase/models/product.py b/addons/purchase/models/product.py index 197d4af6d02..59617c2c5e5 100644 --- a/addons/purchase/models/product.py +++ b/addons/purchase/models/product.py @@ -94,3 +94,9 @@ class ProductSupplierinfo(models.Model): @api.onchange('name') def _onchange_name(self): self.currency_id = self.name.property_purchase_currency_id.id or self.env.company.currency_id.id + + +class ProductPackaging(models.Model): + _inherit = 'product.packaging' + + purchase = fields.Boolean("Purchase", default=True, help="If true, the packaging can be used for purchase orders") diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 5d4b78330f9..7b668f3a926 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -12,7 +12,7 @@ from werkzeug.urls import url_encode from odoo import api, fields, models, _ from odoo.osv import expression from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT -from odoo.tools.float_utils import float_is_zero +from odoo.tools.float_utils import float_compare, float_is_zero, float_round from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools.misc import formatLang, get_lang @@ -885,6 +885,8 @@ class PurchaseOrderLine(models.Model): partner_id = fields.Many2one('res.partner', related='order_id.partner_id', string='Partner', readonly=True, store=True) currency_id = fields.Many2one(related='order_id.currency_id', store=True, string='Currency', readonly=True) date_order = fields.Datetime(related='order_id.date_order', string='Order Date', readonly=True) + product_packaging_id = fields.Many2one('product.packaging', string='Packaging', domain="[('purchase', '=', True), ('product_id', '=', product_id)]", check_company=True) + product_packaging_qty = fields.Float('Packaging Quantity') display_type = fields.Selection([ ('line_section', "Section"), @@ -1151,6 +1153,51 @@ class PurchaseOrderLine(models.Model): self.price_unit = price_unit + @api.onchange('product_id', 'product_qty', 'product_uom') + def _onchange_suggest_packaging(self): + # remove packaging if not match the product + if self.product_packaging_id.product_id != self.product_id: + self.product_packaging_id = False + # suggest biggest suitable packaging + if self.product_id and self.product_qty and self.product_uom: + self.product_packaging_id = self.product_id.packaging_ids.filtered('purchase')._find_suitable_product_packaging(self.product_qty, self.product_uom) + + @api.onchange('product_packaging_id') + def _onchange_product_packaging_id(self): + if self.product_packaging_id and self.product_qty: + newqty = self.product_packaging_id._check_qty(self.product_qty, self.product_uom, "UP") + if float_compare(newqty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0: + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should purchase %(quantity).2f %(unit)s.", + pack_size=self.product_packaging_id.qty, + pack_name=self.product_id.uom_id.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + + @api.onchange('product_packaging_id', 'product_uom', 'product_qty') + def _onchange_update_product_packaging_qty(self): + if not self.product_packaging_id: + self.product_packaging_qty = 0 + else: + packaging_uom = self.product_packaging_id.product_uom_id + packaging_uom_qty = self.product_uom._compute_quantity(self.product_qty, packaging_uom) + self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) + + @api.onchange('product_packaging_qty') + def _onchange_product_packaging_qty(self): + if self.product_packaging_id: + packaging_uom = self.product_packaging_id.product_uom_id + qty_per_packaging = self.product_packaging_id.qty + product_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) + if float_compare(product_qty, self.product_qty, precision_rounding=self.product_uom.rounding) != 0: + self.product_qty = product_qty + @api.depends('product_uom', 'product_qty', 'product_id.uom_id') def _compute_product_uom_qty(self): for line in self: diff --git a/addons/purchase/tests/test_purchase.py b/addons/purchase/tests/test_purchase.py index db1c93a2feb..0955326e9db 100644 --- a/addons/purchase/tests/test_purchase.py +++ b/addons/purchase/tests/test_purchase.py @@ -173,3 +173,45 @@ class TestPurchase(AccountTestInvoicingCommon): '

- product_b from 2020-06-06 to %(today)s

' % {'today': fields.Date.today()}, activity.note, ) + + def test_onchange_packaging_00(self): + """Create a PO and use packaging. Check we suggested suitable packaging + according to the product_qty. Also check product_qty or product_packaging + are correctly calculated when one of them changed. + """ + packaging_single = self.env['product.packaging'].create({ + 'name': "I'm a packaging", + 'product_id': self.product_a.id, + 'qty': 1.0, + }) + packaging_dozen = self.env['product.packaging'].create({ + 'name': "I'm also a packaging", + 'product_id': self.product_a.id, + 'qty': 12.0, + }) + + po = self.env['purchase.order'].create({ + 'partner_id': self.partner_a.id, + }) + po_form = Form(po) + with po_form.order_line.new() as line: + line.product_id = self.product_a + line.product_qty = 1.0 + po_form.save() + self.assertEqual(po.order_line.product_packaging_id, packaging_single) + self.assertEqual(po.order_line.product_packaging_qty, 1.0) + with po_form.order_line.edit(0) as line: + line.product_packaging_qty = 2.0 + po_form.save() + self.assertEqual(po.order_line.product_qty, 2.0) + + + with po_form.order_line.edit(0) as line: + line.product_qty = 24.0 + po_form.save() + self.assertEqual(po.order_line.product_packaging_id, packaging_dozen) + self.assertEqual(po.order_line.product_packaging_qty, 2.0) + with po_form.order_line.edit(0) as line: + line.product_packaging_qty = 1.0 + po_form.save() + self.assertEqual(po.order_line.product_qty, 12) diff --git a/addons/purchase/views/product_packaging_views.xml b/addons/purchase/views/product_packaging_views.xml new file mode 100644 index 00000000000..78b1c034d79 --- /dev/null +++ b/addons/purchase/views/product_packaging_views.xml @@ -0,0 +1,24 @@ + + + + product.packaging.form.view.purchase + product.packaging + + + + + + + + + + product.packaging.tree.view.purchase + product.packaging + + + + + + + + diff --git a/addons/purchase/views/purchase_views.xml b/addons/purchase/views/purchase_views.xml index 54757c71a01..af25ab277f0 100644 --- a/addons/purchase/views/purchase_views.xml +++ b/addons/purchase/views/purchase_views.xml @@ -247,6 +247,8 @@ 'required': [('display_type', '=', False)] }" force_save="1" optional="show"/> + + @@ -270,6 +272,7 @@ + diff --git a/addons/purchase/views/res_config_settings_views.xml b/addons/purchase/views/res_config_settings_views.xml index ad88912de3a..1ee0a480cd4 100644 --- a/addons/purchase/views/res_config_settings_views.xml +++ b/addons/purchase/views/res_config_settings_views.xml @@ -147,6 +147,19 @@ +
+
+ +
+
+
+
diff --git a/addons/purchase_stock/models/purchase.py b/addons/purchase_stock/models/purchase.py index 604748c640e..8f4c1d74eb2 100644 --- a/addons/purchase_stock/models/purchase.py +++ b/addons/purchase_stock/models/purchase.py @@ -487,6 +487,7 @@ class PurchaseOrderLine(models.Model): 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, 'product_uom_qty': product_uom_qty, 'product_uom': product_uom.id, + 'product_packaging_id': self.product_packaging_id.id, } @api.model diff --git a/addons/purchase_stock/tests/test_purchase_order_process.py b/addons/purchase_stock/tests/test_purchase_order_process.py index 56f091067e1..a3f35db8cf7 100644 --- a/addons/purchase_stock/tests/test_purchase_order_process.py +++ b/addons/purchase_stock/tests/test_purchase_order_process.py @@ -27,3 +27,30 @@ class TestPurchaseOrderProcess(PurchaseTestCommon): # Check that order is cancelled. self.assertEqual(po_edit_with_user.state, 'cancel', 'Purchase: PO state should be "Cancel') + + def test_01_packaging_propagation(self): + """Create a PO with lines using packaging, check the packaging propagate + to its move. + """ + product = self.env['product.product'].create({ + 'name': 'Product with packaging', + 'type': 'product', + }) + + packaging = self.env['product.packaging'].create({ + 'name': 'box', + 'product_id': product.id, + }) + + po = self.env['purchase.order'].create({ + 'partner_id': self.env['res.partner'].create({'name': 'My Partner'}).id, + 'order_line': [ + (0, 0, { + 'product_id': product.id, + 'product_qty': 1.0, + 'product_uom': product.uom_id.id, + 'product_packaging_id': packaging.id, + })], + }) + po.button_confirm() + self.assertEqual(po.order_line.move_ids.product_packaging_id, packaging) diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py index 80fd97553b1..e6b7f4b00e0 100644 --- a/addons/sale/__manifest__.py +++ b/addons/sale/__manifest__.py @@ -36,6 +36,7 @@ This module contains all the common features of Sales Management and eCommerce. 'views/payment_templates.xml', 'views/payment_views.xml', 'views/product_views.xml', + 'views/product_packaging_views.xml', 'views/utm_campaign_views.xml', 'wizard/sale_order_cancel_views.xml', 'wizard/sale_payment_link_views.xml', diff --git a/addons/sale/models/product_product.py b/addons/sale/models/product_product.py index 3e20daa9f4c..1e0292ddf44 100644 --- a/addons/sale/models/product_product.py +++ b/addons/sale/models/product_product.py @@ -71,3 +71,8 @@ class ProductAttributeCustomValue(models.Model): _sql_constraints = [ ('sol_custom_value_unique', 'unique(custom_product_template_attribute_value_id, sale_order_line_id)', "Only one Custom Value is allowed per Attribute Value per Sales Order Line.") ] + +class ProductPackaging(models.Model): + _inherit = 'product.packaging' + + sales = fields.Boolean("Sales", default=True, help="If true, the packaging can be used for sales orders") diff --git a/addons/sale/models/sale.py b/addons/sale/models/sale.py index dd4c4aab90a..675db4a3fcd 100644 --- a/addons/sale/models/sale.py +++ b/addons/sale/models/sale.py @@ -9,7 +9,7 @@ from odoo import api, fields, models, SUPERUSER_ID, _ from odoo.exceptions import AccessError, UserError, ValidationError from odoo.tools.misc import formatLang, get_lang from odoo.osv import expression -from odoo.tools import float_is_zero, float_compare +from odoo.tools import float_is_zero, float_compare, float_round from werkzeug.urls import url_encode @@ -1351,6 +1351,9 @@ class SaleOrderLine(models.Model): ('line_section', "Section"), ('line_note', "Note")], default=False, help="Technical field for UX purpose.") + product_packaging_id = fields.Many2one('product.packaging', string='Packaging', default=False, domain="[('sales', '=', True), ('product_id','=',product_id)]", check_company=True) + product_packaging_qty = fields.Float('Packaging Quantity') + @api.depends('state') def _compute_product_uom_readonly(self): for line in self: @@ -1441,6 +1444,51 @@ class SaleOrderLine(models.Model): else: line.qty_delivered_manual = 0.0 + @api.onchange('product_id', 'product_uom_qty', 'product_uom') + def _onchange_suggest_packaging(self): + # remove packaging if not match the product + if self.product_packaging_id.product_id != self.product_id: + self.product_packaging_id = False + # suggest biggest suitable packaging + if self.product_id and self.product_uom_qty and self.product_uom: + self.product_packaging_id = self.product_id.packaging_ids.filtered('sales')._find_suitable_product_packaging(self.product_uom_qty, self.product_uom) + + @api.onchange('product_packaging_id') + def _onchange_product_packaging_id(self): + if self.product_packaging_id and self.product_uom_qty: + newqty = self.product_packaging_id._check_qty(self.product_uom_qty, self.product_uom, "UP") + if float_compare(newqty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + return { + 'warning': { + 'title': _('Warning'), + 'message': _( + "This product is packaged by %(pack_size).2f %(pack_name)s. You should sell %(quantity).2f %(unit)s.", + pack_size=self.product_packaging_id.qty, + pack_name=self.product_id.uom_id.name, + quantity=newqty, + unit=self.product_uom.name + ), + }, + } + + @api.onchange('product_packaging_id', 'product_uom', 'product_uom_qty') + def _onchange_update_product_packaging_qty(self): + if not self.product_packaging_id: + self.product_packaging_qty = False + else: + packaging_uom = self.product_packaging_id.product_uom_id + packaging_uom_qty = self.product_uom._compute_quantity(self.product_uom_qty, packaging_uom) + self.product_packaging_qty = float_round(packaging_uom_qty / self.product_packaging_id.qty, precision_rounding=packaging_uom.rounding) + + @api.onchange('product_packaging_qty') + def _onchange_product_packaging_qty(self): + if self.product_packaging_id: + packaging_uom = self.product_packaging_id.product_uom_id + qty_per_packaging = self.product_packaging_id.qty + product_uom_qty = packaging_uom._compute_quantity(self.product_packaging_qty * qty_per_packaging, self.product_uom) + if float_compare(product_uom_qty, self.product_uom_qty, precision_rounding=self.product_uom.rounding) != 0: + self.product_uom_qty = product_uom_qty + @api.depends('invoice_lines', 'invoice_lines.price_total', 'invoice_lines.move_id.state', 'invoice_lines.move_id.move_type') def _compute_untaxed_amount_invoiced(self): """ Compute the untaxed amount already invoiced from the sale order line, taking the refund attached diff --git a/addons/sale/tests/test_sale_order.py b/addons/sale/tests/test_sale_order.py index 70be53ea68b..4f7ce2f4fd4 100644 --- a/addons/sale/tests/test_sale_order.py +++ b/addons/sale/tests/test_sale_order.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo.exceptions import UserError, AccessError -from odoo.tests import tagged +from odoo.tests import tagged, Form from odoo.tools import float_compare from .common import TestSaleCommon @@ -543,3 +543,48 @@ class TestSaleOrder(TestSaleCommon): sale_order.user_id = self.user_not_in_team sale_order.onchange_user_id() self.assertEqual(sale_order.team_id.id, self.crm_team1.id, 'Should not reset the team to default') + + def test_onchange_packaging_00(self): + """Create a SO and use packaging. Check we suggested suitable packaging + according to the product_qty. Also check product_qty or product_packaging + are correctly calculated when one of them changed. + """ + partner = self.env['res.partner'].create({'name': "I'm a partner"}) + product_tmpl = self.env['product.template'].create({'name': "I'm a product"}) + product = product_tmpl.product_variant_id + packaging_single = self.env['product.packaging'].create({ + 'name': "I'm a packaging", + 'product_id': product.id, + 'qty': 1.0, + }) + packaging_dozen = self.env['product.packaging'].create({ + 'name': "I'm also a packaging", + 'product_id': product.id, + 'qty': 12.0, + }) + + so = self.env['sale.order'].create({ + 'partner_id': partner.id, + }) + so_form = Form(so) + with so_form.order_line.new() as line: + line.product_id = product + line.product_uom_qty = 1.0 + so_form.save() + self.assertEqual(so.order_line.product_packaging_id, packaging_single) + self.assertEqual(so.order_line.product_packaging_qty, 1.0) + with so_form.order_line.edit(0) as line: + line.product_packaging_qty = 2.0 + so_form.save() + self.assertEqual(so.order_line.product_uom_qty, 2.0) + + + with so_form.order_line.edit(0) as line: + line.product_uom_qty = 24.0 + so_form.save() + self.assertEqual(so.order_line.product_packaging_id, packaging_dozen) + self.assertEqual(so.order_line.product_packaging_qty, 2.0) + with so_form.order_line.edit(0) as line: + line.product_packaging_qty = 1.0 + so_form.save() + self.assertEqual(so.order_line.product_uom_qty, 12) diff --git a/addons/sale/views/product_packaging_views.xml b/addons/sale/views/product_packaging_views.xml new file mode 100644 index 00000000000..23b395c90e7 --- /dev/null +++ b/addons/sale/views/product_packaging_views.xml @@ -0,0 +1,24 @@ + + + + product.packaging.form.view.sale + product.packaging + + + + + + + + + + product.packaging.tree.view.sale + product.packaging + + + + + + + + diff --git a/addons/sale/views/sale_views.xml b/addons/sale/views/sale_views.xml index 48c5398873b..8b6a2f7e11b 100644 --- a/addons/sale/views/sale_views.xml +++ b/addons/sale/views/sale_views.xml @@ -407,6 +407,7 @@
+ @@ -555,6 +556,8 @@ optional="hide" attrs="{'readonly': [('parent.state', 'not in', ['draft', 'sent', 'sale'])]}" /> + + - - - - - - diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py index 02c20298884..906df19c141 100644 --- a/addons/stock/models/product.py +++ b/addons/stock/models/product.py @@ -934,6 +934,11 @@ class ProductCategory(models.Model): 'stock.location.route', string='Total routes', compute='_compute_total_route_ids', readonly=True) putaway_rule_ids = fields.One2many('stock.putaway.rule', 'category_id', 'Putaway Rules') + packaging_reserve_method = fields.Selection([ + ('full', 'Reserve Only Full Packagings'), + ('partial', 'Reserve Partial Packagings'),], string="Reserve Packagings", default='partial', + help="Reserve Only Full Packagings: will not reserve partial packagings. If customer orders 2 pallets of 1000 units each and you only have 1600 in stock, then only 1000 will be reserved\n" + "Reserve Partial Packagings: allow reserving partial packagings. If customer orders 2 pallets of 1000 units each and you only have 1600 in stock, then 1600 will be reserved") def _compute_total_route_ids(self): for category in self: @@ -944,6 +949,11 @@ class ProductCategory(models.Model): routes |= base_cat.route_ids category.total_route_ids = routes +class ProductPackaging(models.Model): + _inherit = "product.packaging" + + package_type_id = fields.Many2one('stock.package.type', 'Package Type') + class UoM(models.Model): _inherit = 'uom.uom' diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py index 1fedcc7c1ba..bd5028de1f7 100644 --- a/addons/stock/models/stock_move.py +++ b/addons/stock/models/stock_move.py @@ -175,6 +175,7 @@ class StockMove(models.Model): lot_ids = fields.Many2many('stock.production.lot', compute='_compute_lot_ids', inverse='_set_lot_ids', string='Serial Numbers', readonly=False) reservation_date = fields.Date('Date to Reserve', compute='_compute_reservation_date', store=True, help="This is a technical field for calculating when a move should be reserved") + product_packaging_id = fields.Many2one('product.packaging', 'Packaging', domain="[('product_id', '=', product_id)]", check_company=True) @api.depends('has_tracking', 'picking_type_id.use_create_lots', 'picking_type_id.use_existing_lots', 'state') def _compute_display_assign_serial(self): @@ -781,7 +782,8 @@ class StockMove(models.Model): return [ 'product_id', 'price_unit', 'procure_method', 'location_id', 'location_dest_id', 'product_uom', 'restrict_partner_id', 'scrapped', 'origin_returned_move_id', - 'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline' + 'package_level_id', 'propagate_cancel', 'description_picking', 'date_deadline', + 'product_packaging_id', ] @api.model @@ -896,6 +898,15 @@ class StockMove(models.Model): if product: self.description_picking = product._get_description(self.picking_type_id) + @api.onchange('product_id', 'product_qty', 'product_uom') + def _onchange_suggest_packaging(self): + # remove packaging if not match the product + if self.product_packaging_id.product_id != self.product_id: + self.product_packaging_id = False + # suggest biggest suitable packaging + if self.product_id and self.product_qty and self.product_uom: + self.product_packaging_id = self.product_id.packaging_ids._find_suitable_product_packaging(self.product_qty, self.product_uom) + @api.onchange('lot_ids') def _onchange_lot_ids(self): quantity_done = sum(ml.product_uom_id._compute_quantity(ml.qty_done, self.product_uom) for ml in self.move_line_ids.filtered(lambda ml: not ml.lot_id and ml.lot_name)) @@ -1242,6 +1253,10 @@ class StockMove(models.Model): if not owner_id: owner_id = self.env['res.partner'] + # do full packaging reservation when it's needed + if self.product_packaging_id and self.product_id.product_tmpl_id.categ_id.packaging_reserve_method == "full": + available_quantity = self.product_packaging_id._check_qty(available_quantity, self.product_id.uom_id, "DOWN") + taken_quantity = min(available_quantity, need) # `taken_quantity` is in the quants unit of measure. There's a possibility that the move's diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index f25050e5d5b..b6e4567f69a 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -313,6 +313,7 @@ class StockRule(models.Model): 'description_picking': picking_description, 'priority': values.get('priority', "0"), 'orderpoint_id': values.get('orderpoint_id') and values['orderpoint_id'].id, + 'product_packaging_id': values.get('product_packaging_id', False), } for field in self._get_custom_move_fields(): if field in values: diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml index e9905531f1f..716f5da4056 100644 --- a/addons/stock/views/product_views.xml +++ b/addons/stock/views/product_views.xml @@ -18,6 +18,7 @@ + @@ -159,6 +160,28 @@ + + product.packaging.tree.view.stock + product.packaging + + + + + + + + + + product.packaging.form.view.stock + product.packaging + + + + + + + + diff --git a/addons/stock/views/stock_move_views.xml b/addons/stock/views/stock_move_views.xml index 138b7f3e913..ba74279d4f9 100644 --- a/addons/stock/views/stock_move_views.xml +++ b/addons/stock/views/stock_move_views.xml @@ -37,6 +37,7 @@ + diff --git a/addons/stock/views/stock_picking_views.xml b/addons/stock/views/stock_picking_views.xml index 470534e8fdb..ad139d4555f 100644 --- a/addons/stock/views/stock_picking_views.xml +++ b/addons/stock/views/stock_picking_views.xml @@ -362,6 +362,7 @@ +