[IMP] stock_picking_batch: Add auto-batch

Allow confirmed pickings to be automatically put into batches that match the
group criterias defined in their picking_type.
The pickings can be grouped by :
 - Contact
 - Destination Country
 - Source location
 - Destination location

To avoid having batches too big then, some restrictions can be put over
the auto-batcher to restrict the size of a batch :
 - Max moves per batch
 - Max pickings per batch

Task-2670580

Part-of: odoo/odoo#81533
This commit is contained in:
clesgow
2022-02-02 11:46:52 +00:00
parent 6e042d0b6e
commit 7c0a7abf68
9 changed files with 324 additions and 6 deletions
@@ -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',
@@ -23,4 +23,10 @@
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<!-- Update existing picking types to include autobatch settings for incoming/outgoing -->
<function model="stock.picking.type" name="write">
<value model="stock.picking.type" eval="obj().search([('code', 'in', ['incoming', 'outgoing'])]).ids"/>
<value eval="{'auto_batch': True, 'batch_group_by_partner': True}"/>
</function>
</data></odoo>
@@ -4,3 +4,4 @@
from . import stock_move_line
from . import stock_picking
from . import stock_picking_batch
from . import stock_warehouse
@@ -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'
}
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_picking_type_form_inherit" model="ir.ui.view">
<field name="name">stock.picking.type.form.inherit</field>
<field name="model">stock.picking.type</field>
<field name="inherit_id" ref="stock.view_picking_type_form"/>
<field name="arch" type="xml">
<sheet position="inside">
<group attrs="{'invisible': [('code', 'not in', ('incoming', 'outgoing', 'internal'))]}">
<group string="Batch Transfers">
<field name="auto_batch"/>
<span class="o_form_label font-weight-bold" attrs="{'invisible':[('auto_batch', '=', False)]}">Group by</span>
<div name="batch_contact" class="o_row" attrs="{'invisible':[('auto_batch', '=', False)]}">
<field name="batch_group_by_partner"/>
<label for="batch_group_by_partner" string="Contact"/>
</div>
<span attrs="{'invisible':[('auto_batch', '=', False)]}"/>
<div name="batch_destination" class="o_row" attrs="{'invisible':[('auto_batch', '=', False)]}">
<field name="batch_group_by_destination"/>
<label for="batch_group_by_destination"/>
</div>
<span attrs="{'invisible':[('auto_batch', '=', False)]}" groups="stock.group_stock_multi_locations"/>
<div name="batch_source_location" class="o_row" attrs="{'invisible':['|', ('auto_batch', '=', False), ('default_location_src_id', '=', False)]}" groups="stock.group_stock_multi_locations">
<field name="batch_group_by_src_loc"/>
<label for="batch_group_by_src_loc"/>
</div>
<span attrs="{'invisible':[('auto_batch', '=', False)]}" groups="stock.group_stock_multi_locations"/>
<div name="batch_dest_subloc" class="o_row" attrs="{'invisible':['|', ('auto_batch', '=', False), ('default_location_dest_id', '=', False)]}" groups="stock.group_stock_multi_locations">
<field name="batch_group_by_dest_loc"/>
<label for="batch_group_by_dest_loc"/>
</div>
<field name="batch_max_lines" attrs="{'invisible': [('auto_batch', '=', False)]}"/>
<field name="batch_max_pickings" attrs="{'invisible': [('auto_batch', '=', False)]}"/>
<field name="batch_auto_confirm" attrs="{'invisible': [('auto_batch', '=', False)]}"/>
</group>
</group>
</sheet>
</field>
</record>
</odoo>