diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index ec16d741828..5f80e15f888 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -71,6 +71,7 @@ Contributors * Jacques-Etienne Baudoux (BCIM) * Michael Tietz (MT Software) * Souheil Bejaoui +* Nicolas Delbovier (Acsone) Maintainers ~~~~~~~~~~~ diff --git a/shopfloor_reception/readme/CONTRIBUTORS.rst b/shopfloor_reception/readme/CONTRIBUTORS.rst index 331a33b7ccc..355f8803388 100644 --- a/shopfloor_reception/readme/CONTRIBUTORS.rst +++ b/shopfloor_reception/readme/CONTRIBUTORS.rst @@ -2,4 +2,5 @@ * Juan Miguel Sánchez Arce * Jacques-Etienne Baudoux (BCIM) * Michael Tietz (MT Software) -* Souheil Bejaoui \ No newline at end of file +* Souheil Bejaoui +* Nicolas Delbovier (Acsone) \ No newline at end of file diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py index 36087b3b18b..9cf03df3a4e 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -605,10 +605,43 @@ def _check_move_available(self, move, message_code="product") -> bool: return self.msg_store.move_already_done() return False - def _set_quantity__check_quantity_done(self, selected_line): + def _set_quantity__check_quantity_done(self, selected_line, new_qty_done=None): + """ + Compare the total quantity done of a stock move with its expected quantity. + + This function calculates the total quantity done for a stock move, including a new + quantity for a specific move line, and compares it with the move's + `product_uom_qty`. + + Input: + selected_line: The `stock.move.line` record being updated. + new_qty_done: The new quantity to set on `selected_line`. If None, + use the `qty_done` of the selected line. + + Output: + An integer representing the comparison result: + - 1: The total quantity done exceeds the expected quantity. + - 0: The total quantity done equals the expected quantity. + - -1: The total quantity done is less than the expected quantity. + """ move = selected_line.move_id max_qty_done = move.product_uom_qty - qty_done = sum(move.move_line_ids.mapped("qty_done")) + + # In case `new_qty_done` is set, use this instead of `selected_line.qty_done` + # + # This enables to compute the expected total qty_done on the move before + # apply the new qty done to the selected line in order to avoid having to + # potentially rollback this value afterwards + if new_qty_done: + qty_done = ( + sum( + [m.qty_done for m in move.move_line_ids if m.id != selected_line.id] + ) + + new_qty_done + ) + else: + qty_done = sum(move.move_line_ids.mapped("qty_done")) + rounding = selected_line.product_uom_id.rounding return float_compare(qty_done, max_qty_done, precision_rounding=rounding) @@ -703,7 +736,7 @@ def _set_package_on_move_line(self, picking, line, package): return self._response_for_set_quantity(picking, line, message=message) quantity = line.qty_done response = self._set_quantity__process__set_qty_and_split( - picking, line, quantity + picking, line, quantity, "_set_package_on_move_line" ) if response: return response @@ -913,6 +946,42 @@ def _response_for_set_quantity( ) return self._align_display_product_uom_qty(line, response) + def _response_for_confirm_over_reception( + self, + picking, + line, + quantity, + action, + message, + ): + """ + Create a response message to send user to 'confirm_over_reception' UI state. + + Input: + picking: the picking being processed + line: the line being processed + quantity: the quantity entered by the worker + action: the action function the user is coming from + (e.g. "process_without_pack", "process_with_new_pack", + "process_with_existing_pack") + message: the warning message to show in the UI + + Transitions: + - set_quantity: a bigger qty than expected has been entered and + user still pressed on an action button. + """ + response = self._response( + next_state="confirm_over_reception", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self._data_for_stock_picking(picking, with_lines=True), + "quantity": quantity, + "action": action, + }, + message=message, + ) + return response + def _response_for_set_destination( self, picking, line, message=None, confirmation=None ): @@ -1391,17 +1460,44 @@ def set_quantity__cancel_action(self, picking_id, selected_line_id): selected_line.unlink() return self._response_for_select_move(picking) - def _set_quantity__process__set_qty_and_split(self, picking, line, quantity): - savepoint = self._actions_for("savepoint").new() - line.qty_done = quantity - compare = self._set_quantity__check_quantity_done(line) - if compare == 1: - # If move's qty_done > to move's qty_todo, rollback and return an error - savepoint.rollback() - return self._response_for_set_quantity( - picking, line, message=self.msg_store.unable_to_pick_qty() + def _after_over_reception_confirmed_hook(self, picking, line): + """ + Post-processing hook for handling over-reception. + + This hook function is called when a user confirms an over-reception on a picking. + It can be extended to implement custom business logic, such as: + - Creating a new helpdesk ticket for the supplier. + - Sending a notification to a specific team. + - Automatically adjusting the purchase order quantity. + """ + + def _set_quantity__process__set_qty_and_split( + self, picking, line, quantity, action=None, is_over_reception_confirmed=False + ): + """ + Input: + picking: the current picking being processed + line: the current move line being processed + quantity: the quantity entered by the user + action: the action function the user is coming from + (e.g. "process_without_pack", "process_with_new_pack", + "process_with_existing_pack") + is_over_reception_confirmed: True if user already confirmed he wants + to receive more goods than expected. + """ + compare = self._set_quantity__check_quantity_done(line, quantity) + if compare == 1 and not is_over_reception_confirmed: + message = self._response_for_confirm_over_reception( + picking, + line, + quantity, + action, + message=self.msg_store.line_scanned_qty_done_higher_than_allowed(), ) - savepoint.release() + return message + else: + line.qty_done = quantity + # Only if total_qty_done < qty_todo, we split the move line if compare == -1: default_values = { @@ -1411,7 +1507,7 @@ def _set_quantity__process__set_qty_and_split(self, picking, line, quantity): } line._split_qty_to_be_done(quantity, **default_values) - def _process(self, picking, line, quantity): + def _process(self, picking, line, quantity, action, is_over_reception_confirmed): if message := self._check_picking_processible(picking): return self._response_for_set_quantity(picking, line, message=message) @@ -1423,29 +1519,53 @@ def _process(self, picking, line, quantity): ) response = self._set_quantity__process__set_qty_and_split( - picking, line, quantity + picking, line, quantity, action, is_over_reception_confirmed ) return response - def process_with_existing_pack(self, picking_id, selected_line_id, quantity): + def process_with_existing_pack( + self, picking_id, selected_line_id, quantity, is_over_reception_confirmed=False + ): picking = self.env["stock.picking"].browse(picking_id) selected_line = self.env["stock.move.line"].browse(selected_line_id) - if response := self._process(picking, selected_line, quantity): + if response := self._process( + picking, + selected_line, + quantity, + action="process_with_existing_pack", + is_over_reception_confirmed=is_over_reception_confirmed, + ): return response return self._response_for_select_dest_package(picking, selected_line) - def process_with_new_pack(self, picking_id, selected_line_id, quantity): + def process_with_new_pack( + self, picking_id, selected_line_id, quantity, is_over_reception_confirmed=False + ): picking = self.env["stock.picking"].browse(picking_id) selected_line = self.env["stock.move.line"].browse(selected_line_id) - if response := self._process(picking, selected_line, quantity): + if response := self._process( + picking, + selected_line, + quantity, + action="process_with_new_pack", + is_over_reception_confirmed=is_over_reception_confirmed, + ): return response picking._put_in_pack(selected_line) return self._response_for_set_destination(picking, selected_line) - def process_without_pack(self, picking_id, selected_line_id, quantity): + def process_without_pack( + self, picking_id, selected_line_id, quantity, is_over_reception_confirmed=False + ): picking = self.env["stock.picking"].browse(picking_id) selected_line = self.env["stock.move.line"].browse(selected_line_id) - if response := self._process(picking, selected_line, quantity): + if response := self._process( + picking, + selected_line, + quantity, + action="process_without_pack", + is_over_reception_confirmed=is_over_reception_confirmed, + ): return response return self._response_for_set_destination(picking, selected_line) @@ -1701,6 +1821,7 @@ def set_quantity(self): "quantity": {"type": "float"}, "barcode": {"type": "string"}, "confirmation": {"type": "string", "nullable": True}, + "is_over_reception_confirmed": {"type": "boolean"}, } def set_quantity__cancel_action(self): @@ -1722,6 +1843,7 @@ def process_with_existing_pack(self): "required": True, }, "quantity": {"coerce": to_float, "type": "float"}, + "is_over_reception_confirmed": {"type": "boolean"}, } def process_with_new_pack(self): @@ -1733,6 +1855,7 @@ def process_with_new_pack(self): "required": True, }, "quantity": {"coerce": to_float, "type": "float"}, + "is_over_reception_confirmed": {"type": "boolean"}, } def process_without_pack(self): @@ -1744,6 +1867,7 @@ def process_without_pack(self): "required": True, }, "quantity": {"coerce": to_float, "type": "float"}, + "is_over_reception_confirmed": {"type": "boolean"}, } def set_destination(self): @@ -1797,6 +1921,7 @@ def _states(self): "manual_selection": self._schema_manual_selection, "select_move": self._schema_select_move, "confirm_done": self._schema_confirm_done, + "confirm_over_reception": self._schema_confirm_over_reception, "set_lot": self._schema_set_lot, "set_quantity": self._schema_set_quantity, "set_destination": self._schema_set_destination, @@ -1835,7 +1960,12 @@ def _scan_lot_next_states(self): return {"set_lot"} def _set_quantity_next_states(self): - return {"set_quantity", "select_move", "set_destination"} + return { + "set_quantity", + "select_move", + "set_destination", + "confirm_over_reception", + } def _set_quantity__cancel_action_next_states(self): return {"set_quantity", "select_move"} @@ -1850,13 +1980,13 @@ def _done_next_states(self): return {"select_document", "select_move", "confirm_done"} def _process_with_existing_pack_next_states(self): - return {"set_quantity", "select_dest_package"} + return {"set_quantity", "select_dest_package", "confirm_over_reception"} def _process_with_new_pack_next_states(self): - return {"set_quantity", "set_destination"} + return {"set_quantity", "set_destination", "confirm_over_reception"} def _process_without_pack_next_states(self): - return {"set_quantity", "set_destination"} + return {"set_quantity", "set_destination", "confirm_over_reception"} # SCHEMAS @@ -1917,6 +2047,20 @@ def _schema_set_quantity(self): }, } + @property + def _schema_confirm_over_reception(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ), + "quantity": {"type": "float", "required": True}, + "action": {"type": "string", "required": True}, + } + @property def _schema_set_quantity__cancel_action(self): return { diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html index 1f9fdbc0ffb..b6f6905b9b1 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -418,6 +418,7 @@

Contributors

  • Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
  • Michael Tietz (MT Software) <mtietz@mt-software.de>
  • Souheil Bejaoui <souheil.bejaoui@acsone.eu>
  • +
  • Nicolas Delbovier (Acsone) <nicolas.delbovier@acsone.eu>
  • diff --git a/shopfloor_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py index 106133b0a24..fb03341a402 100644 --- a/shopfloor_reception/tests/__init__.py +++ b/shopfloor_reception/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_return_reception_done from . import test_recover from . import test_scan_lot +from . import test_over_reception diff --git a/shopfloor_reception/tests/test_over_reception.py b/shopfloor_reception/tests/test_over_reception.py new file mode 100644 index 00000000000..6270d38ac8a --- /dev/null +++ b/shopfloor_reception/tests/test_over_reception.py @@ -0,0 +1,59 @@ +# Copyright 2025 ACSONE SA/NV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from .common import CommonCase + + +class TestOverReception(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Create a picking in an assigned state for the test + picking = cls._create_picking( + picking_type=cls.picking_type, + lines=[(cls.product_a, 10)], + confirm=True, + ) + cls.reception_picking = picking + cls.reception_line = picking.move_line_ids[0] + + def test_over_reception_confirmation_flow(self): + """ + Tests the complete flow of an over-reception: + 1. User attempts to process a quantity greater than expected. + 2. The UI transitions to the `confirm_over_reception` state. + 3. The user confirms the action + 4. The action is processed and the move line is updated. + """ + quantity_to_process = 15 # (More than expected) + + # 1. Simulate the user trying to process too much quantity + response = self.service.process_without_pack( + self.reception_picking.id, + self.reception_line.id, + quantity_to_process, + ) + + # 2. Assert the first state transition: we should be in a confirmation state + self.assertEqual(response["next_state"], "confirm_over_reception") + data = response["data"] + self.assertIn("confirm_over_reception", data) + confirm_over_reception_data = data["confirm_over_reception"] + self.assertEqual(confirm_over_reception_data["quantity"], quantity_to_process) + self.assertEqual(confirm_over_reception_data["action"], "process_without_pack") + self.assertEqual( + confirm_over_reception_data["picking"]["id"], self.reception_picking.id + ) + + # 3. Simulate the user confirming the over-reception + response = self.service.process_without_pack( + self.reception_picking.id, + self.reception_line.id, + quantity_to_process, + is_over_reception_confirmed=True, + ) + + # 4. Assert the final state and data + self.assertEqual(response["next_state"], "set_destination") + self.assertEqual(self.reception_line.qty_done, quantity_to_process) diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py index b40a0de5eca..7005f6780e6 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -494,7 +494,7 @@ def _get_service_for_user(self, user): 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 + # when users are working on the same move in parallel picking = self._create_picking() self.service.dispatch("scan_document", params={"barcode": picking.name}) self.service.dispatch( @@ -599,11 +599,7 @@ def test_concurrent_update(self): self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) # We shouldn't be able to process any of those move lines - error_msg = { - "message_type": "error", - "body": "You cannot process that much units.", - } - picking_data = self.data.picking(picking) + # (except if we are doing an over-reception) quantity_done_by_user = 1 for line, service in line_service_mapping: quantity_done_by_user += 2 @@ -619,13 +615,9 @@ def test_concurrent_update(self): line_data[0]["quantity"] = quantity_done_by_user self.assert_response( response, - next_state="set_quantity", - data={ - "picking": picking_data, - "confirmation_required": None, - "selected_move_line": line_data, - }, - message=error_msg, + next_state="confirm_over_reception", + data=self.ANY, + message=self.msg_store.line_scanned_qty_done_higher_than_allowed(), ) # But line's reserved_uom_qty hasn't changed and is still 10.0 @@ -839,12 +831,9 @@ def test_move_states(self): "quantity": 10.0, }, ) - # - expected_message = { - "body": "You cannot process that much units.", - "message_type": "error", - } - self.assertMessage(response, expected_message) + self.assertMessage( + response, self.msg_store.line_scanned_qty_done_higher_than_allowed() + ) # user1 cancels the operation service_user_1.dispatch( "set_quantity__cancel_action", diff --git a/shopfloor_reception/tests/test_set_quantity_action.py b/shopfloor_reception/tests/test_set_quantity_action.py index 8f1bb53f142..e5c71156ef8 100644 --- a/shopfloor_reception/tests/test_set_quantity_action.py +++ b/shopfloor_reception/tests/test_set_quantity_action.py @@ -124,11 +124,6 @@ def test_cancel_action_concurrent(self): }, ) # 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={ @@ -137,7 +132,9 @@ def test_cancel_action_concurrent(self): "quantity": 10.0, }, ) - self.assertMessage(response, expected_message) + self.assertMessage( + response, self.msg_store.line_scanned_qty_done_higher_than_allowed() + ) response = service_user_2.dispatch( "process_with_new_pack", params={ @@ -146,7 +143,9 @@ def test_cancel_action_concurrent(self): "quantity": 10.0, }, ) - self.assertMessage(response, expected_message) + self.assertMessage( + response, self.msg_store.line_scanned_qty_done_higher_than_allowed() + ) # make user1 cancel service_user_1.dispatch( "set_quantity__cancel_action", diff --git a/shopfloor_reception_mobile/static/src/scenario/reception.js b/shopfloor_reception_mobile/static/src/scenario/reception.js index 761e0aa6587..eef6667e52c 100644 --- a/shopfloor_reception_mobile/static/src/scenario/reception.js +++ b/shopfloor_reception_mobile/static/src/scenario/reception.js @@ -80,6 +80,20 @@ const Reception = {
    +