diff --git a/addons/stock_picking_batch/__manifest__.py b/addons/stock_picking_batch/__manifest__.py
index 1e300bca7ca..86613ff1474 100644
--- a/addons/stock_picking_batch/__manifest__.py
+++ b/addons/stock_picking_batch/__manifest__.py
@@ -13,6 +13,7 @@ This module adds the batch transfer option in warehouse management
'data': [
'security/ir.model.access.csv',
'views/stock_picking_batch_views.xml',
+ 'views/stock_picking_type_views.xml',
'views/stock_picking_wave_views.xml',
'views/stock_move_line_views.xml',
'data/stock_picking_batch_data.xml',
diff --git a/addons/stock_picking_batch/data/stock_picking_batch_data.xml b/addons/stock_picking_batch/data/stock_picking_batch_data.xml
index 2cb0c42ba91..74158a825ed 100644
--- a/addons/stock_picking_batch/data/stock_picking_batch_data.xml
+++ b/addons/stock_picking_batch/data/stock_picking_batch_data.xml
@@ -23,4 +23,10 @@
5
+
+
+
+
+
+
diff --git a/addons/stock_picking_batch/models/__init__.py b/addons/stock_picking_batch/models/__init__.py
index bafa2582bf2..b3472b526c6 100644
--- a/addons/stock_picking_batch/models/__init__.py
+++ b/addons/stock_picking_batch/models/__init__.py
@@ -4,3 +4,4 @@
from . import stock_move_line
from . import stock_picking
from . import stock_picking_batch
+from . import stock_warehouse
diff --git a/addons/stock_picking_batch/models/stock_picking.py b/addons/stock_picking_batch/models/stock_picking.py
index af660b050f1..67f9551d8ac 100644
--- a/addons/stock_picking_batch/models/stock_picking.py
+++ b/addons/stock_picking_batch/models/stock_picking.py
@@ -1,7 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
-from odoo import _, api, fields, models
+from odoo import _, api, Command, fields, models
+from odoo.osv import expression
+from odoo.exceptions import ValidationError
class StockPickingType(models.Model):
@@ -9,6 +11,21 @@ class StockPickingType(models.Model):
count_picking_batch = fields.Integer(compute='_compute_picking_count')
count_picking_wave = fields.Integer(compute='_compute_picking_count')
+ auto_batch = fields.Boolean('Automatic Batches',
+ help="Automatically put pickings into batches as they are confirmed when possible.")
+ batch_group_by_partner = fields.Boolean('Contact', help="Automatically group batches by contacts.")
+ batch_group_by_destination = fields.Boolean('Destination Country', help="Automatically group batches by destination country.")
+ batch_group_by_src_loc = fields.Boolean('Source Location',
+ help="Automatically group batches by their source location.")
+ batch_group_by_dest_loc = fields.Boolean('Destination Location',
+ help="Automatically group batches by their destination location.")
+ batch_max_lines = fields.Integer("Maximum lines per batch",
+ help="A transfer will not be automatically added to batches that will exceed this number of lines if the transfer is added to it.\n"
+ "Leave this value as '0' if no line limit.")
+ batch_max_pickings = fields.Integer("Maximum transfers per batch",
+ help="A transfer will not be automatically added to batches that will exceed this number of transfers.\n"
+ "Leave this value as '0' if no transfer limit.")
+ batch_auto_confirm = fields.Boolean("Auto-confirm", default=True)
def _compute_picking_count(self):
super()._compute_picking_count()
@@ -27,6 +44,19 @@ class StockPickingType(models.Model):
for record in self:
record[field] = count.get(record.id, 0)
+ @api.model
+ def _get_batch_group_by_keys(self):
+ return ['batch_group_by_partner', 'batch_group_by_destination', 'batch_group_by_src_loc', 'batch_group_by_dest_loc']
+
+ @api.constrains(lambda self: self._get_batch_group_by_keys() + ['auto_batch'])
+ def _validate_auto_batch_group_by(self):
+ group_by_keys = self._get_batch_group_by_keys()
+ for picking_type in self:
+ if not picking_type.auto_batch:
+ continue
+ if not any(picking_type[key] for key in group_by_keys):
+ raise ValidationError(_("If the Automatic Batches feature is enabled, at least one 'Group by' option must be selected."))
+
def get_action_picking_tree_batch(self):
return self._get_action('stock_picking_batch.stock_picking_batch_action')
@@ -41,7 +71,7 @@ class StockPicking(models.Model):
'stock.picking.batch', string='Batch Transfer',
check_company=True,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
- help='Batch associated to this transfer', copy=False)
+ help='Batch associated to this transfer', index=True, copy=False)
@api.model_create_multi
def create(self, vals_list):
@@ -82,7 +112,100 @@ class StockPicking(models.Model):
search_default_by_location=True,
)}
+ def action_confirm(self):
+ res = super().action_confirm()
+ for picking in self:
+ if picking.picking_type_id.auto_batch and not picking.immediate_transfer and not picking.batch_id and picking.move_ids and picking._is_auto_batchable():
+ picking._find_auto_batch()
+ return res
+
def _should_show_transfers(self):
if len(self.batch_id) == 1 and self == self.batch_id.picking_ids:
return False
return super()._should_show_transfers()
+
+ def _find_auto_batch(self):
+ # Try to find a compatible batch to insert the picking
+ possible_batches = self.env['stock.picking.batch'].sudo().search(self._get_possible_batches_domain())
+ for batch in possible_batches:
+ if batch._is_picking_auto_mergeable(self):
+ batch.picking_ids |= self
+ return batch
+
+ # If no batch were found, try to find a compatible picking and put them both in a new batch.
+ possible_pickings = self.env['stock.picking'].search(self._get_possible_pickings_domain())
+ for picking in possible_pickings:
+ if self._is_auto_batchable(picking):
+ # Create new batch with both pickings
+ new_batch = self.env['stock.picking.batch'].sudo().create({
+ 'picking_ids': [Command.link(self.id), Command.link(picking.id)],
+ 'company_id': self.company_id.id if self.company_id else False,
+ 'picking_type_id': self.picking_type_id.id,
+ })
+ if picking.picking_type_id.batch_auto_confirm:
+ new_batch.action_confirm()
+ return new_batch
+
+ # If nothing was found after those two steps, then no batch is doable given the conditions
+ return False
+
+ def _is_auto_batchable(self, picking=None):
+ """ Verifies if a picking can be put in a batch with another picking without violating auto_batch constrains.
+ """
+ res = True
+ if not picking:
+ picking = self.env['stock.picking']
+ if self.picking_type_id.batch_max_lines:
+ res = res and (len(self.move_ids) + len(picking.move_ids) <= self.picking_type_id.batch_max_lines)
+ if self.picking_type_id.batch_max_pickings:
+ # Sounds absurd. BUT if we put "batch max picking" to a value <= 1, makes sense ... Or not. Because then there is no point to batch.
+ res = res and self.picking_type_id.batch_max_pickings > 1
+ return res
+
+ def _get_possible_pickings_domain(self):
+ self.ensure_one()
+ domain = [
+ ('id', '!=', self.id),
+ ('company_id', '=', self.company_id.id if self.company_id else False),
+ ('immediate_transfer', '=', False),
+ ('state', 'in', ('waiting', 'confirmed', 'assigned')),
+ ('picking_type_id', '=', self.picking_type_id.id),
+ ('batch_id', '=', False),
+ ]
+ if self.picking_type_id.batch_group_by_partner:
+ domain = expression.AND([domain, [('partner_id', '=', self.partner_id.id)]])
+ if self.picking_type_id.batch_group_by_destination:
+ domain = expression.AND([domain, [('partner_id.country_id', '=', self.partner_id.country_id.id)]])
+ if self.picking_type_id.batch_group_by_src_loc:
+ domain = expression.AND([domain, [('location_id', '=', self.location_id.id)]])
+ if self.picking_type_id.batch_group_by_dest_loc:
+ domain = expression.AND([domain, [('location_dest_id', '=', self.location_dest_id.id)]])
+
+ return domain
+
+ def _get_possible_batches_domain(self):
+ self.ensure_one()
+ domain = [
+ ('state', 'in', ('draft', 'in_progress') if self.picking_type_id.batch_auto_confirm else ('draft',)),
+ ('picking_type_id', '=', self.picking_type_id.id),
+ ('company_id', '=', self.company_id.id if self.company_id else False),
+ ]
+ if self.picking_type_id.batch_group_by_partner:
+ domain = expression.AND([domain, [('picking_ids.partner_id', '=', self.partner_id.id)]])
+ if self.picking_type_id.batch_group_by_destination:
+ domain = expression.AND([domain, [('picking_ids.partner_id.country_id', '=', self.partner_id.country_id.id)]])
+ if self.picking_type_id.batch_group_by_src_loc:
+ domain = expression.AND([domain, [('picking_ids.location_id', '=', self.location_id.id)]])
+ if self.picking_type_id.batch_group_by_dest_loc:
+ domain = expression.AND([domain, [('picking_ids.location_dest_id', '=', self.location_dest_id.id)]])
+
+ return domain
+
+ def action_view_batch(self):
+ self.ensure_one()
+ return {
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'stock.picking.batch',
+ 'res_id': self.batch_id.id,
+ 'view_mode': 'form'
+ }
diff --git a/addons/stock_picking_batch/models/stock_picking_batch.py b/addons/stock_picking_batch/models/stock_picking_batch.py
index bc5ad6466ca..5d56b2aa48f 100644
--- a/addons/stock_picking_batch/models/stock_picking_batch.py
+++ b/addons/stock_picking_batch/models/stock_picking_batch.py
@@ -49,10 +49,10 @@ class StockPickingBatch(models.Model):
('done', 'Done'),
('cancel', 'Cancelled')], default='draft',
store=True, compute='_compute_state',
- copy=False, tracking=True, required=True, readonly=True)
+ copy=False, tracking=True, required=True, readonly=True, index=True)
picking_type_id = fields.Many2one(
'stock.picking.type', 'Operation Type', check_company=True, copy=False,
- readonly=True, states={'draft': [('readonly', False)]})
+ readonly=True, index=True, states={'draft': [('readonly', False)]})
picking_type_code = fields.Selection(
related='picking_type_id.code')
scheduled_date = fields.Datetime(
@@ -323,3 +323,12 @@ class StockPickingBatch(models.Model):
return self.env.ref('stock_picking_batch.mt_batch_state')
return super()._track_subtype(init_values)
+ def _is_picking_auto_mergeable(self, picking):
+ """ Verifies if a picking can be safely inserted into the batch without violating auto_batch_constrains.
+ """
+ res = True
+ if self.picking_type_id.batch_max_lines:
+ res = res and (len(self.move_ids) + len(picking.move_ids) <= self.picking_type_id.batch_max_lines)
+ if self.picking_type_id.batch_max_pickings:
+ res = res and (len(self.picking_ids) + 1 <= self.picking_type_id.batch_max_pickings)
+ return res
diff --git a/addons/stock_picking_batch/models/stock_warehouse.py b/addons/stock_picking_batch/models/stock_warehouse.py
new file mode 100644
index 00000000000..45ca5d06a7a
--- /dev/null
+++ b/addons/stock_picking_batch/models/stock_warehouse.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class StockWarehouse(models.Model):
+ _inherit = 'stock.warehouse'
+
+ def _get_picking_type_create_values(self, max_sequence):
+ data, next_sequence = super()._get_picking_type_create_values(max_sequence)
+ updatable_types = {k: v for (k, v) in data.items() if v.get('code') in ('incoming', 'outgoing')}
+ for picking_type in updatable_types.values():
+ picking_type.update({
+ 'auto_batch': True,
+ 'batch_group_by_partner': True,
+ })
+ return data, next_sequence
diff --git a/addons/stock_picking_batch/tests/test_batch_picking.py b/addons/stock_picking_batch/tests/test_batch_picking.py
index 25cd8c34ef2..5943e65e1d2 100644
--- a/addons/stock_picking_batch/tests/test_batch_picking.py
+++ b/addons/stock_picking_batch/tests/test_batch_picking.py
@@ -360,3 +360,92 @@ class TestBatchPicking(TransactionCase):
# final package location should be correctly set based on wizard
self.assertEqual(package.location_id.id, self.customer_location.id)
+
+ def test_auto_batch(self):
+ """ Test a simple auto-batch scenario with new picking type to avoid conflicts with existing picking types.
+ The pickings look like this:
+ Picking_out_1 Picking_out_2 Picking_out_3
+ Partner_1 Partner_2 Partner_1
+
+ So as the picking type is defined to batch automatically by partner, Picking 1&3 should be batched at their confirmation, while Picking2 isn't.
+ """
+ # Create picking type to avoid conflicts with existing pickings with auto-batch enabled grouping by partner.
+ warehouse = self.env['stock.warehouse'].search([], limit=1)
+ type_special_out = self.env['stock.picking.type'].create({
+ 'name': 'Special Delivery',
+ 'sequence_code': 'SPECOUT',
+ 'code': 'outgoing',
+ 'company_id': self.env.company.id,
+ 'warehouse_id': warehouse.id,
+ 'auto_batch': True,
+ 'batch_group_by_partner': True,
+ })
+ partner_1 = self.env['res.partner'].create({
+ 'name': 'Partner 1'
+ })
+ partner_2 = self.env['res.partner'].create({
+ 'name': 'Partner 2'
+ })
+
+ # Create the pickings that will be confirmed and batched afterwards
+ picking_out_1 = self.env['stock.picking'].create({
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'picking_type_id': type_special_out.id,
+ 'company_id': self.env.company.id,
+ 'partner_id': partner_1.id
+ })
+ self.env['stock.move'].create({
+ 'name': self.productA.name,
+ 'product_id': self.productA.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.productA.uom_id.id,
+ 'picking_id': picking_out_1.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ })
+
+ picking_out_2 = self.env['stock.picking'].create({
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'picking_type_id': type_special_out.id,
+ 'company_id': self.env.company.id,
+ 'partner_id': partner_2.id
+ })
+ self.env['stock.move'].create({
+ 'name': self.productB.name,
+ 'product_id': self.productB.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.productB.uom_id.id,
+ 'picking_id': picking_out_2.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ })
+
+ picking_out_3 = self.env['stock.picking'].create({
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ 'picking_type_id': type_special_out.id,
+ 'company_id': self.env.company.id,
+ 'partner_id': partner_1.id
+ })
+ self.env['stock.move'].create({
+ 'name': self.productB.name,
+ 'product_id': self.productB.id,
+ 'product_uom_qty': 10,
+ 'product_uom': self.productB.uom_id.id,
+ 'picking_id': picking_out_3.id,
+ 'location_id': self.stock_location.id,
+ 'location_dest_id': self.customer_location.id,
+ })
+
+ all_pickings = picking_out_1 | picking_out_2 | picking_out_3
+ # No pickings should have any batch before confirmation
+ self.assertFalse(all_pickings.batch_id)
+
+ all_pickings.action_confirm()
+ # Now Picking 1 and 3 should be batched together, while Picking 2 is still in no batch.
+ self.assertTrue(picking_out_1.batch_id)
+ self.assertTrue(picking_out_3.batch_id)
+ self.assertEqual(picking_out_1.batch_id.id, picking_out_3.batch_id.id)
+ self.assertFalse(picking_out_2.batch_id)
diff --git a/addons/stock_picking_batch/tests/test_wave_picking.py b/addons/stock_picking_batch/tests/test_wave_picking.py
index 78f12f327af..75d43d15c6b 100644
--- a/addons/stock_picking_batch/tests/test_wave_picking.py
+++ b/addons/stock_picking_batch/tests/test_wave_picking.py
@@ -23,11 +23,13 @@ class TestBatchPicking(TransactionCase):
SN3 : 1 unit
SN4 : 1 unit
SN5 : 1 unit
+ The picking_internal is the same as Picking1 move-wise, only using a different picking_type so it doesn't get auto-batched with the other pickings.
"""
super().setUpClass()
cls.stock_location = cls.env.ref('stock.stock_location_stock')
cls.customer_location = cls.env.ref('stock.stock_location_customers')
cls.picking_type_out = cls.env['ir.model.data']._xmlid_to_res_id('stock.picking_type_out')
+ cls.picking_type_internal = cls.env['ir.model.data']._xmlid_to_res_id('stock.picking_type_internal')
cls.user_demo = cls.env['res.users'].search([('login', '=', 'demo')])
cls.productA = cls.env['product.product'].create({
@@ -119,8 +121,37 @@ class TestBatchPicking(TransactionCase):
'location_id': cls.stock_location.id,
'location_dest_id': cls.customer_location.id,
})
+
+ cls.picking_internal = cls.env['stock.picking'].create({
+ 'location_id': cls.stock_location.id,
+ 'location_dest_id': cls.customer_location.id,
+ 'picking_type_id': cls.picking_type_internal,
+ 'company_id': cls.env.company.id,
+ })
+
+ cls.env['stock.move'].create({
+ 'name': cls.productA.name,
+ 'product_id': cls.productA.id,
+ 'product_uom_qty': 15,
+ 'product_uom': cls.productA.uom_id.id,
+ 'picking_id': cls.picking_internal.id,
+ 'location_id': cls.customer_location.id,
+ 'location_dest_id': cls.stock_location.id,
+ })
+
+ cls.env['stock.move'].create({
+ 'name': cls.productB.name,
+ 'product_id': cls.productB.id,
+ 'product_uom_qty': 5,
+ 'product_uom': cls.productB.uom_id.id,
+ 'picking_id': cls.picking_internal.id,
+ 'location_id': cls.customer_location.id,
+ 'location_dest_id': cls.stock_location.id,
+ })
+
cls.all_pickings = cls.picking_client_1 | cls.picking_client_2 | cls.picking_client_3
cls.all_pickings.action_confirm()
+ cls.picking_internal.action_confirm()
def test_creation_from_lines(self):
""" Select all the move_lines and create a wave from them """
@@ -225,7 +256,7 @@ class TestBatchPicking(TransactionCase):
self.assertEqual(len(all_db_pickings) + 1, len(new_all_db_picking))
def test_wave_split_move(self):
- lines = self.picking_client_1.move_ids.filtered(lambda m: m.product_id == self.productB).move_line_ids[0:2]
+ lines = self.picking_internal.move_ids.filtered(lambda m: m.product_id == self.productB).move_line_ids[0:2]
move = lines.move_id
all_db_pickings = self.env['stock.picking'].search([])
res_dict = lines.action_open_add_to_wave()
@@ -242,7 +273,7 @@ class TestBatchPicking(TransactionCase):
# Original picking lost a stock move
self.assertTrue(move.picking_id)
- self.assertTrue(move.picking_id == self.picking_client_1)
+ self.assertTrue(move.picking_id == self.picking_internal)
self.assertFalse(lines.move_id == move)
self.assertFalse(move.picking_id.batch_id)
new_move = lines.move_id
diff --git a/addons/stock_picking_batch/views/stock_picking_type_views.xml b/addons/stock_picking_batch/views/stock_picking_type_views.xml
new file mode 100644
index 00000000000..4d069ec47df
--- /dev/null
+++ b/addons/stock_picking_batch/views/stock_picking_type_views.xml
@@ -0,0 +1,40 @@
+
+
+
+ stock.picking.type.form.inherit
+ stock.picking.type
+
+
+
+
+
+
+ Group by
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+