From 1a727ed5fcdc8927cc2f8da9b0df7adf4720939e Mon Sep 17 00:00:00 2001 From: Arnaud Baes Date: Wed, 10 Apr 2019 09:21:47 +0000 Subject: [PATCH] [IMP] stock: Add hybrid procure method 'mts_else_mto' In some occasions, products can be available in a location, but are still ignored when the MTO is set on those products. (e.g.: When a product is returned) To avoid this, we add a `procure_method` named `mts_else_mto`, allowing procurement to reserve the products if enough of them are available in his source location. In order to achieve this, we apply the MTS or the MTO `procure_method` on the moves from the procurement depending on whether or not there are enough quantities of the needed products in the source location ('All or nothing' logic). Quick performance test: With a Sale Order of 800 differents products set as MTO+Buy, Half of these products available in stock. Before the patch : 2m30s to confirm the SO (800 PO Lines created) After the patch: MTO (procure method MTO): 2m30s to confirm the SO (800 PO Lines created) MTO (procure method MTS_ELSE_MTO): 2m26s to confirm the SO (400 PO Lines created) TaskID: 1831347 closes odoo/odoo#32778 Signed-off-by: Simon Lejeune (sle) --- addons/stock/models/stock_rule.py | 43 +++++- addons/stock/models/stock_warehouse.py | 2 +- addons/stock/tests/test_move2.py | 194 +++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 5 deletions(-) diff --git a/addons/stock/models/stock_rule.py b/addons/stock/models/stock_rule.py index 96374d4830a..84c7e44fe90 100644 --- a/addons/stock/models/stock_rule.py +++ b/addons/stock/models/stock_rule.py @@ -44,10 +44,13 @@ class StockRule(models.Model): route_id = fields.Many2one('stock.location.route', 'Route', required=True, ondelete='cascade') procure_method = fields.Selection([ ('make_to_stock', 'Take From Stock'), - ('make_to_order', 'Trigger Another Rule')], string='Move Supply Method', + ('make_to_order', 'Trigger Another Rule'), + ('mts_else_mto', 'Take From Stock, if unavailable, Trigger Another Rule')], string='Move Supply Method', default='make_to_stock', required=True, - help="""Create Procurement: A procurement will be created in the source location and the system will try to find a rule to resolve it. The available stock will be ignored. - Take from Stock: The products will be taken from the available stock.""") + help="Take From Stock: the products will be taken from the available stock of the source location.\n" + "Trigger Another Rule: the system will try to find a stock rule to bring the products in the source location. The available stock will be ignored.\n" + "Take From Stock, if Unavailable, Trigger Another Rule: the products will be taken from the available stock of the source location." + "If there is no stock available, the system will try to find a rule to bring the products in the source location.") route_sequence = fields.Integer('Route Sequence', related='route_id.sequence', store=True, readonly=False) picking_type_id = fields.Many2one( 'stock.picking.type', 'Operation Type', @@ -109,6 +112,8 @@ class StockRule(models.Model): suffix = "" if self.procure_method == 'make_to_order' and self.location_src_id: suffix = _("
A need is created in %s and a rule will be triggered to fulfill it.") % (source) + if self.procure_method == 'mts_else_mto' and self.location_src_id: + suffix = _("
If the products are not available in %s, a rule will be triggered to bring products in this location.") % source message_dict = { 'pull': _('When products are needed in %s,
%s are created from %s to fulfill the need.') % (destination, operation, source) + suffix, 'push': _('When products arrive in %s,
%s are created to send them in %s.') % (source, operation, destination) @@ -170,12 +175,42 @@ class StockRule(models.Model): @api.model def _run_pull(self, procurements): moves_values_by_company = defaultdict(list) + mtso_products_by_locations = defaultdict(list) + + # To handle the `mts_else_mto` procure method, we do a preliminary loop to + # isolate the products we would need to read the forecasted quantity, + # in order to to batch the read. We also make a sanitary check on the + # `location_src_id` field. for procurement, rule in procurements: if not rule.location_src_id: msg = _('No source location defined on stock rule: %s!') % (rule.name, ) raise UserError(msg) - moves_values_by_company[procurement.company_id.id].append(rule._get_stock_move_values(*procurement)) + if rule.procure_method == 'mts_else_mto': + mtso_products_by_locations[rule.location_src_id].append(procurement.product_id.id) + + # Get the forecasted quantity for the `mts_else_mto` procurement. + forecasted_qties_by_loc = {} + for location, product_ids in mtso_products_by_locations.items(): + products = self.env['product.product'].browse(product_ids).with_context(location=location.id) + forecasted_qties_by_loc[location] = {product.id: product.virtual_available for product in products} + + # Prepare the move values, adapt the `procure_method` if needed. + for procurement, rule in procurements: + procure_method = rule.procure_method + if rule.procure_method == 'mts_else_mto': + qty_needed = procurement.product_uom._compute_quantity(procurement.product_qty, procurement.product_id.uom_id) + qty_available = forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] + if float_compare(qty_needed, qty_available, precision_rounding=procurement.product_id.uom_id.rounding) <= 0: + procure_method = 'make_to_stock' + forecasted_qties_by_loc[rule.location_src_id][procurement.product_id.id] -= qty_needed + else: + procure_method = 'make_to_order' + + move_values = rule._get_stock_move_values(*procurement) + move_values['procure_method'] = procure_method + moves_values_by_company[procurement.company_id.id].append(move_values) + for company_id, moves_values in moves_values_by_company.items(): # create the move as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example) moves = self.env['stock.move'].sudo().with_context(force_company=company_id).create(moves_values) diff --git a/addons/stock/models/stock_warehouse.py b/addons/stock/models/stock_warehouse.py index 5534dd8f566..b2f9a934ff9 100644 --- a/addons/stock/models/stock_warehouse.py +++ b/addons/stock/models/stock_warehouse.py @@ -335,7 +335,7 @@ class Warehouse(models.Model): 'depends': ['delivery_steps'], 'create_values': { 'active': True, - 'procure_method': 'make_to_order', + 'procure_method': 'mts_else_mto', 'company_id': self.company_id.id, 'action': 'pull', 'auto': 'manual', diff --git a/addons/stock/tests/test_move2.py b/addons/stock/tests/test_move2.py index a62ff9fef98..982cddc5195 100644 --- a/addons/stock/tests/test_move2.py +++ b/addons/stock/tests/test_move2.py @@ -2049,3 +2049,197 @@ class TestRoutes(TestStockCommon): pushed_move = move1.move_dest_ids self.assertEqual(pushed_move.location_dest_id.id, push_location.id) + + def test_mtso_mto(self): + """ Run a procurement for 5 products when there are only 4 in stock then + check that MTO is applied on the moves when the rule is set to 'mts_else_mto' + """ + warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse.delivery_steps = 'pick_pack_ship' + partner_demo_customer = self.env.ref('base.res_partner_1') + final_location = partner_demo_customer.property_stock_customer + product_a = self.env['product.product'].create({ + 'name': 'ProductA', + 'type': 'product', + }) + + self.env['stock.quant']._update_available_quantity(product_a, warehouse.wh_output_stock_loc_id, 4.0) + + # We set quantities in the stock location to avoid warnings + # triggered by '_onchange_product_id_check_availability' + self.env['stock.quant']._update_available_quantity(product_a, warehouse.lot_stock_id, 4.0) + + # We alter one rule and we set it to 'mts_else_mto' + values = {'warehouse_id': warehouse} + rule = self.env['procurement.group']._get_rule(product_a, final_location, values) + rule.procure_method = 'mts_else_mto' + + pg = self.env['procurement.group'].create({'name': 'Test-pg-mtso-mto'}) + + self.env['procurement.group'].run([ + pg.Procurement( + product_a, + 5.0, + product_a.uom_id, + final_location, + 'test_mtso_mto', + 'test_mtso_mto', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg + } + ) + ]) + + qty_available = self.env['stock.quant']._get_available_quantity(product_a, warehouse.wh_output_stock_loc_id) + + # 3 pickings should be created. + picking_ids = self.env['stock.picking'].search([('group_id', '=', pg.id)]) + self.assertEquals(len(picking_ids), 3) + for picking in picking_ids: + # Only the picking from Stock to Pack should be MTS + if picking.location_id == warehouse.lot_stock_id: + self.assertEquals(picking.move_lines.procure_method, 'make_to_stock') + else: + self.assertEquals(picking.move_lines.procure_method, 'make_to_order') + + self.assertEquals(len(picking.move_lines), 1) + self.assertEquals(picking.move_lines.product_uom_qty, 5, 'The quantity of the move should be the same as on the SO') + self.assertEqual(qty_available, 4, 'The 4 products should still be available') + + def test_mtso_mts(self): + """ Run a procurement for 4 products when there are 4 in stock then + check that MTS is applied on the moves when the rule is set to 'mts_else_mto' + """ + warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse.delivery_steps = 'pick_pack_ship' + partner_demo_customer = self.env.ref('base.res_partner_1') + final_location = partner_demo_customer.property_stock_customer + product_a = self.env['product.product'].create({ + 'name': 'ProductA', + 'type': 'product', + }) + + self.env['stock.quant']._update_available_quantity(product_a, warehouse.wh_output_stock_loc_id, 4.0) + + # We alter one rule and we set it to 'mts_else_mto' + values = {'warehouse_id': warehouse} + rule = self.env['procurement.group']._get_rule(product_a, final_location, values) + rule.procure_method = 'mts_else_mto' + + pg = self.env['procurement.group'].create({'name': 'Test-pg-mtso-mts'}) + + self.env['procurement.group'].run([ + pg.Procurement( + product_a, + 4.0, + product_a.uom_id, + final_location, + 'test_mtso_mts', + 'test_mtso_mts', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg + } + ) + ]) + + # A picking should be created with its move having MTS as procure method. + picking_ids = self.env['stock.picking'].search([('group_id', '=', pg.id)]) + self.assertEquals(len(picking_ids), 1) + picking = picking_ids + self.assertEquals(picking.move_lines.procure_method, 'make_to_stock') + self.assertEquals(len(picking.move_lines), 1) + self.assertEquals(picking.move_lines.product_uom_qty, 4) + + def test_mtso_multi_pg(self): + """ Run 3 procurements for 2 products at the same times when there are 4 in stock then + check that MTS is applied on the moves when the rule is set to 'mts_else_mto' + """ + warehouse = self.env['stock.warehouse'].search([('company_id', '=', self.env.user.id)], limit=1) + warehouse.delivery_steps = 'pick_pack_ship' + partner_demo_customer = self.env.ref('base.res_partner_1') + final_location = partner_demo_customer.property_stock_customer + product_a = self.env['product.product'].create({ + 'name': 'ProductA', + 'type': 'product', + }) + + self.env['stock.quant']._update_available_quantity(product_a, warehouse.wh_output_stock_loc_id, 4.0) + + # We alter one rule and we set it to 'mts_else_mto' + values = {'warehouse_id': warehouse} + rule = self.env['procurement.group']._get_rule(product_a, final_location, values) + rule.procure_method = 'mts_else_mto' + + pg1 = self.env['procurement.group'].create({'name': 'Test-pg-mtso-mts-1'}) + pg2 = self.env['procurement.group'].create({'name': 'Test-pg-mtso-mts-2'}) + pg3 = self.env['procurement.group'].create({'name': 'Test-pg-mtso-mts-3'}) + + self.env['procurement.group'].run([ + pg1.Procurement( + product_a, + 2.0, + product_a.uom_id, + final_location, + 'test_mtso_mts_1', + 'test_mtso_mts_1', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg1 + } + ), + pg2.Procurement( + product_a, + 2.0, + product_a.uom_id, + final_location, + 'test_mtso_mts_2', + 'test_mtso_mts_2', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg2 + } + ), + pg3.Procurement( + product_a, + 2.0, + product_a.uom_id, + final_location, + 'test_mtso_mts_3', + 'test_mtso_mts_3', + warehouse.company_id, + { + 'warehouse_id': warehouse, + 'group_id': pg3 + } + ) + ]) + + pickings_pg1 = self.env['stock.picking'].search([('group_id', '=', pg1.id)]) + pickings_pg2 = self.env['stock.picking'].search([('group_id', '=', pg2.id)]) + pickings_pg3 = self.env['stock.picking'].search([('group_id', '=', pg3.id)]) + + # The 2 first procurements should have create only 1 picking since enough quantities + # are left in the delivery location + self.assertEquals(len(pickings_pg1), 1) + self.assertEquals(len(pickings_pg2), 1) + self.assertEquals(pickings_pg1.move_lines.procure_method, 'make_to_stock') + self.assertEquals(pickings_pg2.move_lines.procure_method, 'make_to_stock') + + # The last one should have 3 pickings as there's nothing left in the delivery location + self.assertEquals(len(pickings_pg3), 3) + for picking in pickings_pg3: + # Only the picking from Stock to Pack should be MTS + if picking.location_id == warehouse.lot_stock_id: + self.assertEquals(picking.move_lines.procure_method, 'make_to_stock') + else: + self.assertEquals(picking.move_lines.procure_method, 'make_to_order') + + # All the moves should be should have the same quantity as it is on each procurements + self.assertEquals(len(picking.move_lines), 1) + self.assertEquals(picking.move_lines.product_uom_qty, 2)