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