diff --git a/shopfloor/__manifest__.py b/shopfloor/__manifest__.py index e33c27458d..5ec4e0340f 100644 --- a/shopfloor/__manifest__.py +++ b/shopfloor/__manifest__.py @@ -6,7 +6,7 @@ { "name": "Shopfloor", "summary": "manage warehouse operations with barcode scanners", - "version": "16.0.2.24.0", + "version": "16.0.2.24.1", "development_status": "Beta", "category": "Inventory", "website": "https://github.com/OCA/wms", diff --git a/shopfloor/actions/message.py b/shopfloor/actions/message.py index 5835e401d8..5bb668ba21 100644 --- a/shopfloor/actions/message.py +++ b/shopfloor/actions/message.py @@ -911,7 +911,10 @@ def lines_different_dest_location(self): def new_move_lines_not_assigned(self): return { "message_type": "error", - "body": _("New move lines cannot be assigned: canceled."), + "body": _( + "Some products in the source location are already reserved. " + "It's impossible to reserve them!" + ), } def package_open(self): diff --git a/shopfloor/data/shopfloor_scenario_data.xml b/shopfloor/data/shopfloor_scenario_data.xml index 5ac71784aa..b9355a241e 100644 --- a/shopfloor/data/shopfloor_scenario_data.xml +++ b/shopfloor/data/shopfloor_scenario_data.xml @@ -74,7 +74,8 @@ "no_prefill_qty": true, "allow_move_line_search_sort_order": true, "allow_move_line_search_additional_domain": true, - "allow_quantity_exceeding_demand": true + "allow_quantity_exceeding_demand": true, + "allow_reserve_only_available": true } diff --git a/shopfloor/migrations/16.0.2.24.1/post-migrate.py b/shopfloor/migrations/16.0.2.24.1/post-migrate.py new file mode 100644 index 0000000000..45057a0242 --- /dev/null +++ b/shopfloor/migrations/16.0.2.24.1/post-migrate.py @@ -0,0 +1,25 @@ +import json +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + location_content_transfer = env.ref("shopfloor.scenario_location_content_transfer") + _update_scenario_options(location_content_transfer) + + +def _update_scenario_options(scenario): + options = scenario.options + options["allow_reserve_only_available"] = True + options_edit = json.dumps(options or {}, indent=4, sort_keys=True) + scenario.write({"options_edit": options_edit}) + _logger.info( + "Option 'allow_reserve_only_available' added to scenario %s", + scenario.name, + ) diff --git a/shopfloor/models/shopfloor_menu.py b/shopfloor/models/shopfloor_menu.py index 28b63ed35f..6ea645a19e 100644 --- a/shopfloor/models/shopfloor_menu.py +++ b/shopfloor/models/shopfloor_menu.py @@ -81,6 +81,13 @@ class ShopfloorMenu(models.Model): help="If you tick this box, this scenario will allow operator to move" " goods even if a reservation is made by a different operation type.", ) + reserve_only_available_is_possible = fields.Boolean( + compute="_compute_reserve_only_available_is_possible" + ) + allow_reserve_only_available = fields.Boolean( + string="Allow to reserve only available quantities on source location", + help="Check this if you want the scenario to reserve only available quantities", + ) ignore_no_putaway_available_is_possible = fields.Boolean( compute="_compute_ignore_no_putaway_available_is_possible" ) @@ -337,6 +344,13 @@ def _check_options(self): ) ) + @api.depends("scenario_id", "picking_type_ids") + def _compute_reserve_only_available_is_possible(self): + for menu in self: + menu.reserve_only_available_is_possible = bool( + menu.scenario_id.has_option("allow_reserve_only_available") + ) + @api.depends("scenario_id", "picking_type_ids") def _compute_move_create_is_possible(self): for menu in self: diff --git a/shopfloor/services/location_content_transfer.py b/shopfloor/services/location_content_transfer.py index 20be2ed373..0bde47d86b 100644 --- a/shopfloor/services/location_content_transfer.py +++ b/shopfloor/services/location_content_transfer.py @@ -4,6 +4,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo import _ from odoo.fields import first +from odoo.tools import float_compare from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component @@ -218,14 +219,28 @@ def _create_moves_from_location(self, location): # create moves for each quant picking_type = self.picking_types move_vals_list = [] + allow_reserve_only_available = self.work.menu.allow_reserve_only_available for quant in quants: + if ( + allow_reserve_only_available + and not float_compare( + quant.available_quantity, + 0.0, + precision_rounding=quant.product_uom_id.rounding, + ) + > 0 + ): + # Don't take fully reserved quants + continue move_vals_list.append( { "name": quant.product_id.name, "company_id": picking_type.company_id.id, "product_id": quant.product_id.id, "product_uom": quant.product_uom_id.id, - "product_uom_qty": quant.quantity, + "product_uom_qty": quant.available_quantity + if allow_reserve_only_available + else quant.quantity, "location_id": location.id, "location_dest_id": picking_type.default_location_dest_id.id, "origin": self.work.menu.name, diff --git a/shopfloor/tests/test_location_content_transfer_mix.py b/shopfloor/tests/test_location_content_transfer_mix.py index 2550f739d5..b7182b88fa 100644 --- a/shopfloor/tests/test_location_content_transfer_mix.py +++ b/shopfloor/tests/test_location_content_transfer_mix.py @@ -384,6 +384,158 @@ def test_with_zone_picking2(self): self.assertEqual(picking_before, picking_after) self.assert_response_scan_destination_all(response, picking_after) + def test_with_zone_picking2_only_available(self): + """Test the following scenario: + + 1) Operator-1 processes the first pallet with the "zone picking" scenario + to move the goods to PACK-1 and unload in destination location1: + + move1 PICK -> PACK-1 'done' + + 2) Operator-1 processes the second pallet with the "zone picking" scenario + to move the goods to PACK-2 and unload in destination location2: + + move1 PICK -> PACK-2 'done' + + 3) Operator-2 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - the app should found one move line + - this move line will be put in its own transfer as its sibling lines + are in another source location + - as such the app should ask the destination location (as there is + only one line) + + move1 PACK-2 -> SHIP (still handled by the operator so not 'done') + + 4) Operator-3 with the "location content transfer" scenario scan + the location where the first pallet is (PACK-1): + - nothing is found as the pallet is currently handled by Operator-2 + - message to user should be the location is empty + + 5) If Operator-2 is unable to finish the flow with the first pallet + (barcode device out of battery... etc), he should be able to recover + what he started. + + 6) Operator-2 then finishes its operation regarding the first pallet, and + scan the location where the second pallet is (PACK-2). He should find + only this pallet available. + """ + self.menu.sudo().allow_reserve_only_available = True + move_lines = self.picking1.move_line_ids + pick_move_line1 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_1 + ) + pick_move_line2 = move_lines.filtered( + lambda ml: ml.result_package_id == self.package_2 + ) + # Operator-1 process the first pallet with the "zone picking" scenario + orig_dest_location = pick_move_line1.location_dest_id + dest_location1 = pick_move_line1.location_dest_id.sudo().copy( + { + "name": orig_dest_location.name + "_1", + "barcode": orig_dest_location.barcode + "_1", + "location_id": orig_dest_location.id, + } + ) + self._zone_picking_process_line(pick_move_line1, dest_location=dest_location1) + # Operator-1 process the second pallet with the "zone picking" scenario + dest_location2 = orig_dest_location.sudo().copy( + { + "name": orig_dest_location.name + "_2", + "barcode": orig_dest_location.barcode + "_2", + "location_id": orig_dest_location.id, + } + ) + self._zone_picking_process_line(pick_move_line2, dest_location=dest_location2) + pack_move_a = pick_move_line1.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_first_pallet = pack_move_a.move_line_ids.filtered( + lambda x: not x.shopfloor_user_id and x.location_id == dest_location1 + ) + self.assertEqual(pack_first_pallet.reserved_uom_qty, 6) + self.assertEqual(pack_first_pallet.qty_done, 0) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda x: not x.shopfloor_user_id and x.location_id == dest_location2 + ) + self.assertEqual(pack_second_pallet.reserved_uom_qty, 4) + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 with the "location content transfer" scenario scan + # the location where the first pallet is. + # This pallet/move line will be put in its own transfer as its sibling + # lines are in another source location. + previous_picking = pack_first_pallet.picking_id + response = self._location_content_transfer_process_line(pack_first_pallet) + new_picking = pack_first_pallet.picking_id + self.assertTrue(previous_picking != new_picking) + self.assert_response_scan_destination_all(response, new_picking) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_first_pallet.package_id.id + ) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-3 with the "location content transfer" scenario scan + # the location where the first pallet is: he should found nothing + response = self._location_content_transfer_process_line( + pack_first_pallet, user=self.stock_user2 + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_empty( + pack_first_pallet.location_id + ), + ) + # Check if Operator-2 is able to recover its session + expected_picking = pack_first_pallet.picking_id + response = self.service.start_or_recover() + self.assert_response_scan_destination_all( + response, + expected_picking, + message=self.service.msg_store.recovered_previous_session(), + ) + # Operator-2 finishes its operation regarding the first pallet + qty = pack_first_pallet.reserved_uom_qty + response = self.service.set_destination_all( + pack_first_pallet.location_id.id, pack_first_pallet.location_dest_id.barcode + ) + self.assert_response_start( + response, + message=self.service.msg_store.location_content_transfer_complete( + pack_first_pallet.location_id, + pack_first_pallet.location_dest_id, + ), + ) + self.assertEqual(pack_first_pallet.qty_done, 6) + self.assertEqual(pack_first_pallet.state, "done") + self.assertEqual(pack_first_pallet.move_id.product_uom_qty, qty) + # Ensure that the second pallet is untouched + self.assertEqual(pack_second_pallet.qty_done, 0) + # Operator-2 (still with the "location content transfer" scenario) scan + # the location where the second pallet is + pack_move_a = pick_move_line2.move_id.move_dest_ids.filtered( + lambda m: m.state not in ("cancel", "done") + ) + self.assertEqual(pack_move_a, self.pack_move_a) + pack_second_pallet = pack_move_a.move_line_ids.filtered( + lambda x: not x.shopfloor_user_id and x.location_id == dest_location2 + ) + picking_before = pack_second_pallet.picking_id + move_lines = self.service.search_move_line.search_move_lines( + locations=pack_second_pallet.location_id + ) + response = self._location_content_transfer_process_line(pack_second_pallet) + response_packages = response["data"]["scan_destination_all"]["package_levels"] + self.assertEqual(len(response_packages), 1) + self.assertEqual( + response_packages[0]["package_src"]["id"], pack_second_pallet.package_id.id + ) + picking_after = pack_second_pallet.picking_id + self.assertEqual(picking_before, picking_after) + self.assert_response_scan_destination_all(response, picking_after) + def test_with_zone_picking3(self): """Test the following scenario: diff --git a/shopfloor/tests/test_location_content_transfer_start.py b/shopfloor/tests/test_location_content_transfer_start.py index 62bc1fcd6a..cc17cf6977 100644 --- a/shopfloor/tests/test_location_content_transfer_start.py +++ b/shopfloor/tests/test_location_content_transfer_start.py @@ -380,3 +380,76 @@ def test_scan_location_create_moves(self): self.product_a | self.product_b | self.product_c | self.product_d, ) self.assertEqual(picking.state, "assigned") + + def test_scan_location_create_moves_partially_available(self): + """The scanned location has no move lines but has some quants to move.""" + picking_type = self.menu.picking_type_ids + # product_a alone + self.env["stock.quant"]._update_available_quantity( + self.product_a, + self.content_loc, + 10, + ) + + self.env["stock.quant"]._update_available_quantity( + self.product_b, self.content_loc, 10 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_c, self.content_loc, 5 + ) + self.env["stock.quant"]._update_available_quantity( + self.product_d, self.content_loc, 5 + ) + + # Create a move that reserve partially product b + move = self.env["stock.move"].create( + { + "product_id": self.product_b.id, + "name": self.product_b.name, + "product_uom_qty": 3.0, + "location_id": self.content_loc.id, + "location_dest_id": self.stock_location.id, + } + ) + move._action_confirm() + move._action_assign() + + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + self.assert_response( + response, + "scan_location", + message=self.service.msg_store.new_move_lines_not_assigned(), + ) + picking = self.env["stock.picking"].search( + [("picking_type_id", "=", picking_type.id)] + ) + self.assertEqual(len(picking), 0) + + self.menu.sudo().allow_reserve_only_available = True + + response = self.service.dispatch( + "scan_location", params={"barcode": self.content_loc.barcode} + ) + picking = self.env["stock.picking"].search( + [("picking_type_id", "=", picking_type.id)] + ) + + self.assert_response_scan_destination_all(response, picking) + move_line_id = response["data"]["scan_destination_all"]["move_lines"][0]["id"] + package_levels = response["data"]["scan_destination_all"]["package_levels"] + self.assertFalse(package_levels) + self.assertIn(move_line_id, picking.move_line_ids.ids) + + self.assertEqual(picking.state, "assigned") + + # Check the product b has 7.0 of reserved quantity + line_b_id = [ + line["id"] + for line in response["data"]["scan_destination_all"]["move_lines"] + if line["product"]["id"] == self.product_b.id + ] + line_b = self.env["stock.move.line"].browse(line_b_id) + self.assertTrue(line_b) + self.assertEqual(7.0, line_b.reserved_qty) diff --git a/shopfloor/views/shopfloor_menu.xml b/shopfloor/views/shopfloor_menu.xml index a210b791ed..97f9b49d96 100644 --- a/shopfloor/views/shopfloor_menu.xml +++ b/shopfloor/views/shopfloor_menu.xml @@ -34,6 +34,14 @@ + + + + +