diff --git a/shopfloor_reception/data/shopfloor_scenario_data.xml b/shopfloor_reception/data/shopfloor_scenario_data.xml
index d9ef7cf264..639e3f66f1 100644
--- a/shopfloor_reception/data/shopfloor_scenario_data.xml
+++ b/shopfloor_reception/data/shopfloor_scenario_data.xml
@@ -9,7 +9,8 @@
{
"auto_post_line": true,
- "allow_return": true
+ "allow_return": true,
+ "allow_select_document_by_product": true
}
diff --git a/shopfloor_reception/models/__init__.py b/shopfloor_reception/models/__init__.py
index ae4c27227f..7d45a954fd 100644
--- a/shopfloor_reception/models/__init__.py
+++ b/shopfloor_reception/models/__init__.py
@@ -1 +1,2 @@
from . import stock_picking
+from . import shopfloor_menu
diff --git a/shopfloor_reception/models/shopfloor_menu.py b/shopfloor_reception/models/shopfloor_menu.py
new file mode 100644
index 0000000000..625f4ee606
--- /dev/null
+++ b/shopfloor_reception/models/shopfloor_menu.py
@@ -0,0 +1,28 @@
+# Copyright 2023 Camptocamp SA
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+from odoo import api, fields, models
+
+ALLOW_SELECT_DOCUMENT_BY_PRODUCT_HELP = """
+If enabled, users will be able to select transfers by product.
+"""
+
+
+class ShopfloorMenu(models.Model):
+ _inherit = "shopfloor.menu"
+
+ allow_select_document_by_product = fields.Boolean(
+ string="Allow select document by product",
+ default=False,
+ help=ALLOW_SELECT_DOCUMENT_BY_PRODUCT_HELP,
+ )
+ allow_select_document_by_product_is_possible = fields.Boolean(
+ compute="_compute_pick_pack_same_time_is_possible"
+ )
+
+ @api.depends("scenario_id")
+ def _compute_pick_pack_same_time_is_possible(self):
+ for menu in self:
+ menu.allow_select_document_by_product_is_possible = (
+ menu.scenario_id.has_option("allow_select_document_by_product")
+ )
diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py
index f1d421deb2..c6f99baeda 100644
--- a/shopfloor_reception/services/reception.py
+++ b/shopfloor_reception/services/reception.py
@@ -256,13 +256,8 @@ def _scan_line__find_or_create_line(self, picking, move, qty_done=1):
)
)
)
- if line:
- # The line quantity to do needs to correspond to
- # the remaining quantity to do of its move.
- line.product_uom_qty = move.product_uom_qty - move.quantity_done
- else:
- qty_todo_remaining = max(0, move.product_uom_qty - move.quantity_done)
- values = move._prepare_move_line_vals(quantity=qty_todo_remaining)
+ if not line:
+ values = move._prepare_move_line_vals()
line = self.env["stock.move.line"].create(values)
return self._scan_line__assign_user(picking, line, qty_done)
@@ -749,7 +744,7 @@ def _response_for_set_lot(self, picking, line, message=None):
message=message,
)
- def _align_product_uom_qties(self, move):
+ def _align_display_product_uom_qty(self, line, response):
# This method aligns product uom qties on move lines.
# In the shopfloor context, we might have multiple users working at
# the same time on the same move. This is done by creating one move line
@@ -768,25 +763,32 @@ def _align_product_uom_qties(self, move):
# If move is already done, do not update lines qties
# if move.state in ("done", "cancel"):
# return
-
+ move = line.move_id
qty_todo = move.product_uom_qty
- qty_done = sum(move.move_line_ids.mapped("qty_done"))
+ qty_done = 0.0
+ move_uom = move.product_uom
+ for move_line in move.move_line_ids:
+ # Use move's uom
+ qty_done += move_uom._compute_quantity(
+ move_line.qty_done, move_line.product_uom_id, round=False
+ )
rounding = move.product_id.uom_id.rounding
compare = float_compare(qty_done, qty_todo, precision_rounding=rounding)
- if compare < 1: # If qty done <= qty todo, align qty todo on move lines
+ if compare < 1: # If qty done < qty todo, align qty todo in the response
remaining_todo = qty_todo - qty_done
- # if we didn't bypass reservation update, the quant reservation
- # would be reduced as much as the deduced quantity, which is wrong
- # as we only moved the quantity to a new move line
- lines = move.move_line_ids.with_context(bypass_reservation_update=True)
- for line in lines:
- line.product_uom_qty = line.qty_done + remaining_todo
+ # Change back to line uom
+ line_todo = line.product_uom_id._compute_quantity(
+ line.qty_done + remaining_todo, move_uom, round=False
+ )
+ response["data"]["set_quantity"]["selected_move_line"][0][
+ "quantity"
+ ] = line_todo
+ return response
def _response_for_set_quantity(
self, picking, line, message=None, asking_confirmation=False
):
- self._align_product_uom_qties(line.move_id)
- return self._response(
+ response = self._response(
next_state="set_quantity",
data={
"selected_move_line": self._data_for_move_lines(line),
@@ -795,6 +797,7 @@ def _response_for_set_quantity(
},
message=message,
)
+ return self._align_display_product_uom_qty(line, response)
def _response_for_set_destination(self, picking, line, message=None):
return self._response(
@@ -839,7 +842,12 @@ def start(self):
def _scan_document__get_handlers_by_type(self):
return {
"picking": self._scan_document__by_picking,
- "product": self._scan_document__by_product,
+ # only add the handler if allow_select_document_by_product is enabled
+ "product": (
+ self._scan_document__by_product
+ if self.work.menu.allow_select_document_by_product
+ else None
+ ),
"packaging": self._scan_document__by_packaging,
"lot": self._scan_document__by_lot,
"origin_move": self._scan_document__by_origin_move,
@@ -1147,6 +1155,22 @@ def set_quantity(
)
return self._response_for_set_quantity(picking, selected_line)
+ def set_quantity__cancel_action(self, picking_id, selected_line_id):
+ picking = self.env["stock.picking"].browse(picking_id)
+ selected_line = self.env["stock.move.line"].browse(selected_line_id)
+ message = self._check_picking_status(picking)
+ if message:
+ return self._response_for_set_quantity(
+ picking, selected_line, message=message
+ )
+ if selected_line.exists():
+ if selected_line.product_uom_qty:
+ stock = self._actions_for("stock")
+ stock.unmark_move_line_as_picked(selected_line)
+ else:
+ selected_line.unlink()
+ return self._response_for_select_move(picking)
+
def _set_quantity__process__set_qty_and_split(self, picking, line, quantity):
move = line.move_id
sum(move.move_line_ids.mapped("qty_done"))
@@ -1216,6 +1240,7 @@ def process_without_pack(self, picking_id, selected_line_id, quantity):
return self._response_for_set_destination(picking, selected_line)
def _post_line(self, selected_line):
+ selected_line.product_uom_qty = selected_line.qty_done
if (
selected_line.picking_id.is_shopfloor_created
and self.work.menu.allow_return
@@ -1237,12 +1262,20 @@ def _post_shopfloor_created_line(self, selected_line):
)
def _auto_post_line(self, selected_line):
+ # If user only processed 1/5 and is the only one working on the move,
+ # then selected_line is the only one related to this move.
+ # In such case, we must ensure there's another move line with the remaining
+ # quantity to do, so selected_line is extracted in a new move as expected.
+ new_move_line = selected_line._split_partial_quantity()
new_move = selected_line.move_id.split_other_move_lines(
selected_line, intersection=True
)
if new_move:
# A new move is created in case of partial quantity
new_move.extract_and_action_done()
+ stock = self._actions_for("stock")
+ stock.unmark_move_line_as_picked(new_move_line)
+ new_move_line.location_dest_id = new_move_line.move_id.location_dest_id
return
# In case of full quantity, post the initial move
selected_line.move_id.extract_and_action_done()
@@ -1411,6 +1444,16 @@ def set_quantity(self):
"confirmation": {"type": "boolean"},
}
+ def set_quantity__cancel_action(self):
+ return {
+ "picking_id": {"coerce": to_int, "required": True, "type": "integer"},
+ "selected_line_id": {
+ "coerce": to_int,
+ "type": "integer",
+ "required": True,
+ },
+ }
+
def process_with_existing_pack(self):
return {
"picking_id": {"coerce": to_int, "required": True, "type": "integer"},
@@ -1542,6 +1585,9 @@ def _set_lot_next_states(self):
def _set_quantity_next_states(self):
return {"set_quantity", "select_move", "set_destination"}
+ def _set_quantity__cancel_action_next_states(self):
+ return {"set_quantity", "select_move"}
+
def _set_destination_next_states(self):
return {"set_destination", "select_move"}
@@ -1622,6 +1668,16 @@ def _schema_set_quantity(self):
},
}
+ @property
+ def _schema_set_quantity__cancel_action(self):
+ return {
+ "selected_move_line": {
+ "type": "list",
+ "schema": {"type": "dict", "schema": self.schemas.move_line()},
+ },
+ "picking": {"type": "dict", "schema": self.schemas.picking()},
+ }
+
@property
def _schema_set_destination(self):
return {
@@ -1699,6 +1755,11 @@ def set_lot_confirm_action(self):
def set_quantity(self):
return self._response_schema(next_states=self._set_quantity_next_states())
+ def set_quantity__cancel_action(self):
+ return self._response_schema(
+ next_states=self._set_quantity__cancel_action_next_states()
+ )
+
def process_with_existing_pack(self):
return self._response_schema(
next_states=self._process_with_existing_pack_next_states()
diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py
index 73da099b67..6811f10a71 100644
--- a/shopfloor_reception/tests/common.py
+++ b/shopfloor_reception/tests/common.py
@@ -49,6 +49,7 @@ def setUpClassVars(cls, *args, **kwargs):
cls.profile = cls.env.ref("shopfloor.profile_demo_1")
cls.picking_type = cls.menu.picking_type_ids
cls.wh = cls.picking_type.warehouse_id
+ cls._enable_allow_select_document_by_product()
def _data_for_move_lines(self, lines, **kw):
return self.data.move_lines(lines, **kw)
@@ -138,3 +139,34 @@ def _get_today_pickings(self):
],
order="scheduled_date ASC",
)
+
+ @classmethod
+ def _enable_allow_select_document_by_product(cls):
+ cls.menu.sudo().allow_select_document_by_product = True
+
+ def assertMessage(self, response, expected_message):
+ message = response.get("message")
+ for key, value in expected_message.items():
+ self.assertEqual(message.get(key), value)
+
+ @classmethod
+ def _get_move_ids_from_response(cls, response):
+ state = response.get("next_state")
+ data = response["data"][state]
+ picking_data = data.get("pickings") or [data.get("picking")]
+ moves_data = []
+ for picking in picking_data:
+ moves_data.extend(picking["moves"])
+ return [move["id"] for move in moves_data]
+
+ def _get_service_for_user(self, user):
+ user_env = self.env(user=user)
+ return self.get_service(
+ "reception", menu=self.menu, profile=self.profile, env=user_env
+ )
+
+ @classmethod
+ def _shopfloor_manager_values(cls):
+ vals = super()._shopfloor_manager_values()
+ vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])]
+ return vals
diff --git a/shopfloor_reception/tests/test_return_scan_line.py b/shopfloor_reception/tests/test_return_scan_line.py
index cb5a218048..859f0ea12b 100644
--- a/shopfloor_reception/tests/test_return_scan_line.py
+++ b/shopfloor_reception/tests/test_return_scan_line.py
@@ -88,15 +88,18 @@ def test_scan_packaging_in_delivery(self):
"barcode": self.product_a_packaging.barcode,
},
)
- data = self.data.picking(return_picking)
selected_move_line = self.get_new_move_lines()
+ move_line_data = self.data.move_lines(selected_move_line)
+ move_line_data[0]["quantity"] = 20.0
+ # Displayed qtu todo is modified by _align_display_product_uom_qty
+ data = self.data.picking(return_picking)
self.assert_response(
response,
next_state="set_quantity",
data={
"confirmation_required": False,
"picking": data,
- "selected_move_line": self.data.move_lines(selected_move_line),
+ "selected_move_line": move_line_data,
},
)
self.assertEqual(selected_move_line.qty_done, self.product_a_packaging.qty)
diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py
index ea50f0f365..f7899f3c1e 100644
--- a/shopfloor_reception/tests/test_select_move.py
+++ b/shopfloor_reception/tests/test_select_move.py
@@ -209,9 +209,20 @@ def test_assign_shopfloor_user_to_line(self):
self.assertEqual(other_move_line.shopfloor_user_id.id, False)
def test_create_new_line_none_available(self):
- # If all lines for a product are already assigned to a different user
- # and there's still qty todo remaining
- # a new line will be created for that qty todo.
+ # If there's already a move line for a given incoming move,
+ # we assigned the whole move's product_uom_qty to it.
+ # The reason for that is that when recomputing states for a given move
+ # if sum(move.move_line_ids.product_uom_qty) != move.product_uom_qty,
+ # then it's state won't be assigned.
+ # For instance:
+ # - user 1 selects line1
+ # - user 2 selected line1 too
+ # - user 1 posts 20/40 goods
+ # - user 2 tries to process any qty, and it fails, because posting
+ # a move triggers the recompute of move's state
+ # To avoid that, the first created line gets
+ # product_uom_qty = move.product_uom_qty
+ # The next ones are getting 0.
picking = self._create_picking()
self.assertEqual(len(picking.move_line_ids), 2)
selected_move_line = picking.move_line_ids.filtered(
@@ -233,9 +244,11 @@ def test_create_new_line_none_available(self):
"barcode": self.product_a.barcode,
},
)
+ # A new line has been created
self.assertEqual(len(picking.move_line_ids), 3)
created_line = picking.move_line_ids[2]
- self.assertEqual(created_line.product_uom_qty, 7)
+ # And its product_uom_qty is 0
+ self.assertEqual(created_line.product_uom_qty, 0.0)
self.assertEqual(created_line.shopfloor_user_id.id, self.env.uid)
def test_done_action(self):
diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py
index a3b08ad5b3..25c3285d0c 100644
--- a/shopfloor_reception/tests/test_set_quantity.py
+++ b/shopfloor_reception/tests/test_set_quantity.py
@@ -382,18 +382,6 @@ def test_scan_new_package(self):
},
)
- @classmethod
- def _shopfloor_manager_values(cls):
- vals = super()._shopfloor_manager_values()
- vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])]
- return vals
-
- def _get_service_for_user(self, user):
- user_env = self.env(user=user)
- return self.get_service(
- "reception", menu=self.menu, profile=self.profile, env=user_env
- )
-
def test_concurrent_update(self):
# We're testing that move line's product uom qties are updated correctly
# when users are workng on the same move in parallel
@@ -408,6 +396,10 @@ def test_concurrent_update(self):
)
self.assertEqual(len(selected_move_line), 1)
self.assertEqual(selected_move_line.qty_done, 1.0)
+ self.assertEqual(
+ selected_move_line.product_uom_qty,
+ selected_move_line.move_id.product_uom_qty,
+ )
# Let's make the first user work a little bit, and pick a total of 4 units
for __ in range(4):
@@ -431,10 +423,13 @@ def test_concurrent_update(self):
"scan_line",
params={"picking_id": picking.id, "barcode": self.product_a.barcode},
)
+ # The whole move's product_uom_qty has been assigned to the first created line.
+ # The new one gets 0.0
new_line = picking.move_line_ids.filtered(
lambda l: l.product_id == self.product_a
and l.shopfloor_user_id == manager_user
)
+ self.assertEqual(new_line.product_uom_qty, 0.0)
move_lines = selected_move_line | new_line
line_service_mapping = [
@@ -448,10 +443,6 @@ def test_concurrent_update(self):
self.assertEqual(lines_qty_done, 6.0)
# should be equal to the moves quantity_done
self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done)
- # And also, the remaining qty is 4.0, then for each move line,
- # product_uom_qty = line.qty_done + move's remaining_qty
- for line in move_lines:
- self.assertEqual(line.product_uom_qty, line.qty_done + 4.0)
# Now, let the new user finish its work
for __ in range(4):
@@ -470,10 +461,12 @@ def test_concurrent_update(self):
self.assertEqual(lines_qty_done, 10.0)
self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done)
- # And also, the product_uom_qty should be == qty_done == 5.0
- for line in move_lines:
- self.assertEqual(line.product_uom_qty, 5.0)
- self.assertEqual(line.product_uom_qty, line.qty_done)
+ # However, product_uom_qty hasn't changed
+ self.assertEqual(selected_move_line.product_uom_qty, 10.0)
+ self.assertEqual(new_line.product_uom_qty, 0.0)
+ # And what's important is that the sum of lines's product_uom_qty is
+ # always == move's product_uom_qty
+ self.assertEqual(sum(move_lines.mapped("product_uom_qty")), 10.0)
# However, if we pick more than move's product_uom_qty, then lines
# product_uom_qty isn't updated, in order to be able to display an error
@@ -536,10 +529,11 @@ def test_concurrent_update(self):
)
self.assertEqual(selected_move_line.qty_done, 3.0)
- self.assertEqual(selected_move_line.product_uom_qty, 3.0)
-
self.assertEqual(new_line.qty_done, 7.0)
- self.assertEqual(new_line.product_uom_qty, 7.0)
+
+ self.assertEqual(selected_move_line.product_uom_qty, 10.0)
+ self.assertEqual(new_line.product_uom_qty, 0.0)
+ self.assertEqual(sum(move_lines.mapped("product_uom_qty")), 10.0)
# And everything's fine on the move
move = move_lines.move_id
@@ -590,3 +584,199 @@ def test_split_move_line(self):
lambda l: l.product_id == self.product_a
)
self.assertEqual(len(move_lines), 3)
+
+ def test_concurrent_update_2(self):
+ self.menu.sudo().auto_post_line = True
+ self.input_location.sudo().active = True
+ # Test related to picking being set to "ready" once the first user posts
+ # its move line, hence the picking being not available in shopfloor afterwards.
+
+ # The reason for that is that `_post_line` calls `_recompute_state`.
+ # If at this point there's more or less reserved qty than what's been ordered
+ # then state isn't computed as assigned.
+
+ # This test ensure that this isn't the case anymore.
+
+ # Creating the picking, selecting the move line.
+ picking = self._create_picking()
+ service_user_1 = self.service
+ service_user_1.dispatch("scan_document", params={"barcode": picking.name})
+ service_user_1.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ move_line_user_1 = picking.move_line_ids.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # The only move line should have qty_done = 1
+ self.assertEqual(move_line_user_1.qty_done, 1.0)
+ self.assertEqual(move_line_user_1.product_uom_qty, 10.0)
+
+ # Now, concurrently pick products with another user for the same move
+ manager_user = self.shopfloor_manager
+ service_user_2 = self._get_service_for_user(manager_user)
+ service_user_2.dispatch("scan_document", params={"barcode": picking.name})
+ service_user_2.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ # The whole move's product_uom_qty has been assigned to the first created line.
+ # The new one gets 0.0
+ move_line_user_2 = picking.move_line_ids.filtered(
+ lambda l: l.product_id == self.product_a
+ and l.shopfloor_user_id == manager_user
+ )
+ self.assertEqual(move_line_user_2.product_uom_qty, 0.0)
+ self.assertEqual(move_line_user_2.qty_done, 1.0)
+
+ # At this point, both lines are referencing the same move
+ self.assertEqual(move_line_user_2.move_id, move_line_user_1.move_id)
+
+ # A new move / picking will be created after it is posted.
+ # store the list of pickings to find it out after it is posted
+ # moves before
+ lines_before = self.env["stock.move.line"].search([])
+
+ # Now, post user_1 move line
+ response = service_user_1.dispatch(
+ "process_with_new_pack",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "quantity": move_line_user_1.qty_done,
+ },
+ )
+ picking_data = self.data.picking(picking)
+ self.assert_response(
+ response,
+ next_state="set_destination",
+ data={
+ "picking": picking_data,
+ "selected_move_line": self.data.move_lines(move_line_user_1),
+ },
+ )
+
+ response = self.service.dispatch(
+ "set_destination",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "location_name": self.input_location.name,
+ },
+ )
+ lines_after = self.env["stock.move.line"].search(
+ [("id", "not in", lines_before.ids)]
+ )
+ # After move_line is posted, its state is done, and its qty_done is 1.0
+ self.assertEqual(move_line_user_1.state, "done")
+
+ # The remaining one is still assigned
+ self.assertEqual(move_line_user_2.state, "assigned")
+ # As well as the new one
+ self.assertEqual(len(lines_after), 1)
+
+ # And the total remaining qty to be done is 9.0 (10.0 - 1.0)
+ self.assertEqual(
+ lines_after.product_uom_qty + move_line_user_2.product_uom_qty, 9.0
+ )
+
+ def test_move_states(self):
+ # as only assigned moves can be posted, we need to ensure that
+ # we got the right states in any case, especially when users are working
+ # concurrently
+ picking = self._create_picking()
+ move_product_a = picking.move_lines.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # user1 processes 10 units
+ move_line_user_1 = move_product_a.move_line_ids
+ service_user_1 = self.service
+ service_user_1.dispatch("scan_document", params={"barcode": picking.name})
+ service_user_1.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ response = service_user_1.dispatch(
+ "set_quantity",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "quantity": move_product_a.product_qty - 1,
+ "barcode": self.product_a.barcode,
+ },
+ )
+ # user2 selects the same picking
+ user2 = self.shopfloor_manager
+ service_user_2 = self._get_service_for_user(user2)
+ response = service_user_2.dispatch(
+ "scan_document", params={"barcode": picking.name}
+ )
+ # And the same line
+ service_user_2.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ move_line_user_2 = move_product_a.move_line_ids - move_line_user_1
+ # user1 shouldn't be able to process his move, since
+ # move qty_done > move product_qty
+ response = service_user_1.dispatch(
+ "process_with_new_pack",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "quantity": 10.0,
+ },
+ )
+ #
+ expected_message = {
+ "body": "You cannot process that much units.",
+ "message_type": "error",
+ }
+ self.assertMessage(response, expected_message)
+ # user1 cancels the operation
+ service_user_1.dispatch(
+ "set_quantity__cancel_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ },
+ )
+ self.assertFalse(move_line_user_1.shopfloor_user_id)
+ self.assertEqual(move_line_user_1.qty_done, 0)
+ # User2 should be able to process 1 unit
+ response = service_user_2.dispatch(
+ "process_with_new_pack",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_2.id,
+ "quantity": 1.0,
+ },
+ )
+ data = self.data.picking(picking)
+ self.assert_response(
+ response,
+ next_state="set_destination",
+ data={
+ "picking": data,
+ "selected_move_line": self.data.move_lines(move_line_user_2),
+ },
+ )
+ self.assertEqual(move_product_a.quantity_done, 1.0)
+ response = service_user_2.dispatch(
+ "set_destination",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_2.id,
+ "location_name": self.dispatch_location.name,
+ },
+ )
+ # When posted, the move line product_uom_qty has been set to qty_done
+ self.assertEqual(move_line_user_2.qty_done, move_line_user_2.product_qty)
+ self.assert_response(
+ response, next_state="select_move", data=self._data_for_select_move(picking)
+ )
+ # Now, user1 can start working on this again
+ service_user_1.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
diff --git a/shopfloor_reception/tests/test_set_quantity_action.py b/shopfloor_reception/tests/test_set_quantity_action.py
index 7cf981eacc..8881e64a5f 100644
--- a/shopfloor_reception/tests/test_set_quantity_action.py
+++ b/shopfloor_reception/tests/test_set_quantity_action.py
@@ -84,3 +84,93 @@ def test_process_without_package(self):
},
)
self.assertFalse(self.selected_move_line.result_package_id)
+
+ def test_cancel_action(self):
+ picking = self._create_picking()
+ move_product_a = picking.move_lines.filtered(
+ lambda l: l.product_id == self.product_a
+ )
+ # User 1 and 2 selects the same picking
+ service_user_1 = self.service
+ service_user_1.dispatch("scan_document", params={"barcode": picking.name})
+ user2 = self.shopfloor_manager
+ service_user_2 = self._get_service_for_user(user2)
+ response = service_user_2.dispatch(
+ "scan_document", params={"barcode": picking.name}
+ )
+ # both users selects the same move
+ service_user_1.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ move_line_user_1 = move_product_a.move_line_ids
+ service_user_2.dispatch(
+ "scan_line",
+ params={"picking_id": picking.id, "barcode": self.product_a.barcode},
+ )
+ move_line_user_2 = move_product_a.move_line_ids - move_line_user_1
+ # And both sets the qty done to 10
+ service_user_1.dispatch(
+ "set_quantity",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "quantity": 10,
+ },
+ )
+ service_user_2.dispatch(
+ "set_quantity",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_2.id,
+ "quantity": 10,
+ },
+ )
+ # Users are blocked, product_uom_qty is 10, but both users have qty_done=10
+ # on their move line, therefore, none of them can confirm
+ expected_message = {
+ "body": "You cannot process that much units.",
+ "message_type": "error",
+ }
+ response = service_user_1.dispatch(
+ "process_with_new_pack",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ "quantity": 10.0,
+ },
+ )
+ self.assertMessage(response, expected_message)
+ response = service_user_2.dispatch(
+ "process_with_new_pack",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_2.id,
+ "quantity": 10.0,
+ },
+ )
+ self.assertMessage(response, expected_message)
+ # make user1 cancel
+ service_user_1.dispatch(
+ "set_quantity__cancel_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_1.id,
+ },
+ )
+ # Since we reused the move line created by odoo for the first user, we only
+ # reset the line
+ self.assertTrue(move_line_user_1.exists())
+ self.assertFalse(move_line_user_1.shopfloor_user_id)
+ self.assertEqual(move_line_user_1.qty_done, 0)
+ self.assertEqual(move_line_user_1.product_uom_qty, 10)
+ # make user cancel
+ service_user_2.dispatch(
+ "set_quantity__cancel_action",
+ params={
+ "picking_id": picking.id,
+ "selected_line_id": move_line_user_2.id,
+ },
+ )
+ # This line has been created by shopfloor, therefore, we unlinked it
+ self.assertFalse(move_line_user_2.exists())
diff --git a/shopfloor_reception_mobile/static/src/scenario/reception.js b/shopfloor_reception_mobile/static/src/scenario/reception.js
index f366d422d9..38d2531eca 100644
--- a/shopfloor_reception_mobile/static/src/scenario/reception.js
+++ b/shopfloor_reception_mobile/static/src/scenario/reception.js
@@ -153,6 +153,7 @@ const Reception = {
+
diff --git a/shopfloor_reception_mobile/static/src/scenario/reception_states.js b/shopfloor_reception_mobile/static/src/scenario/reception_states.js
index fca90f6e80..4a2438a87d 100644
--- a/shopfloor_reception_mobile/static/src/scenario/reception_states.js
+++ b/shopfloor_reception_mobile/static/src/scenario/reception_states.js
@@ -171,6 +171,7 @@ export const reception_states = function () {
events: {
qty_edit: "on_qty_edit",
go_back: "on_back",
+ cancel: "on_cancel",
},
on_qty_edit: (qty) => {
this.scan_destination_qty = parseInt(qty, 10);
@@ -188,14 +189,12 @@ export const reception_states = function () {
);
},
on_cancel: () => {
- // TODO: this endpoing is currently missing in the backend,
- // and it's currently in the roadmap.
- // Once it's implemented, uncomment this call.
- // this.wait_call(
- // this.odoo.call("cancel", {
- // package_level_id: this.state.data.id,
- // })
- // );
+ this.wait_call(
+ this.odoo.call("set_quantity__cancel_action", {
+ picking_id: this.state.data.picking.id,
+ selected_line_id: this.line_being_handled.id,
+ })
+ );
},
on_add_to_existing_pack: () => {
this.wait_call(