diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py
index 80abe268785..3a34ca5eaa4 100644
--- a/addons/mrp/models/mrp_workorder.py
+++ b/addons/mrp/models/mrp_workorder.py
@@ -766,7 +766,8 @@ class MrpWorkorder(models.Model):
move_line.product_uom_qty += self.qty_producing
move_line.qty_done += self.qty_producing
else:
- location_dest_id = production_move.location_dest_id._get_putaway_strategy(self.product_id).id or production_move.location_dest_id.id
+ quantity = self.product_uom_id._compute_quantity(self.qty_producing, self.product_id.uom_id, rounding_method='HALF-UP')
+ putaway_location = production_move.location_dest_id._get_putaway_strategy(self.product_id, quantity)
move_line.create({
'move_id': production_move.id,
'product_id': production_move.product_id.id,
@@ -775,7 +776,7 @@ class MrpWorkorder(models.Model):
'product_uom_id': self.product_uom_id.id,
'qty_done': self.qty_producing,
'location_id': production_move.location_id.id,
- 'location_dest_id': location_dest_id,
+ 'location_dest_id': putaway_location.id,
})
else:
rounding = production_move.product_uom.rounding
diff --git a/addons/sale_stock/security/ir.model.access.csv b/addons/sale_stock/security/ir.model.access.csv
index 7f4be5c95a2..e1f3be9b246 100644
--- a/addons/sale_stock/security/ir.model.access.csv
+++ b/addons/sale_stock/security/ir.model.access.csv
@@ -16,3 +16,4 @@ access_account_journal,account.journal,account.model_account_journal,stock.group
access_stock_location_sale_manager,stock.location sale manager,stock.model_stock_location,sales_team.group_sale_manager,1,0,0,0
access_stock_rule_salemanager,stock_rule salemanager,stock.model_stock_rule,sales_team.group_sale_manager,1,1,1,1
access_stock_rule,stock.rule.flow,stock.model_stock_rule,sales_team.group_sale_salesman,1,0,0,0
+access_stock_package_type_salesman,stock_package_type salesman,stock.model_stock_package_type,sales_team.group_sale_salesman,1,0,0,0
diff --git a/addons/stock/__manifest__.py b/addons/stock/__manifest__.py
index d43c940a88b..f0accd06852 100644
--- a/addons/stock/__manifest__.py
+++ b/addons/stock/__manifest__.py
@@ -15,6 +15,7 @@
'data/stock_demo.xml',
'data/stock_demo2.xml',
'data/stock_orderpoint_demo.xml',
+ 'data/stock_storage_category_demo.xml',
],
'data': [
'security/stock_security.xml',
@@ -71,6 +72,7 @@
'views/stock_move_line_views.xml',
'views/stock_move_views.xml',
'views/stock_orderpoint_views.xml',
+ 'views/stock_storage_category_views.xml',
'views/res_config_settings_views.xml',
'views/report_stock_traceability.xml',
'views/stock_template.xml',
diff --git a/addons/stock/data/stock_storage_category_demo.xml b/addons/stock/data/stock_storage_category_demo.xml
new file mode 100644
index 00000000000..037ce3f652a
--- /dev/null
+++ b/addons/stock/data/stock_storage_category_demo.xml
@@ -0,0 +1,29 @@
+
+
+
+
+ High frequency - Small
+ 200
+
+
+ High frequency - Big
+ 3000
+
+
+ Medium frequency - Small
+ 200
+
+
+ Medium frequency - Big
+ 3000
+
+
+ Low frequency - Small
+ 200
+
+
+ Low frequency - Big
+ 3000
+
+
+
diff --git a/addons/stock/models/__init__.py b/addons/stock/models/__init__.py
index e35e5539dd5..214903e9f7d 100644
--- a/addons/stock/models/__init__.py
+++ b/addons/stock/models/__init__.py
@@ -20,3 +20,4 @@ from . import stock_scrap
from . import product
from . import stock_package_level
from . import stock_package_type
+from . import stock_storage_category
diff --git a/addons/stock/models/product.py b/addons/stock/models/product.py
index 6f60a5cceab..0aa7c82c97b 100644
--- a/addons/stock/models/product.py
+++ b/addons/stock/models/product.py
@@ -96,6 +96,7 @@ class Product(models.Model):
reordering_max_qty = fields.Float(
compute='_compute_nbr_reordering_rules', compute_sudo=False)
putaway_rule_ids = fields.One2many('stock.putaway.rule', 'product_id', 'Putaway Rules')
+ storage_category_capacity_ids = fields.One2many('stock.storage.category.capacity', 'product_id', 'Storage Category Capacity')
@api.depends('stock_move_ids.product_qty', 'stock_move_ids.state')
@api.depends_context(
@@ -486,6 +487,18 @@ class Product(models.Model):
]
return self.env['product.template']._get_action_view_related_putaway_rules(domain)
+ def action_view_storage_category_capacity(self):
+ action = self.env["ir.actions.actions"]._for_xml_id("stock.action_storage_category_capacity")
+ action['context'] = {
+ 'hide_package_type': True,
+ }
+ if len(self) == 1:
+ action['context'].update({
+ 'default_product_id': self.id,
+ })
+ action['domain'] = [('product_id', 'in', self.ids)]
+ return action
+
def action_open_product_lot(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("stock.action_production_lot_form")
@@ -811,6 +824,22 @@ class ProductTemplate(models.Model):
raise UserError(_("Available quantity should be set to zero before changing type"))
return super(ProductTemplate, self).write(vals)
+ def copy(self, default=None):
+ res = super().copy(default=default)
+ # Since we don't copy product variants directly, we need to match the newly
+ # created product variants with the old one, and copy the storage category
+ # capacity from them.
+ new_product_dict = {}
+ for product in res.product_variant_ids:
+ product_attribute_value = product.product_template_attribute_value_ids.product_attribute_value_id
+ new_product_dict[product_attribute_value] = product.id
+ storage_category_capacity_vals = []
+ for storage_category_capacity in self.product_variant_ids.storage_category_capacity_ids:
+ product_attribute_value = storage_category_capacity.product_id.product_template_attribute_value_ids.product_attribute_value_id
+ storage_category_capacity_vals.append(storage_category_capacity.copy_data({'product_id': new_product_dict[product_attribute_value]})[0])
+ self.env['stock.storage.category.capacity'].create(storage_category_capacity_vals)
+ return res
+
# Be aware that the exact same function exists in product.product
def action_open_quants(self):
return self.with_context(active_test=False).product_variant_ids.filtered(lambda p: p.active or p.qty_available != 0).action_open_quants()
@@ -843,6 +872,10 @@ class ProductTemplate(models.Model):
]
return self._get_action_view_related_putaway_rules(domain)
+ def action_view_storage_category_capacity(self):
+ self.ensure_one()
+ return self.product_variant_ids.action_view_storage_category_capacity()
+
def action_view_orderpoints(self):
return self.product_variant_ids.action_view_orderpoints()
diff --git a/addons/stock/models/product_strategy.py b/addons/stock/models/product_strategy.py
index 5606fb9f47d..66817f0404e 100644
--- a/addons/stock/models/product_strategy.py
+++ b/addons/stock/models/product_strategy.py
@@ -3,6 +3,7 @@
from odoo import _, api, fields, models
from odoo.exceptions import UserError
+from odoo.tools.float_utils import float_compare
class RemovalStrategy(models.Model):
@@ -59,15 +60,18 @@ class StockPutawayRule(models.Model):
location_in_id = fields.Many2one(
'stock.location', 'When product arrives in', check_company=True,
domain="[('child_ids', '!=', False), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
- default=_default_location_id, required=True, ondelete='cascade')
+ default=_default_location_id, required=True, ondelete='cascade', index=True)
location_out_id = fields.Many2one(
'stock.location', 'Store to', check_company=True,
- domain="[('id', 'child_of', location_in_id), ('id', '!=', location_in_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
+ domain="[('id', 'child_of', location_in_id), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
required=True, ondelete='cascade')
sequence = fields.Integer('Priority', help="Give to the more specialized category, a higher priority to have them in top of the list.")
company_id = fields.Many2one(
'res.company', 'Company', required=True,
default=lambda s: s.env.company.id, index=True)
+ package_type_ids = fields.Many2many('stock.package.type', string='Package Type', check_company=True)
+ storage_category_id = fields.Many2one('stock.storage.category', 'Storage Category', ondelete='cascade', check_company=True)
+ active = fields.Boolean('Active', default=True)
@api.onchange('location_in_id')
def _onchange_location_in(self):
@@ -86,3 +90,44 @@ class StockPutawayRule(models.Model):
if rule.company_id.id != vals['company_id']:
raise UserError(_("Changing the company of this record is forbidden at this point, you should rather archive it and create a new one."))
return super(StockPutawayRule, self).write(vals)
+
+ def _get_putaway_location(self, product, quantity=0, package=None, qty_by_location=None):
+ package_type = package and package.package_type_id or None
+
+ checked_locations = set()
+ for putaway_rule in self:
+ location_out = putaway_rule.location_out_id
+
+ if not putaway_rule.storage_category_id:
+ if location_out in checked_locations:
+ continue
+ if location_out._check_can_be_used(product, quantity, package, qty_by_location[location_out.id]):
+ return location_out
+ continue
+
+ child_locations = self.env['stock.location'].search([('id', 'child_of', location_out.id), ('usage', '=', 'internal')])
+ # check if already have the product/package type stored
+ for location in child_locations:
+ if location in checked_locations:
+ continue
+ if package_type:
+ if location.quant_ids.filtered(lambda q: q.product_id == product and q.package_id and q.package_id.package_type_id == package_type):
+ if location._check_can_be_used(product, package=package, location_qty=qty_by_location[location.id]):
+ return location
+ else:
+ checked_locations.add(location)
+ elif float_compare(qty_by_location[location.id], 0, precision_rounding=product.uom_id.rounding) > 0:
+ if location._check_can_be_used(product, quantity, location_qty=qty_by_location[location.id]):
+ return location
+ else:
+ checked_locations.add(location)
+
+ # check locations with matched storage category
+ for location in child_locations.filtered(lambda l: l.storage_category_id == putaway_rule.storage_category_id):
+ if location in checked_locations:
+ continue
+ if location._check_can_be_used(product, quantity, package, qty_by_location[location.id]):
+ return location
+ checked_locations.add(location)
+
+ return None
diff --git a/addons/stock/models/res_config_settings.py b/addons/stock/models/res_config_settings.py
index edb4ac782b5..014fe3875eb 100644
--- a/addons/stock/models/res_config_settings.py
+++ b/addons/stock/models/res_config_settings.py
@@ -39,11 +39,13 @@ class ResConfigSettings(models.TransientModel):
module_quality_control_worksheet = fields.Boolean("Quality Worksheet")
group_stock_multi_locations = fields.Boolean('Storage Locations', implied_group='stock.group_stock_multi_locations',
help="Store products in specific locations of your warehouse (e.g. bins, racks) and to track inventory accordingly.")
+ group_stock_storage_categories = fields.Boolean('Storage Categories', implied_group='stock.group_stock_storage_categories')
@api.onchange('group_stock_multi_locations')
def _onchange_group_stock_multi_locations(self):
if not self.group_stock_multi_locations:
self.group_stock_adv_location = False
+ self.group_stock_storage_categories = False
@api.onchange('group_stock_production_lot')
def _onchange_group_stock_production_lot(self):
@@ -62,6 +64,20 @@ class ResConfigSettings(models.TransientModel):
if not self.group_stock_multi_locations and location_grp in base_user.implied_ids and warehouse_grp in base_user.implied_ids:
raise UserError(_("You can't desactivate the multi-location if you have more than once warehouse by company"))
+ # Deactivate putaway rules with storage category when not in storage category
+ # group. Otherwise, active them.
+ storage_cate_grp = self.env.ref('stock.group_stock_storage_categories')
+ PutawayRule = self.env['stock.putaway.rule']
+ if self.group_stock_storage_categories and storage_cate_grp not in base_user.implied_ids:
+ putaway_rules = PutawayRule.search([
+ ('active', '=', False),
+ ('storage_category_id', '!=', False)
+ ])
+ putaway_rules.write({'active': True})
+ elif not self.group_stock_storage_categories and storage_cate_grp in base_user.implied_ids:
+ putaway_rules = PutawayRule.search([('storage_category_id', '!=', False)])
+ putaway_rules.write({'active': False})
+
res = super(ResConfigSettings, self).set_values()
if not self.user_has_groups('stock.group_stock_manager'):
@@ -89,4 +105,5 @@ class ResConfigSettings(models.TransientModel):
('show_operations', '=', False)
])
picking_types.sudo().write({'show_operations': True})
+
return res
diff --git a/addons/stock/models/stock_location.py b/addons/stock/models/stock_location.py
index 48e30e07d8c..54a890b7946 100644
--- a/addons/stock/models/stock_location.py
+++ b/addons/stock/models/stock_location.py
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-
+from collections import defaultdict, OrderedDict
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
-from collections import OrderedDict
+from odoo.tools.float_utils import float_compare
class Location(models.Model):
@@ -68,10 +68,33 @@ class Location(models.Model):
next_inventory_date = fields.Date("Next Expected Inventory", compute="_compute_next_inventory_date", store=True, help="Date for next planned inventory based on cyclic schedule.")
warehouse_view_ids = fields.One2many('stock.warehouse', 'view_location_id', readonly=True)
warehouse_id = fields.Many2one('stock.warehouse', compute='_compute_warehouse_id')
+ storage_category_id = fields.Many2one('stock.storage.category', string='Storage Category')
+ outgoing_move_line_ids = fields.One2many('stock.move.line', 'location_id', help='Technical: used to compute weight.')
+ incoming_move_line_ids = fields.One2many('stock.move.line', 'location_dest_id', help='Technical: used to compute weight.')
+ net_weight = fields.Float('Net Weight', compute="_compute_weight")
+ forecast_weight = fields.Float('Forecasted Weight', compute="_compute_weight")
_sql_constraints = [('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !'),
('inventory_freq_nonneg', 'check(cyclic_inventory_frequency >= 0)', 'The inventory frequency (days) for a location must be non-negative')]
+ @api.depends('outgoing_move_line_ids.product_qty', 'incoming_move_line_ids.product_qty',
+ 'outgoing_move_line_ids.state', 'incoming_move_line_ids.state',
+ 'outgoing_move_line_ids.product_id.weight', 'outgoing_move_line_ids.product_id.weight',
+ 'quant_ids.quantity', 'quant_ids.product_id.weight')
+ def _compute_weight(self):
+ for location in self:
+ location.net_weight = 0
+ quants = location.quant_ids.filtered(lambda q: q.product_id.type != 'service')
+ incoming_move_lines = location.incoming_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel'])
+ outgoing_move_lines = location.outgoing_move_line_ids.filtered(lambda ml: ml.product_id.type != 'service' and ml.state not in ['draft', 'done', 'cancel'])
+ for quant in quants:
+ location.net_weight += quant.product_id.weight * quant.quantity
+ location.forecast_weight = location.net_weight
+ for line in incoming_move_lines:
+ location.forecast_weight += line.product_id.weight * line.product_qty
+ for line in outgoing_move_lines:
+ location.forecast_weight -= line.product_id.weight * line.product_qty
+
@api.depends('name', 'location_id.complete_name')
def _compute_complete_name(self):
for location in self:
@@ -171,28 +194,98 @@ class Location(models.Model):
domain = ['|', ('barcode', operator, name), ('complete_name', operator, name)]
return self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
- def _get_putaway_strategy(self, product):
- ''' Returns the location where the product has to be put, if any compliant putaway strategy is found. Otherwise returns None.'''
- putaway_location = self.env['stock.location']
- # Looking for a putaway about the product.
- putaway_rules = self.putaway_rule_ids.filtered(lambda x: x.product_id == product)
- if putaway_rules:
- putaway_location = putaway_rules[0].location_out_id
- # If not product putaway found, we're looking with category so.
- else:
- categ = product.categ_id
- while categ:
- putaway_rules = self.putaway_rule_ids.filtered(lambda x: x.category_id == categ)
- if putaway_rules:
- putaway_location = putaway_rules[0].location_out_id
- break
- categ = categ.parent_id
- return putaway_location
+ def _get_putaway_strategy(self, product, quantity=0, package=None):
+ """Returns the location where the product has to be put, if any compliant
+ putaway strategy is found. Otherwise returns self.
+ The quantity should be in the default UOM of the product, it is used when
+ no package is specified.
+ """
+ package_type = package and package.package_type_id or self.env['stock.package.type']
+
+ putaway_rules = self.env['stock.putaway.rule']
+ putaway_rules |= self.putaway_rule_ids.filtered(lambda x: x.product_id == product and (package_type in x.package_type_ids or package_type == x.package_type_ids))
+ categ = product.categ_id
+ while categ:
+ putaway_rules |= self.putaway_rule_ids.filtered(lambda x: x.category_id == categ and (package_type in x.package_type_ids or package_type == x.package_type_ids))
+ categ = categ.parent_id
+ if package_type:
+ putaway_rules |= self.putaway_rule_ids.filtered(lambda x: not x.product_id and (package_type in x.package_type_ids or package_type == x.package_type_ids))
+
+ # get current product qty (qty in current quants and future qty on assigned ml) of all child locations
+ qty_by_location = defaultdict(lambda: 0)
+ locations = self.env['stock.location'].search([('id', 'child_of', self.id), ('usage', '=', 'internal')])
+ if locations.storage_category_id:
+ move_line_data = self.env['stock.move.line'].read_group([
+ ('product_id', '=', product.id),
+ ('location_dest_id', 'in', locations.ids),
+ ('state', 'not in', ['draft', 'done', 'cancel'])
+ ], ['location_dest_id', 'product_id', 'product_qty:sum'], ['location_dest_id'])
+ quant_data = self.env['stock.quant'].read_group([
+ ('product_id', '=', product.id),
+ ('location_id', 'in', locations.ids),
+ ], ['location_id', 'product_id', 'quantity:sum'], ['location_id'])
+
+ for values in move_line_data:
+ qty_by_location[values['location_dest_id'][0]] = values['product_qty']
+ for values in quant_data:
+ qty_by_location[values['location_id'][0]] += values['quantity']
+
+ return putaway_rules._get_putaway_location(product, quantity, package, qty_by_location) or self
def should_bypass_reservation(self):
self.ensure_one()
return self.usage in ('supplier', 'customer', 'inventory', 'production') or self.scrap_location or (self.usage == 'transit' and not self.company_id)
+ def _check_can_be_used(self, product, quantity=0, package=None, location_qty=0):
+ """Check if product/package can be stored in the location. Quantity
+ should in the default uom of product, it's only used when no package is
+ specified."""
+ self.ensure_one()
+ if package and package.package_type_id:
+ return self._check_package_storage(product, package)
+ return self._check_product_storage(product, quantity, location_qty)
+
+ def _check_product_storage(self, product, quantity, location_qty):
+ """Check if a number of product can be stored in the location. Quantity
+ should in the default uom of product."""
+ self.ensure_one()
+ if self.storage_category_id:
+ # check weight
+ if self.storage_category_id.max_weight < self.forecast_weight + product.weight * quantity:
+ return False
+ # check if only allow new product when empty
+ if self.storage_category_id.allow_new_product == "empty" and any(float_compare(q.quantity, 0, precision_rounding=q.product_id.uom_id.rounding) > 0 for q in self.quant_ids):
+ return False
+ # check if only allow same product
+ if self.storage_category_id.allow_new_product == "same" and self.quant_ids and self.quant_ids.product_id != product:
+ return False
+ # check if enough space
+ product_capacity = self.storage_category_id.product_capacity_ids.filtered(lambda pc: pc.product_id == product)
+ if product_capacity and quantity + location_qty > product_capacity.quantity:
+ return False
+ return True
+
+ def _check_package_storage(self, product, package):
+ """Check if the given package can be stored in the location."""
+ self.ensure_one()
+ if self.storage_category_id:
+ # check weight
+ if self.storage_category_id.max_weight < self.forecast_weight + package.package_type_id.max_weight:
+ return False
+ # check if only allow new product when empty
+ if self.storage_category_id.allow_new_product == "empty" and any(float_compare(q.quantity, 0, precision_rounding=q.product_id.uom_id.rounding) > 0 for q in self.quant_ids):
+ return False
+ # check if only allow same product
+ if self.storage_category_id.allow_new_product == "same" and self.quant_ids and self.quant_ids.product_id != product:
+ return False
+ # check if enough space
+ package_capacity = self.storage_category_id.package_capacity_ids.filtered(lambda pc: pc.package_type_id == package.package_type_id)
+ if package_capacity:
+ package_number = len(self.quant_ids.package_id.filtered(lambda q: q.package_type_id == package.package_type_id))
+ if package_number >= package_capacity.quantity:
+ return False
+ return True
+
class Route(models.Model):
_name = 'stock.location.route'
diff --git a/addons/stock/models/stock_move.py b/addons/stock/models/stock_move.py
index 2cb41b1b54e..577c64db47a 100644
--- a/addons/stock/models/stock_move.py
+++ b/addons/stock/models/stock_move.py
@@ -1045,10 +1045,10 @@ class StockMove(models.Model):
if origin_move_line:
location_dest = origin_move_line.location_dest_id
else:
- location_dest = self.location_dest_id._get_putaway_strategy(self.product_id)
+ location_dest = self.location_dest_id._get_putaway_strategy(self.product_id, quantity=1)
move_line_vals = {
'picking_id': self.picking_id.id,
- 'location_dest_id': location_dest.id or self.location_dest_id.id,
+ 'location_dest_id': location_dest.id,
'location_id': self.location_id.id,
'product_id': self.product_id.id,
'product_uom_id': self.product_id.uom_id.id,
@@ -1201,7 +1201,7 @@ class StockMove(models.Model):
def _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
self.ensure_one()
# apply putaway
- location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id).id or self.location_dest_id.id
+ location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id, quantity=quantity or 0).id
vals = {
'move_id': self.id,
'product_id': self.product_id.id,
diff --git a/addons/stock/models/stock_move_line.py b/addons/stock/models/stock_move_line.py
index 66b33dde434..89efc4582f8 100644
--- a/addons/stock/models/stock_move_line.py
+++ b/addons/stock/models/stock_move_line.py
@@ -112,11 +112,18 @@ class StockMoveLine(models.Model):
if any(ml.qty_done < 0 for ml in self):
raise ValidationError(_('You can not enter negative quantities.'))
+ @api.onchange('result_package_id')
+ def _onchange_result_package_id(self):
+ if self.result_package_id:
+ if not self.id and self.user_has_groups('stock.group_stock_multi_locations'):
+ self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id, package=self.result_package_id)
+
@api.onchange('product_id', 'product_uom_id')
def _onchange_product_id(self):
if self.product_id:
- if not self.id and self.user_has_groups('stock.group_stock_multi_locations'):
- self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id) or self.location_dest_id
+ if not self.id and self.user_has_groups('stock.group_stock_multi_locations') and not self.result_package_id:
+ qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id)
+ self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id, qty_done)
if self.picking_id:
product = self.product_id.with_context(lang=self.picking_id.partner_id.lang or self.env.user.lang)
self.description_picking = product._get_description(self.picking_id.picking_type_id)
@@ -177,11 +184,15 @@ class StockMoveLine(models.Model):
help him. This onchange will warn him if he set `qty_done` to a non-supported value.
"""
res = {}
- if self.qty_done and self.product_id.tracking == 'serial':
+ if self.qty_done:
qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id)
- if float_compare(qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0:
- message = _('You can only process 1.0 %s of products with unique serial number.', self.product_id.uom_id.name)
- res['warning'] = {'title': _('Warning'), 'message': message}
+ if not self.id and self.user_has_groups('stock.group_stock_multi_locations') and not self.result_package_id:
+ self.location_dest_id = self.location_dest_id._get_putaway_strategy(self.product_id, qty_done)
+ if self.product_id.tracking == 'serial':
+ qty_done = self.product_uom_id._compute_quantity(self.qty_done, self.product_id.uom_id)
+ if float_compare(qty_done, 1.0, precision_rounding=self.product_id.uom_id.rounding) != 0:
+ message = _('You can only process 1.0 %s of products with unique serial number.', self.product_id.uom_id.name)
+ res['warning'] = {'title': _('Warning'), 'message': message}
return res
def init(self):
diff --git a/addons/stock/models/stock_package_type.py b/addons/stock/models/stock_package_type.py
index acf3a19c0fd..631d904159d 100644
--- a/addons/stock/models/stock_package_type.py
+++ b/addons/stock/models/stock_package_type.py
@@ -24,6 +24,7 @@ class PackageType(models.Model):
weight_uom_name = fields.Char(string='Weight unit of measure label', compute='_compute_weight_uom_name', default=_get_default_weight_uom)
length_uom_name = fields.Char(string='Length unit of measure label', compute='_compute_length_uom_name', default=_get_default_length_uom)
company_id = fields.Many2one('res.company', 'Company', index=True)
+ storage_category_capacity_ids = fields.One2many('stock.storage.category.capacity', 'package_type_id', 'Storage Category Capacity', copy=True)
_sql_constraints = [
('positive_height', 'CHECK(height>=0)', 'Height must be positive'),
@@ -37,5 +38,10 @@ class PackageType(models.Model):
package_type.length_uom_name = self.env['product.template']._get_length_uom_name_from_ir_config_parameter()
def _compute_weight_uom_name(self):
- for packaging in self:
- packaging.weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+ for package_type in self:
+ package_type.weight_uom_name = self.env['product.template']._get_weight_uom_name_from_ir_config_parameter()
+
+ def copy(self, default=None):
+ default = dict(default or {})
+ default.update(name=_("%s (copy)") % self.name)
+ return super().copy(default)
diff --git a/addons/stock/models/stock_storage_category.py b/addons/stock/models/stock_storage_category.py
new file mode 100644
index 00000000000..abc8a6f410b
--- /dev/null
+++ b/addons/stock/models/stock_storage_category.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import _, api, fields, models
+
+
+class StorageCategory(models.Model):
+ _name = 'stock.storage.category'
+ _description = "Storage Category"
+ _order = "name"
+
+ name = fields.Char('Storage Category', required=True)
+ max_weight = fields.Float('Max Weight', digits='Stock Weight')
+ capacity_ids = fields.One2many('stock.storage.category.capacity', 'storage_category_id', copy=True)
+ product_capacity_ids = fields.One2many('stock.storage.category.capacity', compute="_compute_storage_capacity_ids", inverse="_set_storage_capacity_ids")
+ package_capacity_ids = fields.One2many('stock.storage.category.capacity', compute="_compute_storage_capacity_ids", inverse="_set_storage_capacity_ids")
+ allow_new_product = fields.Selection([
+ ('empty', 'If the location is empty'),
+ ('same', 'If all products are same'),
+ ('mixed', 'Allow mixed products')], default='mixed', required=True)
+ location_ids = fields.One2many('stock.location', 'storage_category_id')
+ company_id = fields.Many2one('res.company', 'Company')
+
+ _sql_constraints = [
+ ('positive_max_weight', 'CHECK(max_weight >= 0)', 'Max weight should be a positive number.'),
+ ]
+
+ @api.depends('capacity_ids')
+ def _compute_storage_capacity_ids(self):
+ for storage_category in self:
+ storage_category.product_capacity_ids = storage_category.capacity_ids.filtered(lambda c: c.product_id)
+ storage_category.package_capacity_ids = storage_category.capacity_ids.filtered(lambda c: c.package_type_id)
+
+ def _set_storage_capacity_ids(self):
+ for storage_category in self:
+ storage_category.capacity_ids = storage_category.product_capacity_ids | storage_category.package_capacity_ids
+
+ def copy(self, default=None):
+ default = dict(default or {})
+ default.update(name=_("%s (copy)") % self.name)
+ return super().copy(default)
+
+
+class StorageCategoryProductCapacity(models.Model):
+ _name = 'stock.storage.category.capacity'
+ _description = "Storage Category Capacity"
+ _check_company_auto = True
+ _order = "storage_category_id"
+
+ @api.model
+ def _domain_product_id(self):
+ domain = "('type', '=', 'product')"
+ if self.env.context.get('active_model') == 'product.template':
+ product_template_id = self.env.context.get('active_id', False)
+ domain = f"('product_tmpl_id', '=', {product_template_id})"
+ elif self.env.context.get('default_product_id', False):
+ product_id = self.env.context.get('default_product_id', False)
+ domain = f"('id', '=', {product_id})"
+ return f"[{domain}, '|', ('company_id', '=', False), ('company_id', '=', company_id)]"
+
+ storage_category_id = fields.Many2one('stock.storage.category', ondelete='cascade', required=True, index=True)
+ product_id = fields.Many2one('product.product', 'Product', domain=lambda self: self._domain_product_id(), ondelete='cascade', check_company=True)
+ package_type_id = fields.Many2one('stock.package.type', 'Package Type', ondelete='cascade', check_company=True)
+ quantity = fields.Float('Quantity', required=True)
+ product_uom_id = fields.Many2one(related='product_id.uom_id')
+ company_id = fields.Many2one('res.company', 'Company', related="storage_category_id.company_id")
+
+ _sql_constraints = [
+ ('positive_quantity', 'CHECK(quantity > 0)', 'Quantity should be a positive number.'),
+ ('unique_product', 'UNIQUE(product_id, storage_category_id)', 'Multiple capacity rules for one product.'),
+ ('unique_package_type', 'UNIQUE(package_type_id, storage_category_id)', 'Multiple capacity rules for one package type.'),
+ ]
diff --git a/addons/stock/security/ir.model.access.csv b/addons/stock/security/ir.model.access.csv
index 2a945c1ce17..1b840aa3705 100644
--- a/addons/stock/security/ir.model.access.csv
+++ b/addons/stock/security/ir.model.access.csv
@@ -83,3 +83,7 @@ access_stock_package_destination,access.stock.package.destination,model_stock_pa
access_stock_orderpoint_snooze,access_stock_orderpoint_snooze,model_stock_orderpoint_snooze,stock.group_stock_user,1,1,1,1
access_stock_package_type_user,access_stock_package_type_user,model_stock_package_type,stock.group_stock_user,1,0,0,0
access_stock_package_type_manager,access_stock_package_type_manager,model_stock_package_type,stock.group_stock_manager,1,1,1,1
+access_stock_storage_category_user,stock.storage.category.user,model_stock_storage_category,base.group_user,1,0,0,0
+access_stock_storage_category_manager,stock.storage.category.manager,model_stock_storage_category,stock.group_stock_manager,1,1,1,1
+access_stock_storage_category_capacity_user,stock.storage.category.capacity.user,model_stock_storage_category_capacity,base.group_user,1,0,0,0
+access_stock_storage_category_capacity_manager,stock.storage.category.capacity.manager,model_stock_storage_category_capacity,stock.group_stock_manager,1,1,1,1
diff --git a/addons/stock/security/stock_security.xml b/addons/stock/security/stock_security.xml
index 6a739de883a..171d0d6f397 100644
--- a/addons/stock/security/stock_security.xml
+++ b/addons/stock/security/stock_security.xml
@@ -63,6 +63,11 @@
Require a signature on your delivery orders
+
+
+ Manage Storage Categories
+
+
diff --git a/addons/stock/tests/test_move.py b/addons/stock/tests/test_move.py
index 7acc47cd544..279cbfc32ff 100644
--- a/addons/stock/tests/test_move.py
+++ b/addons/stock/tests/test_move.py
@@ -10,6 +10,8 @@ class StockMove(TransactionCase):
@classmethod
def setUpClass(cls):
super(StockMove, cls).setUpClass()
+ group_stock_multi_locations = cls.env.ref('stock.group_stock_multi_locations')
+ cls.env.user.write({'groups_id': [(4, group_stock_multi_locations.id, 0)]})
cls.stock_location = cls.env.ref('stock.stock_location_stock')
cls.customer_location = cls.env.ref('stock.stock_location_customers')
cls.supplier_location = cls.env.ref('stock.stock_location_suppliers')
@@ -937,6 +939,671 @@ class StockMove(TransactionCase):
# check if the putaway was rightly applied
self.assertEqual(move1.move_line_ids.location_dest_id.id, shelf2_location.id)
+ def test_putaway_with_storage_category_1(self):
+ """Receive a product. Test the product will be move to a child location
+ with correct storage category.
+ """
+ # storage category
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category"
+ })
+
+ self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+ shelf2_location = self.env['stock.location'].create({
+ 'name': 'shelf2',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ # check if the putaway was rightly applied
+ self.assertEqual(move1.move_line_ids.location_dest_id.id, shelf2_location.id)
+
+ def test_putaway_with_storage_category_2(self):
+ """Receive a product twice. Test first time the putaway applied since we
+ have enough space, and second time it is not since the location is full.
+ """
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category"
+ })
+ # set the capacity for the product in this storage category to be 100
+ storage_category_form = Form(storage_category, view='stock.stock_storage_category_form')
+ with storage_category_form.product_capacity_ids.new() as line:
+ line.product_id = self.product
+ line.quantity = 100
+ storage_category = storage_category_form.save()
+
+ shelf1_location = self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first move
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ # check if the putaway was rightly applied
+ self.assertEqual(move1.move_line_ids.location_dest_id.id, shelf1_location.id)
+
+ # second move
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ # check if the putaway wasn't applied
+ self.assertEqual(move2.move_line_ids.location_dest_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_3(self):
+ """Received products twice, set storage category to only accept new
+ product when empty. Check the first time putaway rule applied and second
+ time not.
+ """
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category",
+ 'allow_new_product': "empty",
+ })
+
+ shelf1_location = self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first move
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+ move_line = move1.move_line_ids[0]
+ move_line.qty_done = 100
+ move1._action_done()
+ self.assertEqual(move1.state, 'done')
+
+ # check if the putaway was rightly applied
+ self.assertEqual(move1.move_line_ids.location_dest_id.id, shelf1_location.id)
+
+ # second move
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move2.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ # check if the putaway wasn't applied
+ self.assertEqual(move2.move_line_ids.location_dest_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_4(self):
+ """Received products, set storage category to only accept same product.
+ Check the putaway rule can't be applied when the location has different
+ products.
+ """
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category",
+ 'allow_new_product': "same",
+ })
+
+ shelf1_location = self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # create a different product and its quant
+ product2 = self.env['product.product'].create({
+ 'name': 'Product 2',
+ 'type': 'product',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ })
+ self.env['stock.quant'].create({
+ 'product_id': product2.id,
+ 'product_uom_id': self.uom_unit.id,
+ 'location_id': shelf1_location.id,
+ 'quantity': 1,
+ 'reserved_quantity': 0,
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+ move_line = move1.move_line_ids[0]
+ move_line.qty_done = 100
+ move1._action_done()
+ self.assertEqual(move1.state, 'done')
+
+ # check if the putaway can't be applied
+ self.assertEqual(move1.move_line_ids.location_dest_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_5(self):
+ """Receive a package. Test the package will be move to a child location
+ with correct storage category.
+ """
+ # storage category
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category"
+ })
+
+ package_type = self.env['stock.package.type'].create({
+ 'name': "package type",
+ })
+
+ self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+ shelf2_location = self.env['stock.location'].create({
+ 'name': 'shelf2',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ 'package_type_ids': [(4, package_type.id, 0)],
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ package = self.env['stock.quant.package'].create({
+ 'name': 'package',
+ 'package_type_id': package_type.id,
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ move_form = Form(move1, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package
+ line.qty_done = 100
+ move1 = move_form.save()
+ move1._action_done()
+
+ # check if the putaway was rightly applied
+ self.assertEqual(package.location_id.id, shelf2_location.id)
+
+ def test_putaway_with_storage_category_6(self):
+ """Receive package with same package type twice. Check putaway rule can
+ be applied on the first one but not the second one due to no space.
+ """
+ # storage category
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category"
+ })
+
+ package_type = self.env['stock.package.type'].create({
+ 'name': "package type",
+ })
+
+ # set the capacity for the package type in this storage category to be 1
+ storage_category_form = Form(storage_category, view='stock.stock_storage_category_form')
+ with storage_category_form.package_capacity_ids.new() as line:
+ line.package_type_id = package_type
+ line.quantity = 1
+ storage_category = storage_category_form.save()
+
+ self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+ shelf2_location = self.env['stock.location'].create({
+ 'name': 'shelf2',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ 'package_type_ids': [(4, package_type.id, 0)],
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first package
+ package1 = self.env['stock.quant.package'].create({
+ 'name': 'package 1',
+ 'package_type_id': package_type.id,
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ move_form = Form(move1, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package1
+ line.qty_done = 100
+ move1 = move_form.save()
+ move1._action_done()
+
+ # check if the putaway was rightly applied
+ self.assertEqual(package1.location_id.id, shelf2_location.id)
+
+ # second package
+ package2 = self.env['stock.quant.package'].create({
+ 'name': 'package 2',
+ 'package_type_id': package_type.id,
+ })
+
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move2.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ move_form = Form(move2, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package2
+ line.qty_done = 100
+ move2 = move_form.save()
+ move2._action_done()
+
+ # check if the putaway wasn't applied
+ self.assertEqual(package2.location_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_7(self):
+ """Receive package with same package type twice, set storage category to
+ only accept new product when empty. Check putaway rule can be applied on
+ the first one but not the second one.
+ """
+ # storage category
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category",
+ 'allow_new_product': "empty",
+ })
+
+ package_type = self.env['stock.package.type'].create({
+ 'name': "package type",
+ })
+
+ # set the capacity for the package type in this storage category to be 100
+ storage_category_form = Form(storage_category, view='stock.stock_storage_category_form')
+ with storage_category_form.package_capacity_ids.new() as line:
+ line.package_type_id = package_type
+ line.quantity = 100
+ storage_category = storage_category_form.save()
+
+ self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+ shelf2_location = self.env['stock.location'].create({
+ 'name': 'shelf2',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ 'package_type_ids': [(4, package_type.id, 0)],
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first package
+ package1 = self.env['stock.quant.package'].create({
+ 'name': 'package 1',
+ 'package_type_id': package_type.id,
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ move_form = Form(move1, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package1
+ line.qty_done = 100
+ move1 = move_form.save()
+ move1._action_done()
+
+ # check if the putaway was rightly applied
+ self.assertEqual(package1.location_id.id, shelf2_location.id)
+
+ # second package
+ package2 = self.env['stock.quant.package'].create({
+ 'name': 'package 2',
+ 'package_type_id': package_type.id,
+ })
+
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move2.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ move_form = Form(move2, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package2
+ line.qty_done = 100
+ move2 = move_form.save()
+ move2._action_done()
+
+ # check if the putaway wasn't applied
+ self.assertEqual(package2.location_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_8(self):
+ """Receive package withs different products, set storage category to only
+ accept same product. Check putaway rule can be applied on the first one
+ but not the second one.
+ """
+ # storage category
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category",
+ 'allow_new_product': "same",
+ })
+
+ package_type = self.env['stock.package.type'].create({
+ 'name': "package type",
+ })
+
+ # set the capacity for the package type in this storage category to be 100
+ storage_category_form = Form(storage_category, view='stock.stock_storage_category_form')
+ with storage_category_form.package_capacity_ids.new() as line:
+ line.package_type_id = package_type
+ line.quantity = 100
+ storage_category = storage_category_form.save()
+
+ self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ })
+ shelf2_location = self.env['stock.location'].create({
+ 'name': 'shelf2',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+
+ # putaway from stock to child location for package type
+ putaway = self.env['stock.putaway.rule'].create({
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ 'package_type_ids': [(4, package_type.id, 0)],
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first package
+ package1 = self.env['stock.quant.package'].create({
+ 'name': 'package 1',
+ 'package_type_id': package_type.id,
+ })
+
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ move_form = Form(move1, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package1
+ line.qty_done = 100
+ move1 = move_form.save()
+ move1._action_done()
+
+ # check if the putaway was rightly applied
+ self.assertEqual(package1.location_id.id, shelf2_location.id)
+
+ # second package
+ package2 = self.env['stock.quant.package'].create({
+ 'name': 'package 2',
+ 'package_type_id': package_type.id,
+ })
+
+ product2 = self.env['product.product'].create({
+ 'name': 'Product 2',
+ 'type': 'product',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ })
+
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': product2.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move2.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ move_form = Form(move2, view='stock.view_stock_move_nosuggest_operations')
+ with move_form.move_line_nosuggest_ids.new() as line:
+ line.result_package_id = package2
+ line.qty_done = 100
+ move2 = move_form.save()
+ move2._action_done()
+
+ # check if the putaway wasn't applied
+ self.assertEqual(package2.location_id.id, self.stock_location.id)
+
+ def test_putaway_with_storage_category_9(self):
+ """Receive a product twice. Test first time the putaway applied, and second
+ time it is not since the products violate the max_weight limitaion.
+ """
+ self.product.weight = 1
+ storage_category = self.env['stock.storage.category'].create({
+ 'name': "storage category",
+ 'max_weight': 100,
+ })
+
+ shelf1_location = self.env['stock.location'].create({
+ 'name': 'shelf1',
+ 'usage': 'internal',
+ 'location_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ # putaway from stock to child location with storage_category
+ putaway = self.env['stock.putaway.rule'].create({
+ 'product_id': self.product.id,
+ 'location_in_id': self.stock_location.id,
+ 'location_out_id': self.stock_location.id,
+ 'storage_category_id': storage_category.id,
+ })
+ self.stock_location.write({
+ 'putaway_rule_ids': [(4, putaway.id, 0)],
+ })
+
+ # first move
+ move1 = self.env['stock.move'].create({
+ 'name': 'test_move_1',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move1._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move1.move_line_ids), 1)
+
+ # check if the putaway was rightly applied
+ self.assertEqual(move1.move_line_ids.location_dest_id.id, shelf1_location.id)
+
+ # second move
+ move2 = self.env['stock.move'].create({
+ 'name': 'test_move_2',
+ 'location_id': self.supplier_location.id,
+ 'location_dest_id': self.stock_location.id,
+ 'product_id': self.product.id,
+ 'product_uom': self.uom_unit.id,
+ 'product_uom_qty': 100.0,
+ })
+ move2._action_confirm()
+ self.assertEqual(move1.state, 'assigned')
+ self.assertEqual(len(move2.move_line_ids), 1)
+
+ # check if the putaway wasn't applied since there are already 100kg products in the location
+ self.assertEqual(move2.move_line_ids.location_dest_id.id, self.stock_location.id)
+
def test_availability_1(self):
""" Check that the `availability` field on a move is correctly computed when there is
more than enough products in stock.
diff --git a/addons/stock/views/product_strategy_views.xml b/addons/stock/views/product_strategy_views.xml
index 9e03118a00a..d14318a41f5 100644
--- a/addons/stock/views/product_strategy_views.xml
+++ b/addons/stock/views/product_strategy_views.xml
@@ -7,20 +7,27 @@
-
+
-
-
-
+
+
@@ -96,5 +103,5 @@
+ action="action_putaway_tree" sequence="8" groups="stock.group_stock_multi_locations"/>
diff --git a/addons/stock/views/product_views.xml b/addons/stock/views/product_views.xml
index 204dd8a0db3..e9905531f1f 100644
--- a/addons/stock/views/product_views.xml
+++ b/addons/stock/views/product_views.xml
@@ -287,6 +287,12 @@
class="oe_stat_button" icon="fa-random" groups="stock.group_stock_multi_locations"
attrs="{'invisible': [('type', '=', 'service')]}"
context="{'invisible_handle': True, 'single_product': True}"/>
+
@@ -388,6 +394,12 @@
'invisible_handle': True,
'single_product': product_variant_count == 1,
}"/>
+