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 @@
+
+
+
+
+
+
+
+ Purchase products by multiple of unit # per package
+
+
+
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 @@
+