[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:
@@ -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>
|
||||
Reference in New Issue
Block a user