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 +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ + + +
+
+
+
+
+