diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst index 6e0fdc30774..4196ac90152 100644 --- a/shopfloor_reception/README.rst +++ b/shopfloor_reception/README.rst @@ -17,7 +17,7 @@ Shopfloor Reception .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github @@ -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 349de78804a..734a3455d24 100644 --- a/shopfloor_reception/services/reception.py +++ b/shopfloor_reception/services/reception.py @@ -598,10 +598,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) @@ -701,7 +734,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 @@ -917,6 +950,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): return self._response( next_state="set_destination", @@ -1311,19 +1380,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): - move = line.move_id - sum(move.move_line_ids.mapped("qty_done")) - 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 = { @@ -1333,7 +1427,12 @@ def _set_quantity__process__set_qty_and_split(self, picking, line, quantity): } line._split_qty_to_be_done(quantity, **default_values) - def process_with_existing_pack(self, picking_id, selected_line_id, quantity): + if is_over_reception_confirmed: + self._after_over_reception_confirmed_hook(picking, line) + + 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) message = self._check_picking_processible(picking) @@ -1342,13 +1441,19 @@ def process_with_existing_pack(self, picking_id, selected_line_id, quantity): picking, selected_line, message=message ) response = self._set_quantity__process__set_qty_and_split( - picking, selected_line, quantity + picking, + selected_line, + quantity, + action="process_with_existing_pack", + is_over_reception_confirmed=is_over_reception_confirmed, ) if response: 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) message = self._check_picking_processible(picking) @@ -1357,14 +1462,20 @@ def process_with_new_pack(self, picking_id, selected_line_id, quantity): picking, selected_line, message=message ) response = self._set_quantity__process__set_qty_and_split( - picking, selected_line, quantity + picking, + selected_line, + quantity, + action="process_with_new_pack", + is_over_reception_confirmed=is_over_reception_confirmed, ) if response: 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) message = self._check_picking_processible(picking) @@ -1373,7 +1484,11 @@ def process_without_pack(self, picking_id, selected_line_id, quantity): picking, selected_line, message=message ) response = self._set_quantity__process__set_qty_and_split( - picking, selected_line, quantity + picking, + selected_line, + quantity, + "process_without_pack", + is_over_reception_confirmed, ) if response: return response @@ -1614,6 +1729,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): @@ -1635,6 +1751,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): @@ -1646,6 +1763,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): @@ -1657,6 +1775,7 @@ def process_without_pack(self): "required": True, }, "quantity": {"coerce": to_float, "type": "float"}, + "is_over_reception_confirmed": {"type": "boolean"}, } def set_destination(self): @@ -1720,6 +1839,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, @@ -1755,7 +1875,12 @@ def _set_lot_next_states(self): return {"select_move", "set_lot", "set_quantity"} 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"} @@ -1773,13 +1898,13 @@ def _set_lot_confirm_action_next_states(self): return {"set_lot", "set_quantity"} 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 @@ -1840,6 +1965,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 a1af0acb72a..29a0bce679a 100644 --- a/shopfloor_reception/static/description/index.html +++ b/shopfloor_reception/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Shopfloor Reception -
+
+

Shopfloor Reception

- - -Odoo Community Association - -
-

Shopfloor Reception

-

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runboat

+

Beta License: AGPL-3 OCA/wms Translate me on Weblate Try me on Runboat

Shopfloor implementation of the reception scenario. Allows to receive products and create the proper packs for each logistic unit.

Table of contents

@@ -391,11 +386,11 @@

Shopfloor Reception

-

Known issues / Roadmap

+

Known issues / Roadmap

Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity).

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -403,25 +398,26 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -436,6 +432,5 @@

Maintainers

-
diff --git a/shopfloor_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py index 7e192132e7e..052a6270d4a 100644 --- a/shopfloor_reception/tests/__init__.py +++ b/shopfloor_reception/tests/__init__.py @@ -14,3 +14,4 @@ from . import test_return_set_quantity from . import test_return_reception_done from . import test_recover +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 9b4da52973e..15c089138d9 100644 --- a/shopfloor_reception/tests/test_set_quantity.py +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -470,7 +470,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( @@ -575,11 +575,13 @@ 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 + # (except if we are doing an over-reception) error_msg = { - "message_type": "error", - "body": "You cannot process that much units.", + "body": "Please note that the scanned quantity is higher than the " + "maximum allowed.", + "message_type": "warning", } - picking_data = self.data.picking(picking) + quantity_done_by_user = 1 for line, service in line_service_mapping: quantity_done_by_user += 2 @@ -595,12 +597,8 @@ 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, - }, + next_state="confirm_over_reception", + data=self.ANY, message=error_msg, ) @@ -815,8 +813,8 @@ def test_move_states(self): ) # expected_message = { - "body": "You cannot process that much units.", - "message_type": "error", + "body": "Please note that the scanned quantity is higher than the maximum allowed.", + "message_type": "warning", } self.assertMessage(response, expected_message) # user1 cancels the operation diff --git a/shopfloor_reception/tests/test_set_quantity_action.py b/shopfloor_reception/tests/test_set_quantity_action.py index a7bf4b4b897..f1e82366fee 100644 --- a/shopfloor_reception/tests/test_set_quantity_action.py +++ b/shopfloor_reception/tests/test_set_quantity_action.py @@ -122,10 +122,9 @@ def test_cancel_action(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", + "body": "Please note that the scanned quantity is higher than the maximum allowed.", + "message_type": "warning", } response = service_user_1.dispatch( "process_with_new_pack", diff --git a/shopfloor_reception_mobile/static/src/scenario/reception.js b/shopfloor_reception_mobile/static/src/scenario/reception.js index ce2117091a5..cc7c428c018 100644 --- a/shopfloor_reception_mobile/static/src/scenario/reception.js +++ b/shopfloor_reception_mobile/static/src/scenario/reception.js @@ -85,6 +85,20 @@ const Reception = { +