From 90be5eadf83f03ee2c6ad51142b09c03e7035ebc Mon Sep 17 00:00:00 2001 From: MmeQuignon Date: Mon, 5 Dec 2022 18:10:50 +0100 Subject: [PATCH 01/54] Add shopfloor_single_product_transfer --- shopfloor_single_product_transfer/README.rst | 1 + shopfloor_single_product_transfer/__init__.py | 1 + .../__manifest__.py | 20 + .../data/shopfloor_scenario_data.xml | 19 + .../demo/shopfloor_menu_demo.xml | 21 + .../demo/stock_picking_type_demo.xml | 23 + .../docs/diagram.plantuml | 85 +++ .../docs/diagram.png | Bin 0 -> 10535 bytes .../docs/oca_logo.png | Bin 0 -> 3297 bytes .../readme/CONTRIBUTORS.rst | 1 + .../readme/DESCRIPTION.rst | 1 + .../readme/USAGE.rst | 25 + .../services/__init__.py | 1 + .../services/single_product_transfer.py | 703 ++++++++++++++++++ .../tests/__init__.py | 4 + .../tests/common.py | 135 ++++ .../tests/test_scan_location.py | 53 ++ .../tests/test_scan_product.py | 513 +++++++++++++ .../tests/test_set_quantity.py | 540 ++++++++++++++ .../tests/test_start.py | 31 + 20 files changed, 2177 insertions(+) create mode 100644 shopfloor_single_product_transfer/README.rst create mode 100644 shopfloor_single_product_transfer/__init__.py create mode 100644 shopfloor_single_product_transfer/__manifest__.py create mode 100644 shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml create mode 100644 shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml create mode 100644 shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml create mode 100644 shopfloor_single_product_transfer/docs/diagram.plantuml create mode 100644 shopfloor_single_product_transfer/docs/diagram.png create mode 100644 shopfloor_single_product_transfer/docs/oca_logo.png create mode 100644 shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst create mode 100644 shopfloor_single_product_transfer/readme/DESCRIPTION.rst create mode 100644 shopfloor_single_product_transfer/readme/USAGE.rst create mode 100644 shopfloor_single_product_transfer/services/__init__.py create mode 100644 shopfloor_single_product_transfer/services/single_product_transfer.py create mode 100644 shopfloor_single_product_transfer/tests/__init__.py create mode 100644 shopfloor_single_product_transfer/tests/common.py create mode 100644 shopfloor_single_product_transfer/tests/test_scan_location.py create mode 100644 shopfloor_single_product_transfer/tests/test_scan_product.py create mode 100644 shopfloor_single_product_transfer/tests/test_set_quantity.py create mode 100644 shopfloor_single_product_transfer/tests/test_start.py diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst new file mode 100644 index 00000000000..29ab2d1458e --- /dev/null +++ b/shopfloor_single_product_transfer/README.rst @@ -0,0 +1 @@ +wait 4 da boat diff --git a/shopfloor_single_product_transfer/__init__.py b/shopfloor_single_product_transfer/__init__.py new file mode 100644 index 00000000000..99464a7510b --- /dev/null +++ b/shopfloor_single_product_transfer/__init__.py @@ -0,0 +1 @@ +from . import services diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py new file mode 100644 index 00000000000..412587c9a82 --- /dev/null +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -0,0 +1,20 @@ +{ + "name": "Shopfloor Single Product Transfer", + "summary": "Move an item from one location to another.", + "version": "14.0.1.0.0", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["mmequignon"], + "license": "AGPL-3", + "installable": True, + "auto_install": False, + "depends": ["shopfloor"], + "data": [ + "data/shopfloor_scenario_data.xml", + ], + "demo": [ + "demo/stock_picking_type_demo.xml", + "demo/shopfloor_menu_demo.xml", + ], +} diff --git a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml new file mode 100644 index 00000000000..4797287910d --- /dev/null +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -0,0 +1,19 @@ + + + + + + Single Product Transfer + single_product_transfer + +{ + "allow_create_moves": true, + "allow_unreserve_other_moves": true, + "allow_ignore_no_putaway_available": true, + "no_prefill_qty": true +} + + + + diff --git a/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml b/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml new file mode 100644 index 00000000000..acdd2666b51 --- /dev/null +++ b/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml @@ -0,0 +1,21 @@ + + + + + + Single Product Transfer + 45 + + + + + + + diff --git a/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml b/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml new file mode 100644 index 00000000000..2669f4748bd --- /dev/null +++ b/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml @@ -0,0 +1,23 @@ + + + + + + Single Product Transfer + SPT + + + + + + + + + internal + + + + + + diff --git a/shopfloor_single_product_transfer/docs/diagram.plantuml b/shopfloor_single_product_transfer/docs/diagram.plantuml new file mode 100644 index 00000000000..672942cac56 --- /dev/null +++ b/shopfloor_single_product_transfer/docs/diagram.plantuml @@ -0,0 +1,85 @@ +# Diagram to generate with PlantUML (https://plantuml.com/) +# +# $ sudo apt install plantuml +# $ plantuml diagram.plantuml +# + +@startuml +participant start +participant select_location +participant select_product +participant set_quantity + +skinparam roundcorner 20 +skinparam sequence { + +ParticipantBorderColor #875A7B +ParticipantBackgroundColor #875A7B +ParticipantFontSize 17 +ParticipantFontColor white + +LifeLineBorderColor #875A7B + +ArrowColor #00A09D +} + +header +title Single Product Transfer scenario + +== start == + +alt #Lightgreen Successful cases + start -[#green]> select_location: **/start** \n(no ongoing move_line for user) + start -[#green]> set_quantity: **/start** \n(when an ongoing move_line is found for current user) +end + +== select_location == + +alt #Pink Errors + select_location -[#red]> select_location: **/select_location**(barcode)\nif no stock in location (reserved or not) + select_location -[#red]> select_location: **/select_location**(barcode)\nif selected location doesn't match the scenario configuration +else #Lightgreen Successful cases + select_location -[#green]> select_product: **/select_location**(barcode)\nscanned location is ok (see above checks) +end + +== select_product == + +alt #Pink Errors + select_product -[#red]> select_product: **/scan_product**(location_id, barcode)\nNo stock for product in location + select_product -[#red]> select_product: **/scan_product**(location_id, barcode)\nProduct scanned, but tracked by lot + select_product -[#red]> select_product: **/scan_product**(location_id, barcode)\nUnreserved stock for product\nallow_move_create disabled + select_product -[#red]> select_product: **/scan_product**(location_id, barcode)\nStock for product reserved by another move\nallow_unreserve_other_moves disabled +else #Lightgreen Successful cases + select_product -[#green]> set_quantity: **/scan_product**(location_id, barcode)\nValid product / lot / packaging scanned\n(see above checks) + select_product -[#green]> select_location: **/scan_product__action_cancel**()\n(User clicked the cancel button) +end + +== set_quantity == + +note over set_quantity: general +alt #Pink Errors + set_quantity -[#red]> set_quantity: **/set_quantity**(selected_line_id, barcode)\nbarcode not found +else #Lightgreen Ask for confirmation + set_quantity -[#green]> select_location: **/set_quantity__action_cancel**(selected_line_id)\n(User clicked the cancel button) +end + +note over set_quantity: product/lot/packaging scanned +alt #Pink Errors + set_quantity -[#red]> set_quantity: **/set_quantity**(selected_line_id, barcode)\nScanned product/lot not in selected line + set_quantity -[#red]> set_quantity: **/set_quantity**(selected_line_id, barcode)\nqty_done is already >= product_uom_qty + set_quantity -[#red]> set_quantity: **/set_quantity**(selected_line_id, barcode)\nnot_prefill_qty is disabled +else #Lightgreen Successful cases + set_quantity -[#green]> set_quantity: **/set_quantity**(selected_line_id, barcode, confirmation=False)\nall above checks are ok\n(increments qty for product / lot / packaging) +end + +note over set_quantity: location scanned +alt #Pink Errors + set_quantity -[#red]> set_quantity: **/set_quantity**(selected_line_id, barcode, confirmation=False)\nscanned location is invalid +else #LightBlue Ask for confirmation + set_quantity -[#blue]> set_quantity: **/set_quantity**(selected_line_id, barcode, confirmation=False)\nscanned location is a child of menu's default dest location\nasks for confirmation +else #Lightgreen Successful cases + set_quantity -[#green]> select_location: **/select_location**(selected_line_id, barcode, confirmation=True)\nscanned location is a child of menu's default dest location\nposts the move + set_quantity -[#green]> select_location: **/select_location**(selected_line_id, barcode, confirmation=True)\nscanned location is a child of move_line's default dest location\nposts the move +end + +@enduml diff --git a/shopfloor_single_product_transfer/docs/diagram.png b/shopfloor_single_product_transfer/docs/diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..8505a03c732a18ea733cc5a07c634bdc211729ce GIT binary patch literal 10535 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冭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 literal 0 HcmV?d00001 diff --git a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000000..bca4ee0cadb --- /dev/null +++ b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Matthieu Méquignon diff --git a/shopfloor_single_product_transfer/readme/DESCRIPTION.rst b/shopfloor_single_product_transfer/readme/DESCRIPTION.rst new file mode 100644 index 00000000000..52a1524024d --- /dev/null +++ b/shopfloor_single_product_transfer/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Allow to move a single product from a location to another one. diff --git a/shopfloor_single_product_transfer/readme/USAGE.rst b/shopfloor_single_product_transfer/readme/USAGE.rst new file mode 100644 index 00000000000..c4e9758581e --- /dev/null +++ b/shopfloor_single_product_transfer/readme/USAGE.rst @@ -0,0 +1,25 @@ +**Source location selection** + Select a source location. + It must be a valid location according to the configuration of the scenario, + and there must be stock in the selected location. + +**Move line selection** + Select a product or a lot in this location. + If an unassigned move line for this product / lot exists in the previously selected + location, then it is selected. + Otherwise, if the `Allow Move Creation` is enabled, it will try to create a move line. + If the `Allow to process reserved quantities` option is enabled, other moves + will be unreserved. + If there's unreserved goods in the location, a new move is created with quantity equal + to the unreserved goods in the location. + +**Set quantity / destination location** + 1. **Scan a product / lot to set the quantity** + If the `Do not pre-fill quantity to pick` option is enabled, it will increment the + done quantity by 1 each time the product or lot barcode is scanned. + Else, it will set the quantity done as the reserved quantity. + 2. **Scan a destination location** + The scanned location will be checked. + It must be a child of the current line destination location or a child of + the scenario default destination location. + If this is ok, then the move is processed. diff --git a/shopfloor_single_product_transfer/services/__init__.py b/shopfloor_single_product_transfer/services/__init__.py new file mode 100644 index 00000000000..3225992cdb9 --- /dev/null +++ b/shopfloor_single_product_transfer/services/__init__.py @@ -0,0 +1 @@ +from . import single_product_transfer diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py new file mode 100644 index 00000000000..226537caec0 --- /dev/null +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -0,0 +1,703 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging +from functools import wraps + +from odoo.osv.expression import AND +from odoo.tools import float_compare + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component +from odoo.addons.component.exception import NoComponentError +from odoo.addons.shopfloor.utils import to_float + +_logger = logging.getLogger("shopfloor.services.single_product_transfer") + + +def with_savepoint(method): + @wraps(method) + def wrapper(self, *args, **kwargs): + savepoint = self._actions_for("savepoint").new() + response = method(self, *args, **kwargs) + message_type = response.get("message", {}).get("message_type") + if message_type in ("error", "warning"): + _logger.info( + "%(method_name)s returned an error/warning. Transaction rollbacked.", + {"method_name": method.__name__}, + ) + savepoint.rollback() + savepoint.release() + return response + + return wrapper + + +class ShopfloorSingleProductTransfer(Component): + """ + Methods for the Single Product Transfer Process + + Move a product or lot from one location to another. + + * scan the source location + * scan a product/lot/packaging from this source location + * confirm or change the quantity to move + * scan the destination location + + You will find a sequence diagram describing states and endpoints + relationships [here](../docs/diagram.png). + Keep [the sequence diagram](../docs/diagram.plantuml) up-to-date + if you change endpoints. + + """ + + # TODO check if we can remove allow_create_moves + _inherit = "base.shopfloor.process" + _name = "shopfloor.single.product.transfer" + _usage = "single_product_transfer" + _description = __doc__ + + # Responses + + def _response_for_select_location(self, message=None): + return self._response(next_state="select_location", message=message) + + def _response_for_select_product(self, location, message=None): + data = {"location": self.data.location(location)} + # import pdb; pdb.set_trace() + return self._response(next_state="select_product", data=data, message=message) + + def _response_for_set_quantity( + self, move_line, message=None, asking_confirmation=False + ): + data = { + "move_line": self.data.move_line(move_line), + "asking_confirmation": asking_confirmation, + } + return self._response(next_state="set_quantity", data=data, message=message) + + # Handlers + + def _scan_location__location_found(self, location): + """Check that the location exists.""" + if not location: + message = self.msg_store.no_location_found() + return self._response_for_select_location(message=message) + + def _scan_location__check_location(self, location): + """Check that `location` belongs to the source location of the operation type.""" + locations = self.picking_types.default_location_src_id + child_locations = self.env["stock.location"].search( + [("id", "child_of", locations.ids)] + ) + if location not in (locations | child_locations): + message = self.msg_store.location_content_unable_to_transfer(location) + return self._response_for_select_location(message=message) + + def _scan_location__check_stock(self, location): + """Check if the location has products to move.""" + quants_in_location = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("quantity", ">", 0)] + ) + if not quants_in_location: + message = self.msg_store.location_empty(location) + return self._response_for_select_location(message=message) + + def _scan_product__scan_product(self, location, barcode): + search = self._actions_for("search") + product = search.product_from_scan(barcode) + handlers = [ + self._scan_product__check_tracking, + self._scan_product__select_move_line, + # If no line is found, we might try to create one, + # if allow_move_create is True + self._scan_product__check_create_move_line, + # First, try to create a move line with the available quantity + self._scan_product__create_move_line, + # If no stock is available at first, try to unreserve moves if option + # allow_unreserve_other_moves is enabled + self._scan_product__unreserve_move_line, + # Check again if there's some unreserved qty + self._scan_product__create_move_line, + # Then return a `no product available` error + self._scan_product__no_stock_available, + ] + if product: + response = self._use_handlers(handlers, product, location) + if response: + return response + + def _scan_product__check_tracking(self, product, location, lot=None): + if product.tracking == "lot": + message = self.msg_store.scan_lot_on_product_tracked_by_lot() + return self._response_for_select_product(location, message=message) + + def _scan_product__select_move_line_domain(self, product, location, lot=None): + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ("state", "in", ("assigned", "partially_available")), + ("picking_id.user_id", "in", (False, self.env.uid)), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ] + if lot: + lot_domain = [("lot_id", "=", lot.id)] + domain = AND([domain, lot_domain]) + return domain + + def _scan_product__select_move_line(self, product, location, lot=None): + domain = self._scan_product__select_move_line_domain(product, location, lot=lot) + query = self.env["stock.move.line"]._search(domain, limit=1) + order_elems = [ + "stock_move_line__picking_id.user_id", + "stock_move_line__picking_id.priority DESC", + "stock_move_line__picking_id.scheduled_date ASC", + "id DESC", + ] + query.order = ",".join(order_elems) + move_line = self.env["stock.move.line"].browse(query) + if move_line: + stock = self._actions_for("stock") + if self.work.menu.no_prefill_qty: + # First, mark move line as picked with qty_done = 0, + # so the move wont be split because 0 < qty_done < product_uom_qty + stock.mark_move_line_as_picked(move_line, quantity=0) + # Then, set the no prefill qty on the move line + if lot: + qty_done = 1 + else: + qty_done = 1 + move_line.qty_done = qty_done + else: + stock.mark_move_line_as_picked(move_line) + return self._response_for_set_quantity(move_line) + + def _scan_product__check_create_move_line(self, product, location, lot=None): + # TODO this is making the `allow_move_create` flag mandatory, we do not want that + if not self.is_allow_move_create(): + message = self.msg_store.no_operation_found() + return self._response_for_select_product(location, message=message) + + def _scan_product__unreserve_move_line(self, product, location, lot=None): + unreserve = self._actions_for("stock.unreserve") + if self.work.menu.allow_unreserve_other_moves: + move_lines = self._find_location_move_lines(location, product, lot=lot) + response = unreserve.check_unreserve(location, move_lines, product, lot) + if response: + return response + unreserve.unreserve_moves(move_lines, self.picking_types) + + def _scan_product__create_move_line_domain(self, product, location, lot=None): + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ("available_quantity", ">", 0), + # FIXME: handle the scan of packages later + # this will also prevent the fetch of quants with lots having + # a package, to check. + ("package_id", "=", False), + ] + if lot: + lot_domain = [("lot_id", "=", lot.id)] + domain = AND([domain, lot_domain]) + return domain + + def _scan_product__create_move_line(self, product, location, lot=None): + quant_domain = self._scan_product__create_move_line_domain( + product, location, lot=lot + ) + quants_in_location = self.env["stock.quant"].search(quant_domain) + available_quantity = sum(quants_in_location.mapped("available_quantity")) + if available_quantity: + # Check if available qty > packaging qty if packaging qty is scanned + move = self._create_move_from_location( + location, product, available_quantity, lot=lot + ) + move_line = move.move_line_ids + response = self._scan_product__check_putaway(move_line) + if response: + return response + return self._response_for_set_quantity(move_line) + + def _scan_product__no_stock_available( + self, product, location, lot=None, packaging=None + ): + message = self.msg_store.no_operation_found() + return self._response_for_select_product(location, message=message) + + def _scan_product__check_putaway(self, move_line): + stock = self._actions_for("stock") + ignore_no_putaway_available = self.work.menu.ignore_no_putaway_available + no_putaway_available = stock.no_putaway_available(self.picking_types, move_line) + if ignore_no_putaway_available and no_putaway_available: + message = self.msg_store.no_putaway_destination_available() + return self._response_for_select_product( + move_line.location_id, message=message + ) + + def _scan_product__scan_lot(self, location, barcode): + search = self._actions_for("search") + handlers = [ + self._scan_product__select_move_line, + # If no line is found, we might try to create one, + # only if allow_move_create option is True + self._scan_product__check_create_move_line, + # First, try to create a move line with the available quantity + self._scan_product__create_move_line, + # If no stock is available at first, try to unreserve moves if option + # allow_unreserve_other_moves is enabled + self._scan_product__unreserve_move_line, + # Check again if there's some unreserved qty + self._scan_product__create_move_line, + # Then return a `no product available` error + self._scan_product__no_stock_available, + ] + lot = search.lot_from_scan(barcode) + if lot: + product = lot.product_id + product_response = self._use_handlers(handlers, product, location, lot=lot) + if product_response: + return product_response + + def _use_handlers(self, handlers, *args, **kwargs): + # TODO: each handler should raise a Shopfloor dedicated exception + # with the response data attached + for handler in handlers: + response = handler(*args, **kwargs) + if response: + return response + + # Copied from manual_product_transfer + def _find_location_move_lines_domain(self, location, product, lot=None): + domain = [ + ("location_id", "=", location.id), + ("product_id", "=", product.id), + ("state", "in", ("assigned", "partially_available")), + ("picking_id.user_id", "in", (False, self.env.uid)), + ] + if lot: + domain = AND([domain, [("lot_id", "=", lot.id)]]) + return domain + + # Copied from manual_product_transfer + def _find_location_move_lines(self, location, product, lot=None): + """Find existing move lines in progress related to the source location + but not linked to any user. + """ + domain = self._find_location_move_lines_domain(location, product, lot=lot) + return self.env["stock.move.line"].search(domain) + + # Copied from manual_product_transfer + def _create_move_from_location(self, location, product, quantity, lot=None): + picking_type = self.picking_types + move_vals = { + "name": product.name, + "company_id": picking_type.company_id.id, + "product_id": product.id, + "product_uom": product.uom_id.id, + "product_uom_qty": quantity, + "location_id": location.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "origin": self.work.menu.name, + "picking_type_id": picking_type.id, + } + move = self.env["stock.move"].create(move_vals) + move._action_confirm(merge=False) + move.with_context( + {"force_reservation": self.work.menu.allow_force_reservation} + )._action_assign() + assert move.state == "assigned", "The reservation of quantities has failed" + # we expect to get only one move line as we are + # moving only bulk products w/o lot or package. + move_line = move.move_line_ids[0] + stock = self._actions_for("stock") + if self.work.menu.no_prefill_qty: + # We ensure the qty_done is 0 here, so we can set it manually after + # to avoid the split of the move line by 'mark_move_line_as_picked'. + stock.mark_move_line_as_picked(move_line, quantity=0) + # Just to be explicit + # TODO add if packaging + if lot: + qty_done = 1 + else: + qty_done = 1 + move_line.qty_done = qty_done + else: + stock.mark_move_line_as_picked(move_line) + return move + + def _set_quantity__check_product_in_line(self, move_line, product, lot=None): + message = False + if lot: + wrong_lot = move_line.lot_id != lot + if wrong_lot: + message = self.msg_store.wrong_record(lot._name) + if move_line.product_id != product: + message = self.msg_store.wrong_record(product._name) + if message: + return self._response_for_set_quantity(move_line, message=message) + + def _set_quantity__check_quantity_done(self, move_line, product, lot=None): + rounding = product.uom_id.rounding + qty_done = move_line.qty_done + qty_todo = move_line.product_uom_qty + # If qty done is >= qty todo, then there's nothing more to pick + if float_compare(qty_done, qty_todo, precision_rounding=rounding) > 0: + message = self.msg_store.unable_to_pick_more(qty_todo) + return self._response_for_set_quantity(move_line, message=message) + + def _set_quantity__check_no_prefill_qty(self, move_line, product, lot=None): + # TODO this is making the `no_prefill_qty` flag mandatory, we do not want that + if not self.work.menu.no_prefill_qty: + # If no_prefill_qty is False, then qty_done should have been prefilled + # with product_uom_qty in the select_product screen + message = self.msg_store.unable_to_pick_more(move_line.product_uom_qty) + return self._response_for_set_quantity(move_line, message=message) + + def _set_quantity__increment_qty_done(self, move_line, product, lot=None): + """Increment the quantity done depending on the item scanned.""" + # TODO use no_prefill_qty option + # TODO if packaging + if lot: + qty_done = 1 + else: + qty_done = 1 + move_line.qty_done += qty_done + return self._response_for_set_quantity(move_line) + + def _set_quantity__set_picker_qty(self, move_line, quantity): + """Sets move_line qty_done according to picker quantity.""" + move_line.qty_done = quantity + + def _set_quantity__scan_product_handlers(self): + return ( + self._set_quantity__check_product_in_line, + self._set_quantity__increment_qty_done, + ) + + def _set_quantity__scan_product(self, move_line, barcode, confirmation=False): + search = self._actions_for("search") + product = search.product_from_scan(barcode) + handlers = self._set_quantity__scan_product_handlers() + if product: + response = self._use_handlers(handlers, move_line, product) + if response: + return response + + def _set_quantity__scan_lot(self, move_line, barcode, confirmation=False): + search = self._actions_for("search") + lot = search.lot_from_scan(barcode) + handlers = self._set_quantity__scan_product_handlers() + if lot: + product = lot.product_id + response = self._use_handlers(handlers, move_line, product, lot=lot) + if response: + return response + + def _set_quantity__valid_dest_location_for_move_line_domain(self, move_line): + move_line_dest_location = move_line.location_dest_id + return [ + "|", + ("id", "in", move_line_dest_location.ids), + ("id", "child_of", move_line_dest_location.ids), + ] + + def _set_quantity__valid_dest_location_for_move_line(self, move_line): + domain = self._set_quantity__valid_dest_location_for_move_line_domain(move_line) + return self.env["stock.location"].search(domain) + + def _valid_dest_location_for_menu_domain(self): + return [ + "|", + ("id", "in", self.picking_types.default_location_dest_id.ids), + ("id", "child_of", self.picking_types.default_location_dest_id.ids), + ] + + def _valid_dest_location_for_menu(self): + domain = self._valid_dest_location_for_menu_domain() + return self.env["stock.location"].search(domain) + + def _set_quantity__check_location(self, location, move_line, confirmation=False): + valid_locations_for_move_line = ( + self._set_quantity__valid_dest_location_for_move_line(move_line) + ) + valid_locations_for_menu = self._valid_dest_location_for_menu() + message = False + asking_confirmation = False + if location in valid_locations_for_move_line: + # scanned location is valid, return no response + pass + elif ( + location in valid_locations_for_menu + and self.work.menu.allow_alternative_destination + ): + # Considered valid if scan confirmed + if not confirmation: + # Ask for confirmation + orig_location = move_line.location_dest_id + message = self.msg_store.confirm_location_changed( + orig_location, location + ) + asking_confirmation = True + else: + # Invalid location, return an error + message = self.msg_store.dest_location_not_allowed() + if message: + return self._response_for_set_quantity( + move_line, message=message, asking_confirmation=asking_confirmation + ) + + def _write_destination_on_lines(self, lines, location): + # TODO + # '_write_destination_on_lines' is implemented in: + # + # - 'location_content_transfer' + # - 'zone_picking' + # - 'cluster_picking' (but it is called '_unload_write_destination_on_lines') + # + # And all of them has a different implementation, + # To refactor later. + try: + # TODO loose dependency on 'shopfloor_checkout_sync' to avoid having + # yet another glue module. In the long term we should make + # 'shopfloor_checkout_sync' use events and trash the overrides made + # on all scenarios. + self._actions_for("checkout.sync")._sync_checkout(lines, location) + except NoComponentError: + pass + lines.location_dest_id = location + lines.package_level_id.location_dest_id = location + + def _set_quantity__post_move(self, location, move_line, confirmation=False): + # TODO qty_done = 0: transfer_no_qty_done + # TODO qty done < product_qty: transfer_confirm_done + self._write_destination_on_lines(move_line, location) + stock = self._actions_for("stock") + stock.validate_moves(move_line.move_id) + message = self.msg_store.transfer_done_success(move_line.picking_id) + return self._response_for_select_product(move_line.location_id, message=message) + + def _find_user_move_line_domain(self, user): + return [ + ("picking_id.user_id", "in", (False, self.env.uid)), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ("state", "in", ("assigned", "partially_available")), + ("qty_done", ">", 0), + ] + + def _find_user_move_line(self): + """Return the first move line already started (if any).""" + user = self.env.user + domain = self._find_user_move_line_domain(user) + return self.env["stock.move.line"].search(domain, limit=1) + + def _set_quantity__by_product(self, move_line, barcode, confirmation=False): + product_handlers = [ + self._set_quantity__scan_product, + self._set_quantity__scan_lot, + # scan packaging + ] + product_response = self._use_handlers(product_handlers, move_line, barcode) + if product_response: + return product_response + + def _set_quantity__by_location(self, move_line, barcode, confirmation=False): + search = self._actions_for("search") + location = search.location_from_scan(barcode) + handlers = [ + # Cannot confirm if qty_done is not valid (> qty todo) + self._set_quantity__check_quantity_done, + self._set_quantity__check_location, + self._set_quantity__post_move, + ] + if location: + response = self._use_handlers( + handlers, location, move_line, confirmation=confirmation + ) + if response: + return response + + # Endpoints + + def start(self): + move_line = self._find_user_move_line() + if move_line: + message = self.msg_store.recovered_previous_session() + return self._response_for_set_quantity(move_line, message=message) + return self._response_for_select_location() + + def scan_location(self, barcode): + """Scan a source location. + + It is the starting point of this scenario. + + If stock has been found in the scanned location, allows to scan a + product or a lot. + + Transitions: + * select_product: to scan a product or a lot stored in the scanned location + * start: no stock found or wrong barcode + """ + search = self._actions_for("search") + location = search.location_from_scan(barcode) + handlers = [ + self._scan_location__location_found, + self._scan_location__check_location, + self._scan_location__check_stock, + ] + response = self._use_handlers(handlers, location) + if response: + return response + return self._response_for_select_product(location) + + @with_savepoint + def scan_product(self, location_id, barcode): + """Looks for a move line in the given location, from a barcode.""" + location = self.env["stock.location"].browse(location_id) + if not location.exists(): + return self._response_for_select_product(location) + handlers = [ + self._scan_product__scan_product, + self._scan_product__scan_lot, + ] + response = self._use_handlers(handlers, location, barcode) + if response: + return response + message = self.msg_store.barcode_not_found() + return self._response_for_select_product(location, message=message) + + def scan_product__action_cancel(self): + return self._response_for_select_location() + + def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): + """Sets quantity done if a product is scanned, + or posts the move if a location is scanned. + """ + move_line = self.env["stock.move.line"].browse(selected_line_id) + if not move_line.exists(): + # TODO Should probably return to scan_product or scan_location? + return self._response_for_set_quantity(move_line) + self._set_quantity__set_picker_qty(move_line, quantity) + handlers = [ + # Increment qty done if a product / lot / packaging is scanned + self._set_quantity__by_product, + # Post the move if a location is scanned + self._set_quantity__by_location, + ] + response = self._use_handlers( + handlers, move_line, barcode, confirmation=confirmation + ) + if response: + return response + message = self.msg_store.barcode_not_found() + return self._response_for_set_quantity(move_line, message=message) + + def set_quantity__action_cancel(self, selected_line_id): + stock = self._actions_for("stock") + move_line = self.env["stock.move.line"].browse(selected_line_id).exists() + stock.unmark_move_line_as_picked(move_line) + return self._response_for_select_location() + + +class ShopfloorSingleProductTransferValidator(Component): + _inherit = "base.shopfloor.validator" + _name = "shopfloor.single.product.transfer.validator" + _usage = "single_product_transfer.validator" + + def start(self): + return {} + + def scan_location(self): + return {"barcode": {"required": True, "type": "string"}} + + def scan_product(self): + return { + "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def scan_product__action_cancel(self): + return {} + + def set_quantity(self): + return { + "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + "quantity": {"coerce": to_float, "required": True, "type": "float"}, + "confirmation": {"type": "boolean", "nullable": True, "required": False}, + } + + def set_quantity__action_cancel(self): + return { + "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + } + + +class ShopfloorSingleProductTransferValidatorResponse(Component): + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.single.product.transfer.validator.response" + _usage = "single_product_transfer.validator.response" + + _start_state = "select_location" + + def _states(self): + return { + "select_location": self._schema_select_location, + "select_product": self._schema_select_product, + "set_quantity": self._schema_set_quantity, + } + + def start(self): + return self._response_schema(next_states=self._start_next_states()) + + def scan_location(self): + return self._response_schema(next_states=self._scan_location_next_states()) + + def scan_product(self): + return self._response_schema(next_states=self._scan_product_next_states()) + + def scan_product__action_cancel(self): + return self._response_schema( + next_states=self._scan_product__action_cancel_next_states() + ) + + def set_quantity(self): + return self._response_schema(next_states=self._set_quantity_next_states()) + + def set_quantity__action_cancel(self): + return self._response_schema( + next_states=self._set_quantity__action_cancel_next_states() + ) + + def _start_next_states(self): + return {"select_location", "set_quantity"} + + def _scan_location_next_states(self): + return {"select_location", "select_product"} + + def _scan_product_next_states(self): + return {"select_product", "set_quantity"} + + def _scan_product__action_cancel_next_states(self): + return {"select_location"} + + def _set_quantity_next_states(self): + return {"set_quantity", "select_product"} + + def _set_quantity__action_cancel_next_states(self): + return {"select_location"} + + @property + def _schema_select_location(self): + return {} + + @property + def _schema_select_product(self): + return {"location": {"type": "dict", "schema": self.schemas.location()}} + + @property + def _schema_set_quantity(self): + return { + "move_line": {"type": "dict", "schema": self.schemas.move_line()}, + "asking_confirmation": {"type": "boolean", "nullable": True}, + } diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py new file mode 100644 index 00000000000..ce54e6a641f --- /dev/null +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_start +from . import test_scan_location +from . import test_scan_product +from . import test_set_quantity diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py new file mode 100644 index 00000000000..fe1760396a6 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/common.py @@ -0,0 +1,135 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo.addons.shopfloor.tests.common import CommonCase as BaseCommonCase + + +class CommonCase(BaseCommonCase): + def setUp(self): + super().setUp() + self.service = self.get_service( + "single_product_transfer", menu=self.menu, profile=self.profile + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.cache_existing_record_ids() + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref( + "shopfloor_single_product_transfer.shopfloor_menu_demo_single_product_transfer" + ) + cls.profile = cls.env.ref("shopfloor.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.other_picking_type = cls.env.ref( + "shopfloor.picking_type_location_content_transfer_demo" + ) + cls.wh = cls.picking_type.warehouse_id + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + # cls.packing_location.sudo().active = True + cls.location_src = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.env.ref("stock.stock_location_company") + cls.location_customer = cls.env.ref("stock.stock_location_suppliers") + cls.child_location = ( + cls.env["stock.location"] + .sudo() + .create( + { + "location_id": cls.location_src.id, + "name": "Child Location", + "barcode": "CLN", + } + ) + ) + + @classmethod + def _create_picking(cls, picking_type=None, lines=None, confirm=True, assign=True): + picking = super()._create_picking( + picking_type=picking_type, lines=lines, confirm=confirm + ) + if assign: + picking.action_assign() + cls.cache_existing_record_ids() + return picking + + @classmethod + def cache_existing_record_ids(cls): + # store ids of pickings, moves and move lines already created before + # tests are run. + cls.existing_picking_ids = cls.env["stock.picking"].search([]).ids + cls.existing_move_ids = cls.env["stock.move"].search([]).ids + cls.existing_move_line_ids = cls.env["stock.move.line"].search([]).ids + + @classmethod + def _add_stock_to_product(cls, product, location, qty, lot=None): + """Set the stock quantity of the product.""" + values = { + "product_id": product.id, + "location_id": location.id, + "inventory_quantity": qty, + } + if lot: + values["lot_id"] = lot.id + cls.env["stock.quant"].sudo().with_context(inventory_mode=True).create(values) + cls.cache_existing_record_ids() + + @classmethod + def _create_lot_for_product(cls, product, name): + return cls.env["stock.production.lot"].create( + { + "product_id": product.id, + "name": name, + "company_id": cls.env.company.id, + } + ) + + @classmethod + def _set_product_tracking_by_lot(cls, product): + product.tracking = "lot" + + @classmethod + def _enable_create_move_line(cls): + cls.menu.sudo().allow_move_create = True + + @classmethod + def _enable_unreserve_other_moves(cls): + cls.menu.sudo().allow_unreserve_other_moves = True + + @classmethod + def _enable_ignore_no_putaway_available(cls): + cls.menu.sudo().ignore_no_putaway_available = True + + @classmethod + def _enable_no_prefill_qty(cls): + cls.menu.sudo().no_prefill_qty = True + + # Data methods + + def _data_for_location(self, location): + return self.data.location(location) + + def _data_for_move_line(self, move_line): + return self.data.move_line(move_line) + + @classmethod + def get_new_move_line(cls): + return cls.env["stock.move.line"].search( + [("id", "not in", cls.existing_move_line_ids)] + ) + + @classmethod + def get_new_picking(cls): + return cls.env["stock.picking"].search( + [("id", "not in", cls.existing_picking_ids)] + ) + + @classmethod + def get_new_move(cls): + return cls.env["stock.move"].search([("id", "not in", cls.existing_move_ids)]) diff --git a/shopfloor_single_product_transfer/tests/test_scan_location.py b/shopfloor_single_product_transfer/tests/test_scan_location.py new file mode 100644 index 00000000000..ce3e008eed9 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_scan_location.py @@ -0,0 +1,53 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestScanLocation(CommonCase): + def test_scan_location_not_found(self): + response = self.service.dispatch("scan_location", params={"barcode": "NOPE"}) + expected_message = { + "message_type": "error", + "body": "No location found for this barcode.", + } + self.assert_response( + response, next_state="select_location", data={}, message=expected_message + ) + + def test_scan_wrong_location(self): + location = self.location_customer + response = self.service.dispatch( + "scan_location", params={"barcode": location.name} + ) + expected_message = { + "message_type": "error", + "body": f"The content of {location.name} cannot be transferred with this scenario.", + } + self.assert_response( + response, next_state="select_location", data={}, message=expected_message + ) + + def test_scan_empty_location(self): + location = self.child_location + response = self.service.dispatch( + "scan_location", params={"barcode": location.name} + ) + expected_message = { + "message_type": "error", + "body": f"Location {location.name} empty", + } + self.assert_response( + response, next_state="select_location", data={}, message=expected_message + ) + + def test_scan_location_ok(self): + location = self.location_src + response = self.service.dispatch( + "scan_location", params={"barcode": location.name} + ) + self.assert_response( + response, + next_state="select_product", + data={"location": self._data_for_location(location)}, + ) diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py new file mode 100644 index 00000000000..06e798de49b --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -0,0 +1,513 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + +LOGGER_NAME = "shopfloor.services.single_product_transfer" +ROLLBACK_LOG = ( + "INFO:shopfloor.services.single_product_transfer:" + "scan_product returned an error/warning. " + "Transaction rollbacked." +) +NO_LOG_EXCEPTION = ( + "no logs of level INFO or higher triggered on " + "shopfloor.services.single_product_transfer" +) + + +class TestScanProduct(CommonCase): + @classmethod + def _create_putaway_rule(cls, product, location_src, location_dest): + putaway_model = cls.env["stock.putaway.rule"].sudo() + cls.putaway_rule = putaway_model.create( + { + "product_id": product.id, + "location_in_id": location_src.id, + "location_out_id": location_dest.id, + } + ) + + def test_scan_wrong_barcode(self): + location = self.location_src + response = self.service.dispatch( + "scan_product", params={"location_id": location.id, "barcode": "NOPE"} + ) + expected_message = {"message_type": "error", "body": "Barcode not found"} + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_tracked_product(self): + location = self.location_src + product = self.product_a + self._set_product_tracking_by_lot(product) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + expected_message = { + "message_type": "warning", + "body": "Product tracked by lot, please scan one.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_product_multiple_lines_in_picking_no_prefill_qty_enabled(self): + self._enable_no_prefill_qty() + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + # Without argument, a multi line picking is created + # with product_a and product_b + picking = self._create_picking() + move_line = picking.move_line_ids.filtered(lambda l: l.product_id == product) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + # a new picking should have been created for the selected move line + new_picking = self.get_new_picking() + self.assertTrue(new_picking) + self.assertEqual(move_line.picking_id, new_picking) + self.assertEqual(move_line.qty_done, 1.0) + self.assertEqual(move_line.product_uom_qty, 10.0) + + def test_scan_product_no_move_line(self): + # No move with product in location, create move line is disabled. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_product_with_move_line(self): + # No move with product in location, create move line is disabled. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + picking = self._create_picking(lines=[(product, 10)]) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = picking.move_line_ids + self.assertTrue(move_line.picking_id.user_id) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_product_with_stock_create_move_disabled(self): + # No move with product in location, create move line is enabled but + # there's no stock. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + self.assertFalse(self.get_new_move_line()) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_product_with_stock_create_move_enabled(self): + # No move with product in location, create move line is enabled but + # there's no stock. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + self._enable_create_move_line() + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + self.assertTrue(move_line.picking_id.user_id) + self.assertEqual(move_line.product_qty, 10.0) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_product_with_reserved_stock_unreserve_move_disabled(self): + # No move with product in location, create move line is enabled but + # there's no stock. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + self._enable_create_move_line() + # This picking has reserved the only available goods + self._create_picking( + lines=[(product, 10)], picking_type=self.other_picking_type + ) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + self.assertFalse(self.get_new_move_line()) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_product_with_reserved_stock_unreserve_move_enabled(self): + # No move with product in location, create move line is enabled but + # there's no stock. + # Scanning the product should return a `No operation found` error + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._enable_unreserve_other_moves() + self._add_stock_to_product(product, location, 10) + # This picking has reserved the only available goods + self._create_picking( + lines=[(product, 10)], picking_type=self.other_picking_type + ) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + self.assertTrue(move_line.picking_id.user_id) + self.assertEqual(move_line.product_qty, 10.0) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_lot_no_move_line(self): + location = self.location_src + product = self.product_a + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", params={"location_id": location.id, "barcode": lot.name} + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_lot_with_move_line(self): + location = self.location_src + product = self.product_a + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + self._add_stock_to_product(product, location, 10, lot=lot) + picking = self._create_picking(lines=[(product, 10)]) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": lot.name}, + ) + move_line = picking.move_line_ids + self.assertTrue(move_line.picking_id.user_id) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_lot_with_stock_create_move_disabled(self): + location = self.location_src + product = self.product_a + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + self._add_stock_to_product(product, location, 10, lot=lot) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", params={"location_id": location.id, "barcode": lot.name} + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + self.assertFalse(self.get_new_move_line()) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_lot_with_stock_create_move_enabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + self._add_stock_to_product(product, location, 10, lot=lot) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": lot.name}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + self.assertTrue(move_line.picking_id.user_id) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_lot_with_reserved_stock_unreserve_move_disabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + self._add_stock_to_product(product, location, 10, lot=lot) + # This picking has reserved the only available goods + self._create_picking( + lines=[(product, 10)], picking_type=self.other_picking_type + ) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", params={"location_id": location.id, "barcode": lot.name} + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + self.assertFalse(self.get_new_move_line()) + expected_message = { + "message_type": "error", + "body": "No operation found for this menu and profile.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_scan_lot_with_reserved_stock_unreserve_move_enabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._enable_unreserve_other_moves() + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + self._add_stock_to_product(product, location, 10, lot=lot) + # This picking has reserved the only available goods + self._create_picking( + lines=[(product, 10)], picking_type=self.other_picking_type + ) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": lot.name}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + self.assertTrue(move_line.picking_id.user_id) + self.assertEqual(move_line.product_qty, 10.0) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_product_no_putaway_ignore_no_putaway_enabled(self): + # Ignore no putaway available is set, and no putaway rule is defined. + # Returns an error message, and no move line has been created. + self._enable_create_move_line() + self._enable_ignore_no_putaway_available() + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + with self.assertLogs(LOGGER_NAME) as log_catcher: + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + self.assertIn(ROLLBACK_LOG, log_catcher.output) + self.assertFalse(self.get_new_move_line()) + expected_message = { + "message_type": "error", + "body": "No putaway destination is available.", + } + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", data=data, message=expected_message + ) + + def test_scan_product_no_putaway_ignore_no_putaway_disabled(self): + # Ignore no putaway available is not set, and no putaway rule is defined. + # Creates a move line. + self._enable_create_move_line() + location = self.location_src + product = self.product_a + self._add_stock_to_product(product, location, 10) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_scan_product_with_putaway_ignore_no_putaway_enabled(self): + # Ignore no putawai available is set, and a putaway is defined. + # Creates a move line + location = self.location_src + product = self.product_a + location_dest = self.location_dest + location_putaway_dest = self.env.ref("stock.location_refrigerator_small") + self._create_putaway_rule(product, location_dest, location_putaway_dest) + self._enable_create_move_line() + self._enable_ignore_no_putaway_available() + self._add_stock_to_product(product, location, 10) + with self.assertRaisesRegex(AssertionError, NO_LOG_EXCEPTION): + with self.assertLogs(LOGGER_NAME): + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_create_move_line_by_product_no_prefill_qty_disabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + max_qty_done = 10 + self._add_stock_to_product(product, location, max_qty_done) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertEqual(move_line.qty_done, max_qty_done) + self.assertEqual(move_line.product_uom_qty, max_qty_done) + + def test_create_move_line_by_product_no_prefill_qty_enabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._enable_no_prefill_qty() + max_qty_done = 10 + self._add_stock_to_product(product, location, max_qty_done) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": product.barcode}, + ) + move_line = self.get_new_move_line() + self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.product_uom_qty, max_qty_done) + + def test_create_move_line_by_lot_no_prefill_qty_disabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + max_qty_done = 10 + self._add_stock_to_product(product, location, max_qty_done, lot=lot) + self.service.dispatch( + "scan_product", params={"location_id": location.id, "barcode": lot.name} + ) + move_line = self.get_new_move_line() + self.assertEqual(move_line.qty_done, max_qty_done) + self.assertEqual(move_line.product_uom_qty, max_qty_done) + + def test_create_move_line_by_lot_no_prefill_qty_enabled(self): + location = self.location_src + product = self.product_a + self._enable_create_move_line() + self._enable_no_prefill_qty() + self._set_product_tracking_by_lot(product) + lot = self._create_lot_for_product(product, "LOT_BARCODE") + max_qty_done = 10 + self._add_stock_to_product(product, location, max_qty_done, lot=lot) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": lot.name}, + ) + move_line = self.get_new_move_line() + self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.product_uom_qty, max_qty_done) + + def test_action_cancel(self): + response = self.service.dispatch("scan_product__action_cancel") + self.assert_response(response, next_state="select_location", data={}) + + def test_scan_product_packaging(self): + location = self.location_src + packaging = self.product_a_packaging + product = packaging.product_id + self._add_stock_to_product(product, location, 10) + picking = self._create_picking(lines=[(product, 10)]) + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": packaging.barcode}, + ) + move_line = picking.move_line_ids + self.assertTrue(move_line.picking_id.user_id) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py new file mode 100644 index 00000000000..5f4f4e0f422 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -0,0 +1,540 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetQuantity(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.location = cls.location_src + cls.product = cls.product_a + + @classmethod + def _setup_picking(cls, lot=None): + if lot: + cls._set_product_tracking_by_lot(cls.product) + cls._add_stock_to_product(cls.product, cls.location, 10, lot=lot) + return cls._create_picking(lines=[(cls.product, 10)]) + + def test_set_quantity_barcode_not_found(self): + # First, select a picking + picking = self._setup_picking() + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + move_line = picking.move_line_ids + # Then try to scan an invalid barcode + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": "NOPE", + }, + ) + expected_message = {"message_type": "error", "body": "Barcode not found"} + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_product_prefill_qty_disabled(self): + # First, select a picking + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + # Without no_prefill_qty, once selected, a moveline qty done is already + # equal to the qty todo. + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + # We do not prevent the user to set a bigger qty + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + # However, we prevent the user to post the line if qty_done > qty_todo + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = { + "message_type": "error", + "body": f"You must not pick more than {move_line.product_uom_qty} units.", + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_product_prefill_qty_enabled(self): + # First, select a picking + self._enable_no_prefill_qty() + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + self.assertEqual(move_line.qty_done, 1.0) + # We can scan the same product 9 times, and the qty will increment by 1 + # each time. + for expected_qty in range(2, 11): + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + self.assertEqual(move_line.qty_done, expected_qty) + # We do not prevent the user to set a qty_done > qty_todo in the picker + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + # However, we prevent the user to post the line if qty_done > qty_todo + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = { + "message_type": "error", + "body": f"You must not pick more than {move_line.product_uom_qty} units.", + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_picker_quantity(self): + self._enable_no_prefill_qty() + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + self.assertEqual(move_line.qty_done, 1) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 5.0, + "barcode": self.product.barcode, + }, + ) + # Here, user manually set 5.0 as qty done and scanned a product, + # expected qty_done on move line is 6.0 + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + self.assertEqual(move_line.qty_done, 6.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 10.0, + "barcode": self.product.barcode, + }, + ) + # Here user sets 10.0 then scans a product. + # Expected qty_done is 11.0 + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + self.assertEqual(move_line.qty_done, 11.0) + # When scanning a location, a qty_done is checked. + # Since qty done > qty todo, an error should be raised + + def test_set_quantity_scan_lot_prefill_qty_disabled(self): + # First, select a picking + lot = self._create_lot_for_product(self.product, "LOT_BARCODE") + picking = self._setup_picking(lot=lot) + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": lot.name}, + ) + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": lot.name, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + # However, we shouldn't be able to confirm (scan a location) + # since qty_done > qty_todo (max is 10.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = self.msg_store.unable_to_pick_more(10.0) + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_lot_prefill_qty_enabled(self): + # First, select a picking + self._enable_no_prefill_qty() + lot = self._create_lot_for_product(self.product, "LOT_BARCODE") + picking = self._setup_picking(lot=lot) + response = self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": lot.name}, + ) + move_line = picking.move_line_ids + self.assertEqual(move_line.qty_done, 1.0) + # We can scan the same lot 9 times (until qty_done == product_uom_qty), + # and the qty will increment by 1 each time. + for expected_qty in range(2, 11): + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": lot.name, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + self.assertEqual(move_line.qty_done, expected_qty) + # Nothign prevents the user to set qty_done > qty_todo + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": lot.name, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + # However, we shouldn't be able to confirm (scan a location) + # since qty_done > qty_todo (max is 10.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = self.msg_store.unable_to_pick_more(10.0) + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_packaging(self): + """Scan a packaging to process an existing line.""" + # First, select a picking + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.packaging.barcode}, + ) + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.packaging.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + self.assertEqual(move_line.qty_done, 15.0) + # However, we shouldn't be able to confirm (scan a location) + # since qty_done > qty_todo (max is 10.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = self.msg_store.unable_to_pick_more(10.0) + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_packaging_with_allow_move_create(self): + """Scan a packaging to create and then process a line. + + With no_prefill_qty disabled. + """ + location = self.location + self._add_stock_to_product(self.product, location, 10) + self._enable_create_move_line() + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.packaging.barcode}, + ) + domain = self.service._scan_product__select_move_line_domain( + self.product, location + ) + move_line = self.env["stock.move.line"].search(domain, limit=1) + self.assertEqual(move_line.qty_done, 10.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.barcode, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + data = { + "location": self._data_for_location(location), + } + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( + self, + ): + """Scan a packaging to create and then process a line. + + With no_prefill_qty enabled. + """ + location = self.location + self._add_stock_to_product(self.product, location, 10) + self._enable_create_move_line() + self._enable_no_prefill_qty() + response = self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.packaging.barcode}, + ) + domain = self.service._scan_product__select_move_line_domain( + self.product, location + ) + move_line = self.env["stock.move.line"].search(domain, limit=1) + self.assertEqual(move_line.qty_done, 5.0) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.packaging.barcode, + }, + ) + self.assertEqual(move_line.qty_done, 10.0) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response(response, next_state="set_quantity", data=data) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.barcode, + }, + ) + expected_message = self.msg_store.unable_to_pick_more(self.packaging.qty) + data = {"location": self._data_for_location(location)} + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_set_quantity_invalid_dest_location(self): + picking = self._setup_picking() + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + move_line = picking.move_line_ids + # Then try to scan wrong_location + wrong_location = self.env.ref("stock.stock_location_14") + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": wrong_location.name, + }, + ) + expected_message = {"message_type": "error", "body": "You cannot place it here"} + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_menu_default_location(self): + picking = self._setup_picking() + self.menu.sudo().allow_alternative_destination = True + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + # Change the destination on the move_line + move_line = picking.move_line_ids + move_line.location_dest_id = self.env.ref("stock.stock_location_14") + # Scanning a child of the menu, shopfloor should ask for a confirmation + params = { + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.name, + } + response = self.service.dispatch("set_quantity", params=params) + expected_message = { + "message_type": "warning", + "body": ( + f"Confirm location change from {move_line.location_dest_id.name} " + f"to {self.dispatch_location.name}?" + ), + } + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": True, + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + # Now, calling the same endpoint with confirm=True should be ok + params["confirmation"] = True + response = self.service.dispatch("set_quantity", params=params) + expected_message = self.service.msg_store.transfer_done_success( + move_line.picking_id + ) + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_set_quantity_child_move_location(self): + picking = self._setup_picking() + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + # Change the destination on the move_line + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.name, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_product", message=expected_message, data=data + ) + + def test_action_cancel(self): + # First, select a picking + picking = self._setup_picking() + self.service.dispatch( + "scan_product", + params={ + "location_id": self.location.id, + "barcode": self.product.barcode, + }, + ) + move_line = picking.move_line_ids + move_line.qty_done = 10.0 + # Result here already tested in + # `test_scan_product::TestScanProduct::test_scan_product_with_move_line` + response = self.service.dispatch( + "set_quantity__action_cancel", params={"selected_line_id": move_line.id} + ) + data = {} + self.assert_response(response, next_state="select_location", data=data) + # Ensure the qty_done and user has been reset. + self.assertFalse(move_line.picking_id.user_id) + self.assertEqual(move_line.qty_done, 0.0) diff --git a/shopfloor_single_product_transfer/tests/test_start.py b/shopfloor_single_product_transfer/tests/test_start.py new file mode 100644 index 00000000000..56267c16e6e --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -0,0 +1,31 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestStart(CommonCase): + def test_start(self): + response = self.service.dispatch("start") + self.assert_response(response, next_state="select_location", data={}) + + def test_recover(self): + product = self.product_a + location = self.location_src + self._add_stock_to_product(product, location, 10) + picking = self._create_picking(lines=[(product, 10)]) + picking.user_id = self.env.user + move_line = picking.move_line_ids + move_line.qty_done = move_line.product_uom_qty + response = self.service.dispatch("start") + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + message = { + "message_type": "info", + "body": "Recovered previous session.", + } + self.assert_response( + response, next_state="set_quantity", data=data, message=message + ) From 55636969e837aa2f482309b985bfa3a41b30fc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Fri, 13 Jan 2023 14:06:50 +0100 Subject: [PATCH 02/54] sf_s_product_transfer: support packaging scan --- .../docs/diagram.png | Bin 10535 -> 172886 bytes .../services/single_product_transfer.py | 163 ++++++++++++------ .../tests/test_scan_product.py | 2 +- .../tests/test_set_quantity.py | 103 ++++++++++- 4 files changed, 211 insertions(+), 57 deletions(-) diff --git a/shopfloor_single_product_transfer/docs/diagram.png b/shopfloor_single_product_transfer/docs/diagram.png index 8505a03c732a18ea733cc5a07c634bdc211729ce..bfe4d64b79919a11a58168a6ee5ea7c08f39017f 100644 GIT binary patch literal 172886 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", 0), - # FIXME: handle the scan of packages later - # this will also prevent the fetch of quants with lots having - # a package, to check. - ("package_id", "=", False), - ] - if lot: - lot_domain = [("lot_id", "=", lot.id)] - domain = AND([domain, lot_domain]) - return domain - - def _scan_product__create_move_line(self, product, location, lot=None): - quant_domain = self._scan_product__create_move_line_domain( - product, location, lot=lot + def _scan_product__create_move_line( + self, product, location, lot=None, packaging=None + ): + available_quantity = product.with_context( + location_id=location.id, lot=lot.id if lot else None + ).free_qty + is_product_available = ( + float_compare( + available_quantity, + packaging.qty if packaging else 1.0, + precision_rounding=product.uom_id.rounding, + ) + >= 0 ) - quants_in_location = self.env["stock.quant"].search(quant_domain) - available_quantity = sum(quants_in_location.mapped("available_quantity")) - if available_quantity: - # Check if available qty > packaging qty if packaging qty is scanned + if is_product_available: move = self._create_move_from_location( - location, product, available_quantity, lot=lot + location, product, available_quantity, lot=lot, packaging=packaging ) move_line = move.move_line_ids response = self._scan_product__check_putaway(move_line) @@ -288,7 +318,9 @@ def _find_location_move_lines(self, location, product, lot=None): return self.env["stock.move.line"].search(domain) # Copied from manual_product_transfer - def _create_move_from_location(self, location, product, quantity, lot=None): + def _create_move_from_location( + self, location, product, quantity, lot=None, packaging=None + ): picking_type = self.picking_types move_vals = { "name": product.name, @@ -315,18 +347,18 @@ def _create_move_from_location(self, location, product, quantity, lot=None): # We ensure the qty_done is 0 here, so we can set it manually after # to avoid the split of the move line by 'mark_move_line_as_picked'. stock.mark_move_line_as_picked(move_line, quantity=0) - # Just to be explicit - # TODO add if packaging - if lot: - qty_done = 1 - else: - qty_done = 1 + # Set the initial qty_done to 1 for product and lot + qty_done = 1 + if packaging: + qty_done = packaging.qty move_line.qty_done = qty_done else: stock.mark_move_line_as_picked(move_line) return move - def _set_quantity__check_product_in_line(self, move_line, product, lot=None): + def _set_quantity__check_product_in_line( + self, move_line, product, lot=None, packaging=None + ): message = False if lot: wrong_lot = move_line.lot_id != lot @@ -337,8 +369,10 @@ def _set_quantity__check_product_in_line(self, move_line, product, lot=None): if message: return self._response_for_set_quantity(move_line, message=message) - def _set_quantity__check_quantity_done(self, move_line, product, lot=None): - rounding = product.uom_id.rounding + def _set_quantity__check_quantity_done( + self, location, move_line, confirmation=False + ): + rounding = move_line.product_id.uom_id.rounding qty_done = move_line.qty_done qty_todo = move_line.product_uom_qty # If qty done is >= qty todo, then there's nothing more to pick @@ -346,22 +380,24 @@ def _set_quantity__check_quantity_done(self, move_line, product, lot=None): message = self.msg_store.unable_to_pick_more(qty_todo) return self._response_for_set_quantity(move_line, message=message) - def _set_quantity__check_no_prefill_qty(self, move_line, product, lot=None): - # TODO this is making the `no_prefill_qty` flag mandatory, we do not want that + def _set_quantity__check_no_prefill_qty( + self, move_line, product, lot=None, packaging=None + ): if not self.work.menu.no_prefill_qty: # If no_prefill_qty is False, then qty_done should have been prefilled # with product_uom_qty in the select_product screen message = self.msg_store.unable_to_pick_more(move_line.product_uom_qty) return self._response_for_set_quantity(move_line, message=message) - def _set_quantity__increment_qty_done(self, move_line, product, lot=None): + def _set_quantity__increment_qty_done( + self, move_line, product, lot=None, packaging=None + ): """Increment the quantity done depending on the item scanned.""" - # TODO use no_prefill_qty option - # TODO if packaging - if lot: - qty_done = 1 - else: - qty_done = 1 + # When we reach this handler, the 'no_prefill_qty' is enabled + # For product or lot, we increment by 1 by default + qty_done = 1 + if packaging: + qty_done = packaging.qty move_line.qty_done += qty_done return self._response_for_set_quantity(move_line) @@ -394,6 +430,18 @@ def _set_quantity__scan_lot(self, move_line, barcode, confirmation=False): if response: return response + def _set_quantity__scan_packaging(self, move_line, barcode, confirmation=False): + search = self._actions_for("search") + packaging = search.packaging_from_scan(barcode) + handlers = self._set_quantity__scan_product_handlers() + if packaging: + product = packaging.product_id + response = self._use_handlers( + handlers, move_line, product, packaging=packaging + ) + if response: + return response + def _set_quantity__valid_dest_location_for_move_line_domain(self, move_line): move_line_dest_location = move_line.location_dest_id return [ @@ -494,8 +542,8 @@ def _find_user_move_line(self): def _set_quantity__by_product(self, move_line, barcode, confirmation=False): product_handlers = [ self._set_quantity__scan_product, + self._set_quantity__scan_packaging, self._set_quantity__scan_lot, - # scan packaging ] product_response = self._use_handlers(product_handlers, move_line, barcode) if product_response: @@ -552,12 +600,19 @@ def scan_location(self, barcode): @with_savepoint def scan_product(self, location_id, barcode): - """Looks for a move line in the given location, from a barcode.""" + """Looks for a move line in the given location, from a barcode. + + Barcode can be: + - a product + - a product packaging + - a lot + """ location = self.env["stock.location"].browse(location_id) if not location.exists(): return self._response_for_select_product(location) handlers = [ self._scan_product__scan_product, + self._scan_product__scan_packaging, self._scan_product__scan_lot, ] response = self._use_handlers(handlers, location, barcode) diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index 06e798de49b..4a5cb37bc7c 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -74,7 +74,7 @@ def test_scan_product_multiple_lines_in_picking_no_prefill_qty_enabled(self): new_picking = self.get_new_picking() self.assertTrue(new_picking) self.assertEqual(move_line.picking_id, new_picking) - self.assertEqual(move_line.qty_done, 1.0) + self.assertEqual(move_line.qty_done, 1) self.assertEqual(move_line.product_uom_qty, 10.0) def test_scan_product_no_move_line(self): diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 5f4f4e0f422..d3c004f1124 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -10,6 +10,8 @@ def setUpClass(cls): super().setUpClass() cls.location = cls.location_src cls.product = cls.product_a + cls.packaging = cls.product_a_packaging + cls.packaging.qty = 5 @classmethod def _setup_picking(cls, lot=None): @@ -99,7 +101,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "scan_product", params={"location_id": self.location.id, "barcode": self.product.barcode}, ) - self.assertEqual(move_line.qty_done, 1.0) + self.assertEqual(move_line.qty_done, 1) # We can scan the same product 9 times, and the qty will increment by 1 # each time. for expected_qty in range(2, 11): @@ -248,7 +250,7 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): params={"location_id": self.location.id, "barcode": lot.name}, ) move_line = picking.move_line_ids - self.assertEqual(move_line.qty_done, 1.0) + self.assertEqual(move_line.qty_done, 1) # We can scan the same lot 9 times (until qty_done == product_uom_qty), # and the qty will increment by 1 each time. for expected_qty in range(2, 11): @@ -424,6 +426,103 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( response, next_state="select_product", message=expected_message, data=data ) + def test_set_quantity_scan_packaging(self): + """Scan a packaging to process an existing line.""" + # First, select a picking + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.packaging.barcode}, + ) + self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "barcode": self.packaging.barcode, + }, + ) + expected_message = { + "message_type": "error", + "body": f"You must not pick more than {move_line.product_uom_qty} units.", + } + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + + def test_set_quantity_scan_packaging_with_allow_move_create(self): + """Scan a packaging to create and then process a line. + + With no_prefill_qty disabled. + """ + self._add_stock_to_product(self.product, self.location, 10) + self._enable_create_move_line() + response = self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.packaging.barcode}, + ) + domain = self.service._scan_product__select_move_line_domain( + self.product, self.location + ) + move_line = self.env["stock.move.line"].search(domain, limit=1) + self.assertEqual(move_line.qty_done, self.packaging.qty) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + self.assert_response( + response, next_state="select_location", message=expected_message, data={} + ) + + def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( + self, + ): + """Scan a packaging to create and then process a line. + + With no_prefill_qty enabled. + """ + self._add_stock_to_product(self.product, self.location, 10) + self._enable_create_move_line() + self._enable_no_prefill_qty() + response = self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.packaging.barcode}, + ) + domain = self.service._scan_product__select_move_line_domain( + self.product, self.location + ) + move_line = self.env["stock.move.line"].search(domain, limit=1) + self.assertEqual(move_line.qty_done, self.packaging.qty) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "barcode": self.packaging.barcode, + }, + ) + expected_message = self.msg_store.unable_to_pick_more(self.packaging.qty) + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + self.assert_response( + response, next_state="select_location", message=expected_message, data={} + ) + def test_set_quantity_invalid_dest_location(self): picking = self._setup_picking() self.service.dispatch( From e0e856fdcfebdf01e7630c3e58873c853e6cd082 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 8 Mar 2023 14:41:26 +0100 Subject: [PATCH 03/54] add allow_alternative_destination menu option --- .../data/shopfloor_scenario_data.xml | 1 + .../tests/test_set_quantity.py | 97 ------------------- 2 files changed, 1 insertion(+), 97 deletions(-) diff --git a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml index 4797287910d..4793d287ff8 100644 --- a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -11,6 +11,7 @@ "allow_create_moves": true, "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true, + "allow_alternative_destination": true, "no_prefill_qty": true } diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index d3c004f1124..692e2d34401 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -426,103 +426,6 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( response, next_state="select_product", message=expected_message, data=data ) - def test_set_quantity_scan_packaging(self): - """Scan a packaging to process an existing line.""" - # First, select a picking - picking = self._setup_picking() - move_line = picking.move_line_ids - self.service.dispatch( - "scan_product", - params={"location_id": self.location.id, "barcode": self.packaging.barcode}, - ) - self.assertEqual(move_line.qty_done, move_line.product_uom_qty) - response = self.service.dispatch( - "set_quantity", - params={ - "selected_line_id": move_line.id, - "barcode": self.packaging.barcode, - }, - ) - expected_message = { - "message_type": "error", - "body": f"You must not pick more than {move_line.product_uom_qty} units.", - } - data = {"move_line": self._data_for_move_line(move_line)} - self.assert_response( - response, next_state="set_quantity", message=expected_message, data=data - ) - - def test_set_quantity_scan_packaging_with_allow_move_create(self): - """Scan a packaging to create and then process a line. - - With no_prefill_qty disabled. - """ - self._add_stock_to_product(self.product, self.location, 10) - self._enable_create_move_line() - response = self.service.dispatch( - "scan_product", - params={"location_id": self.location.id, "barcode": self.packaging.barcode}, - ) - domain = self.service._scan_product__select_move_line_domain( - self.product, self.location - ) - move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertEqual(move_line.qty_done, self.packaging.qty) - response = self.service.dispatch( - "set_quantity", - params={ - "selected_line_id": move_line.id, - "barcode": self.dispatch_location.barcode, - }, - ) - expected_message = self.msg_store.transfer_done_success(move_line.picking_id) - self.assert_response( - response, next_state="select_location", message=expected_message, data={} - ) - - def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( - self, - ): - """Scan a packaging to create and then process a line. - - With no_prefill_qty enabled. - """ - self._add_stock_to_product(self.product, self.location, 10) - self._enable_create_move_line() - self._enable_no_prefill_qty() - response = self.service.dispatch( - "scan_product", - params={"location_id": self.location.id, "barcode": self.packaging.barcode}, - ) - domain = self.service._scan_product__select_move_line_domain( - self.product, self.location - ) - move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertEqual(move_line.qty_done, self.packaging.qty) - response = self.service.dispatch( - "set_quantity", - params={ - "selected_line_id": move_line.id, - "barcode": self.packaging.barcode, - }, - ) - expected_message = self.msg_store.unable_to_pick_more(self.packaging.qty) - data = {"move_line": self._data_for_move_line(move_line)} - self.assert_response( - response, next_state="set_quantity", message=expected_message, data=data - ) - response = self.service.dispatch( - "set_quantity", - params={ - "selected_line_id": move_line.id, - "barcode": self.dispatch_location.barcode, - }, - ) - expected_message = self.msg_store.transfer_done_success(move_line.picking_id) - self.assert_response( - response, next_state="select_location", message=expected_message, data={} - ) - def test_set_quantity_invalid_dest_location(self): picking = self._setup_picking() self.service.dispatch( From 59050b32aef13e146899c56b0c6e4e408e252d8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 28 Mar 2023 18:40:25 +0200 Subject: [PATCH 04/54] sf_s_product_transfer: add completion info --- .../services/single_product_transfer.py | 13 +++-- .../tests/test_set_quantity.py | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index e400b8ea51b..942015fc507 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -61,10 +61,11 @@ class ShopfloorSingleProductTransfer(Component): def _response_for_select_location(self, message=None): return self._response(next_state="select_location", message=message) - def _response_for_select_product(self, location, message=None): + def _response_for_select_product(self, location, message=None, popup=None): data = {"location": self.data.location(location)} - # import pdb; pdb.set_trace() - return self._response(next_state="select_product", data=data, message=message) + return self._response( + next_state="select_product", data=data, message=message, popup=popup + ) def _response_for_set_quantity( self, move_line, message=None, asking_confirmation=False @@ -523,7 +524,11 @@ def _set_quantity__post_move(self, location, move_line, confirmation=False): stock = self._actions_for("stock") stock.validate_moves(move_line.move_id) message = self.msg_store.transfer_done_success(move_line.picking_id) - return self._response_for_select_product(move_line.location_id, message=message) + completion_info = self._actions_for("completion.info") + completion_info_popup = completion_info.popup(move_line) + return self._response_for_select_product( + move_line.location_id, message=message, popup=completion_info_popup + ) def _find_user_move_line_domain(self, user): return [ diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 692e2d34401..d0c6c0ecb53 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -20,6 +20,23 @@ def _setup_picking(cls, lot=None): cls._add_stock_to_product(cls.product, cls.location, 10, lot=lot) return cls._create_picking(lines=[(cls.product, 10)]) + @classmethod + def _setup_chained_picking(cls, picking): + next_moves = picking.move_lines.browse() + for move in picking.move_lines: + next_moves |= move.copy( + { + "move_orig_ids": [(6, 0, move.ids)], + "location_id": move.location_dest_id.id, + "location_dest_id": cls.customer_location.id, + } + ) + next_moves._assign_picking() + next_picking = next_moves.picking_id + next_picking.action_confirm() + next_picking.action_assign() + return next_picking + def test_set_quantity_barcode_not_found(self): # First, select a picking picking = self._setup_picking() @@ -540,3 +557,34 @@ def test_action_cancel(self): # Ensure the qty_done and user has been reset. self.assertFalse(move_line.picking_id.user_id) self.assertEqual(move_line.qty_done, 0.0) + + def test_set_quantity_done_with_completion_info(self): + self.picking_type.sudo().display_completion_info = "next_picking_ready" + picking = self._setup_picking() + self._setup_chained_picking(picking) + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + # Change the destination on the move_line + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.name, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + completion_info = self.service._actions_for("completion.info") + expected_popup = completion_info.popup(move_line) + data = {"location": self._data_for_location(location)} + self.assert_response( + response, + next_state="select_product", + message=expected_message, + data=data, + popup=expected_popup, + ) From ef4619bbe28f08656fd11e8b5bdafb0435e9f5b0 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Fri, 31 Mar 2023 13:47:50 +0200 Subject: [PATCH 05/54] sf_s_product_transfer: imp chekout sync integration --- shopfloor_single_product_transfer/README.rst | 111 ++++- .../shopfloor_single_product_transfer.pot | 21 + .../services/single_product_transfer.py | 10 +- .../static/description/icon.png | Bin 0 -> 9455 bytes .../static/description/index.html | 453 ++++++++++++++++++ .../tests/__init__.py | 1 + .../tests/test_set_quantity_checkout_sync.py | 92 ++++ 7 files changed, 685 insertions(+), 3 deletions(-) create mode 100644 shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot create mode 100644 shopfloor_single_product_transfer/static/description/icon.png create mode 100644 shopfloor_single_product_transfer/static/description/index.html create mode 100644 shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index 29ab2d1458e..f43e568886f 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -1 +1,110 @@ -wait 4 da boat +================================= +Shopfloor Single Product Transfer +================================= + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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 + :target: https://github.com/OCA/wms/tree/14.0/shopfloor_single_product_transfer + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_single_product_transfer + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allow to move a single product from a location to another one. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +**Source location selection** + Select a source location. + It must be a valid location according to the configuration of the scenario, + and there must be stock in the selected location. + +**Move line selection** + Select a product or a lot in this location. + If an unassigned move line for this product / lot exists in the previously selected + location, then it is selected. + Otherwise, if the `Allow Move Creation` is enabled, it will try to create a move line. + If the `Allow to process reserved quantities` option is enabled, other moves + will be unreserved. + If there's unreserved goods in the location, a new move is created with quantity equal + to the unreserved goods in the location. + +**Set quantity / destination location** + 1. **Scan a product / lot to set the quantity** + If the `Do not pre-fill quantity to pick` option is enabled, it will increment the + done quantity by 1 each time the product or lot barcode is scanned. + Else, it will set the quantity done as the reserved quantity. + 2. **Scan a destination location** + The scanned location will be checked. + It must be a child of the current line destination location or a child of + the scenario default destination location. + If this is ok, then the move is processed. + +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 smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Matthieu Méquignon + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px + :target: https://github.com/mmequignon + :alt: mmequignon + +Current `maintainer `__: + +|maintainer-mmequignon| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot b/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot new file mode 100644 index 00000000000..8a1a2e3573c --- /dev/null +++ b/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot @@ -0,0 +1,21 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_single_product_transfer +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor_single_product_transfer +#: model:shopfloor.menu,name:shopfloor_single_product_transfer.shopfloor_menu_demo_single_product_transfer +#: model:shopfloor.scenario,name:shopfloor_single_product_transfer.scenario_single_product_transfer +#: model:stock.picking.type,name:shopfloor_single_product_transfer.picking_type_single_product_transfer_demo +msgid "Single Product Transfer" +msgstr "" diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 942015fc507..e23151fab79 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -496,6 +496,9 @@ def _set_quantity__check_location(self, location, move_line, confirmation=False) move_line, message=message, asking_confirmation=asking_confirmation ) + def _lock_lines(self, lines): + self._actions_for("lock").for_update(lines) + def _write_destination_on_lines(self, lines, location): # TODO # '_write_destination_on_lines' is implemented in: @@ -511,9 +514,12 @@ def _write_destination_on_lines(self, lines, location): # yet another glue module. In the long term we should make # 'shopfloor_checkout_sync' use events and trash the overrides made # on all scenarios. - self._actions_for("checkout.sync")._sync_checkout(lines, location) + checkout_sync = self._actions_for("checkout.sync") except NoComponentError: - pass + self._lock_lines(lines) + else: + self._lock_lines(checkout_sync._all_lines_to_lock(lines)) + checkout_sync._sync_checkout(lines, location) lines.location_dest_id = location lines.package_level_id.location_dest_id = location diff --git a/shopfloor_single_product_transfer/static/description/icon.png b/shopfloor_single_product_transfer/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d GIT binary patch literal 9455 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 z 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 Xviia!U7SGha1wx#SCgwmn*{w2TRX*I literal 0 HcmV?d00001 diff --git a/shopfloor_single_product_transfer/static/description/index.html b/shopfloor_single_product_transfer/static/description/index.html new file mode 100644 index 00000000000..df719c4b489 --- /dev/null +++ b/shopfloor_single_product_transfer/static/description/index.html @@ -0,0 +1,453 @@ + + + + + + +Shopfloor Single Product Transfer + + + +
+

Shopfloor Single Product Transfer

+ + +

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

+

Allow to move a single product from a location to another one.

+

Table of contents

+ +
+

Usage

+
+
Source location selection
+
Select a source location. +It must be a valid location according to the configuration of the scenario, +and there must be stock in the selected location.
+
Move line selection
+
Select a product or a lot in this location. +If an unassigned move line for this product / lot exists in the previously selected +location, then it is selected. +Otherwise, if the Allow Move Creation is enabled, it will try to create a move line. +If the Allow to process reserved quantities option is enabled, other moves +will be unreserved. +If there’s unreserved goods in the location, a new move is created with quantity equal +to the unreserved goods in the location.
+
Set quantity / destination location
+
    +
  1. Scan a product / lot to set the quantity +If the Do not pre-fill quantity to pick option is enabled, it will increment the +done quantity by 1 each time the product or lot barcode is scanned. +Else, it will set the quantity done as the reserved quantity.
  2. +
  3. Scan a destination location +The scanned location will be checked. +It must be a child of the current line destination location or a child of +the scenario default destination location. +If this is ok, then the move is processed.
  4. +
+
+
+
+
+

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 smashing it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainer:

+

mmequignon

+

This module is part of the OCA/wms project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py index ce54e6a641f..fc4981e6890 100644 --- a/shopfloor_single_product_transfer/tests/__init__.py +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_scan_location from . import test_scan_product from . import test_set_quantity +from . import test_set_quantity_checkout_sync diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py new file mode 100644 index 00000000000..2aeca2ed2ef --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -0,0 +1,92 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetQuantityCheckoutSync(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.location = cls.location_src + cls.product = cls.product_a + + @classmethod + def _setup_picking(cls): + cls._add_stock_to_product(cls.product, cls.location, 10) + return cls._create_picking(lines=[(cls.product, 10)]) + + @classmethod + def _setup_chained_picking(cls, picking): + next_moves = picking.move_lines.browse() + for move in picking.move_lines: + next_moves |= move.copy( + { + "move_orig_ids": [(6, 0, move.ids)], + "location_id": move.location_dest_id.id, + "location_dest_id": cls.customer_location.id, + } + ) + next_moves._assign_picking() + next_picking = next_moves.picking_id + next_picking.action_confirm() + next_picking.action_assign() + return next_picking + + @classmethod + def _add_pack_move_after_pick_move(cls, pick_move, picking_type): + move_vals = { + "name": pick_move.product_id.name, + "picking_type_id": picking_type.id, + "product_id": pick_move.product_id.id, + "product_uom_qty": pick_move.product_uom_qty, + "product_uom": pick_move.product_uom.id, + "location_id": picking_type.default_location_src_id.id, + "location_dest_id": picking_type.default_location_dest_id.id, + "state": "waiting", + "procure_method": "make_to_order", + "move_orig_ids": [(6, 0, pick_move.ids)], + "group_id": pick_move.group_id.id, + } + return cls.env["stock.move"].create(move_vals) + + def test_set_quantity_child_move_location(self): + if "checkout_sync" not in self.env["stock.picking.type"]._fields: + # checkout_sync module not installed nothing to test + return + picking1 = self._setup_picking() + picking2 = self._setup_picking() + move1 = picking1.move_lines + move2 = picking2.move_lines + pack_move1 = self._add_pack_move_after_pick_move(move1, self.wh.pack_type_id) + pack_move2 = self._add_pack_move_after_pick_move(move2, self.wh.pack_type_id) + (pack_move1 | pack_move2)._assign_picking() + # Activating the checkout sync on transfer type + self.wh.sudo().pack_type_id.checkout_sync = True + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + move_line = picking1.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.name, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + data = {"location": self._data_for_location(self.location)} + completion_info = self.service._actions_for("completion.info") + expected_popup = completion_info.popup(move_line) + self.assert_response( + response, + next_state="select_product", + message=expected_message, + data=data, + popup=expected_popup, + ) + self.assertEqual(move1.move_line_ids.location_dest_id, self.dispatch_location) + # Move synchronize for checkout + self.assertEqual(move2.location_dest_id, self.dispatch_location) From db682809b399445c1952eb6f9b759f806dbd5dc4 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 25 May 2023 10:22:25 +0200 Subject: [PATCH 06/54] shopfloor_single_product_transfer: add hook --- shopfloor_single_product_transfer/__init__.py | 1 + .../__manifest__.py | 3 ++- shopfloor_single_product_transfer/hooks.py | 14 ++++++++++++++ .../migrations/14.0.1.1.0/post-migrate.py | 17 +++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 shopfloor_single_product_transfer/hooks.py create mode 100644 shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py diff --git a/shopfloor_single_product_transfer/__init__.py b/shopfloor_single_product_transfer/__init__.py index 99464a7510b..95d8980c806 100644 --- a/shopfloor_single_product_transfer/__init__.py +++ b/shopfloor_single_product_transfer/__init__.py @@ -1 +1,2 @@ from . import services +from .hooks import post_init_hook diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 412587c9a82..0916deb96c9 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.1.0.0", + "version": "14.0.1.1.0", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, Odoo Community Association (OCA)", @@ -17,4 +17,5 @@ "demo/stock_picking_type_demo.xml", "demo/shopfloor_menu_demo.xml", ], + "post_init_hook": "post_init_hook", } diff --git a/shopfloor_single_product_transfer/hooks.py b/shopfloor_single_product_transfer/hooks.py new file mode 100644 index 00000000000..b8bde8ff0f8 --- /dev/null +++ b/shopfloor_single_product_transfer/hooks.py @@ -0,0 +1,14 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__file__) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + env["shopfloor.app"].search([])._handle_registry_sync() + _logger.info("Refreshing routes for existing apps") diff --git a/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py b/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py new file mode 100644 index 00000000000..a0149474ae3 --- /dev/null +++ b/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py @@ -0,0 +1,17 @@ +# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +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, {}) + env["shopfloor.app"].search([])._handle_registry_sync() + _logger.info("Activate sync for existing apps") From 198397424c83857af6066901a1cfdb7b26c4c003 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Fri, 12 May 2023 08:19:24 +0200 Subject: [PATCH 07/54] sf_single_product_transfer: allow to scan packages on start --- .../services/single_product_transfer.py | 113 ++++++++++++++---- .../tests/__init__.py | 2 +- .../tests/common.py | 3 + .../tests/test_scan_location.py | 53 -------- .../tests/test_scan_location_or_package.py | 101 ++++++++++++++++ .../tests/test_scan_product.py | 2 +- .../tests/test_set_quantity.py | 4 +- .../tests/test_start.py | 2 +- 8 files changed, 198 insertions(+), 82 deletions(-) delete mode 100644 shopfloor_single_product_transfer/tests/test_scan_location.py create mode 100644 shopfloor_single_product_transfer/tests/test_scan_location_or_package.py diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index e23151fab79..c7d8e971357 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -61,8 +61,14 @@ class ShopfloorSingleProductTransfer(Component): def _response_for_select_location(self, message=None): return self._response(next_state="select_location", message=message) - def _response_for_select_product(self, location, message=None, popup=None): - data = {"location": self.data.location(location)} + def _response_for_select_product( + self, location=None, package=None, message=None, popup=None + ): + data = {} + if location: + data["location"] = self.data.location(location) + if package: + data["package"] = self.data.package(package) return self._response( next_state="select_product", data=data, message=message, popup=popup ) @@ -103,6 +109,34 @@ def _scan_location__check_stock(self, location): message = self.msg_store.location_empty(location) return self._response_for_select_location(message=message) + def _scan_location__check_package(self, location): + """Check if the location has lines without an assigned package.""" + lines_without_package = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("package_id", "=", False), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ] + ) + if not lines_without_package: + message = ( + self.msg_store.location_contains_only_lines_with_package_scan_one() + ) + return self._response_for_select_location(message=message) + + def _scan_package__check_location(self, package): + """Check if this package corresponds to any of the allowed locations.""" + locations = self.picking_types.default_location_src_id + child_locations = self.env["stock.location"].search( + [("id", "child_of", locations.ids)] + ) + allowed_locations = locations | child_locations + if package.location_id not in allowed_locations: + message = self.msg_store.package_not_allowed_in_src_location( + package.name, self.picking_types + ) + return self._response_for_select_location(message=message) + def _scan_product__scan_packaging(self, location, barcode): search = self._actions_for("search") packaging = search.packaging_from_scan(barcode) @@ -159,7 +193,7 @@ def _scan_product__check_tracking( ): if product.tracking == "lot": message = self.msg_store.scan_lot_on_product_tracked_by_lot() - return self._response_for_select_product(location, message=message) + return self._response_for_select_product(location=location, message=message) def _scan_product__select_move_line_domain(self, product, location, lot=None): domain = [ @@ -207,7 +241,7 @@ def _scan_product__check_create_move_line( ): if not self.is_allow_move_create(): message = self.msg_store.no_operation_found() - return self._response_for_select_product(location, message=message) + return self._response_for_select_product(location=location, message=message) def _scan_product__unreserve_move_line( self, product, location, lot=None, packaging=None @@ -254,7 +288,7 @@ def _scan_product__no_stock_available( self, product, location, lot=None, packaging=None ): message = self.msg_store.no_operation_found() - return self._response_for_select_product(location, message=message) + return self._response_for_select_product(location=location, message=message) def _scan_product__check_putaway(self, move_line): stock = self._actions_for("stock") @@ -263,7 +297,7 @@ def _scan_product__check_putaway(self, move_line): if ignore_no_putaway_available and no_putaway_available: message = self.msg_store.no_putaway_destination_available() return self._response_for_select_product( - move_line.location_id, message=message + location=move_line.location_id, package=move_line.package_id, message=message ) def _scan_product__scan_lot(self, location, barcode): @@ -533,7 +567,7 @@ def _set_quantity__post_move(self, location, move_line, confirmation=False): completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) return self._response_for_select_product( - move_line.location_id, message=message, popup=completion_info_popup + location=move_line.location_id, package=move_line.package_id, message=message, popup=completion_info_popup ) def _find_user_move_line_domain(self, user): @@ -576,6 +610,27 @@ def _set_quantity__by_location(self, move_line, barcode, confirmation=False): if response: return response + def _scan_package(self, package): + handlers = [ + self._scan_package__check_location, + ] + response = self._use_handlers(handlers, package) + if response: + return response + return self._response_for_select_product(package=package) + + def _scan_location(self, location): + handlers = [ + self._scan_location__location_found, + self._scan_location__check_location, + self._scan_location__check_stock, + self._scan_location__check_package, + ] + response = self._use_handlers(handlers, location) + if response: + return response + return self._response_for_select_product(location=location) + # Endpoints def start(self): @@ -585,29 +640,24 @@ def start(self): return self._response_for_set_quantity(move_line, message=message) return self._response_for_select_location() - def scan_location(self, barcode): - """Scan a source location. + def scan_location_or_package(self, barcode): + """Scan a source location or a source package. It is the starting point of this scenario. - If stock has been found in the scanned location, allows to scan a - product or a lot. + If stock has been found in the scanned location, or if a package has been found, + it allows to scan a product or a lot. Transitions: * select_product: to scan a product or a lot stored in the scanned location * start: no stock found or wrong barcode """ search = self._actions_for("search") + package = search.package_from_scan(barcode) + if package: + return self._scan_package(package) location = search.location_from_scan(barcode) - handlers = [ - self._scan_location__location_found, - self._scan_location__check_location, - self._scan_location__check_stock, - ] - response = self._use_handlers(handlers, location) - if response: - return response - return self._response_for_select_product(location) + return self._scan_location(location) @with_savepoint def scan_product(self, location_id, barcode): @@ -620,7 +670,7 @@ def scan_product(self, location_id, barcode): """ location = self.env["stock.location"].browse(location_id) if not location.exists(): - return self._response_for_select_product(location) + return self._response_for_select_product(location=location) handlers = [ self._scan_product__scan_product, self._scan_product__scan_packaging, @@ -630,7 +680,9 @@ def scan_product(self, location_id, barcode): if response: return response message = self.msg_store.barcode_not_found() - return self._response_for_select_product(location, message=message) + return self._response_for_select_product( + location=location, package=package, message=message + ) def scan_product__action_cancel(self): return self._response_for_select_location() @@ -673,7 +725,7 @@ class ShopfloorSingleProductTransferValidator(Component): def start(self): return {} - def scan_location(self): + def scan_location_or_package(self): return {"barcode": {"required": True, "type": "string"}} def scan_product(self): @@ -716,7 +768,7 @@ def _states(self): def start(self): return self._response_schema(next_states=self._start_next_states()) - def scan_location(self): + def scan_location_or_package(self): return self._response_schema(next_states=self._scan_location_next_states()) def scan_product(self): @@ -759,7 +811,18 @@ def _schema_select_location(self): @property def _schema_select_product(self): - return {"location": {"type": "dict", "schema": self.schemas.location()}} + return { + "location": { + "type": "dict", + "required": False, + "schema": self.schemas.location(), + }, + "package": { + "type": "dict", + "required": False, + "schema": self.schemas.package(), + }, + } @property def _schema_set_quantity(self): diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py index fc4981e6890..c8a71f6f1bd 100644 --- a/shopfloor_single_product_transfer/tests/__init__.py +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -1,5 +1,5 @@ from . import test_start -from . import test_scan_location +from . import test_scan_location_or_package from . import test_scan_product from . import test_set_quantity from . import test_set_quantity_checkout_sync diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index fe1760396a6..d62a5608038 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -118,6 +118,9 @@ def _data_for_location(self, location): def _data_for_move_line(self, move_line): return self.data.move_line(move_line) + def _data_for_package(self, package): + return self.data.package(package) + @classmethod def get_new_move_line(cls): return cls.env["stock.move.line"].search( diff --git a/shopfloor_single_product_transfer/tests/test_scan_location.py b/shopfloor_single_product_transfer/tests/test_scan_location.py deleted file mode 100644 index ce3e008eed9..00000000000 --- a/shopfloor_single_product_transfer/tests/test_scan_location.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2022 Camptocamp SA -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) - -from .common import CommonCase - - -class TestScanLocation(CommonCase): - def test_scan_location_not_found(self): - response = self.service.dispatch("scan_location", params={"barcode": "NOPE"}) - expected_message = { - "message_type": "error", - "body": "No location found for this barcode.", - } - self.assert_response( - response, next_state="select_location", data={}, message=expected_message - ) - - def test_scan_wrong_location(self): - location = self.location_customer - response = self.service.dispatch( - "scan_location", params={"barcode": location.name} - ) - expected_message = { - "message_type": "error", - "body": f"The content of {location.name} cannot be transferred with this scenario.", - } - self.assert_response( - response, next_state="select_location", data={}, message=expected_message - ) - - def test_scan_empty_location(self): - location = self.child_location - response = self.service.dispatch( - "scan_location", params={"barcode": location.name} - ) - expected_message = { - "message_type": "error", - "body": f"Location {location.name} empty", - } - self.assert_response( - response, next_state="select_location", data={}, message=expected_message - ) - - def test_scan_location_ok(self): - location = self.location_src - response = self.service.dispatch( - "scan_location", params={"barcode": location.name} - ) - self.assert_response( - response, - next_state="select_product", - data={"location": self._data_for_location(location)}, - ) diff --git a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py new file mode 100644 index 00000000000..c732fb30a61 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -0,0 +1,101 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestScanLocation(CommonCase): + def test_scan_barcode_not_found(self): + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": "NOPE"} + ) + expected_message = { + "message_type": "error", + "body": "Barcode not found", + } + self.assert_response( + response, + next_state="select_location_or_package", + data={}, + message=expected_message, + ) + + def test_scan_wrong_location(self): + location = self.location_customer + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": location.name} + ) + expected_message = { + "message_type": "error", + "body": f"The content of {location.name} cannot be transferred with this scenario.", + } + self.assert_response( + response, + next_state="select_location_or_package", + data={}, + message=expected_message, + ) + + def test_scan_empty_location(self): + location = self.child_location + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": location.name} + ) + expected_message = { + "message_type": "error", + "body": f"Location {location.name} empty", + } + self.assert_response( + response, + next_state="select_location_or_package", + data={}, + message=expected_message, + ) + + def test_scan_location_ok(self): + location = self.location_src + + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": location.name} + ) + self.assert_response( + response, + next_state="select_product", + data={"location": self._data_for_location(location)}, + ) + + def test_scan_location_only_lines_with_package(self): + location = self.location_src + package = self.env["stock.quant.package"].sudo().create({}) + for line in location.source_move_line_ids: + # There are no lines without a package in this location. + line.package_id = package + + # Scan a location, user is asked to scan a package. + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": location.name} + ) + expected_message = { + "message_type": "warning", + "body": "This location only contains lines with a package, " + "please scan one of those packages.", + } + self.assert_response( + response, + next_state="select_location_or_package", + data={}, + message=expected_message, + ) + + # Scan a package. + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": package.name} + ) + self.assert_response( + response, + next_state="select_product", + data={ + "package": self._data_for_package(package), + "location": self._data_for_location(package.location_id), + }, + ) diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index 4a5cb37bc7c..0a4bb5087c0 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -492,7 +492,7 @@ def test_create_move_line_by_lot_no_prefill_qty_enabled(self): def test_action_cancel(self): response = self.service.dispatch("scan_product__action_cancel") - self.assert_response(response, next_state="select_location", data={}) + self.assert_response(response, next_state="select_location_or_package", data={}) def test_scan_product_packaging(self): location = self.location_src diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index d0c6c0ecb53..4ce73cf1f34 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -553,7 +553,9 @@ def test_action_cancel(self): "set_quantity__action_cancel", params={"selected_line_id": move_line.id} ) data = {} - self.assert_response(response, next_state="select_location", data=data) + self.assert_response( + response, next_state="select_location_or_package", data=data + ) # Ensure the qty_done and user has been reset. self.assertFalse(move_line.picking_id.user_id) self.assertEqual(move_line.qty_done, 0.0) diff --git a/shopfloor_single_product_transfer/tests/test_start.py b/shopfloor_single_product_transfer/tests/test_start.py index 56267c16e6e..cd8b824584b 100644 --- a/shopfloor_single_product_transfer/tests/test_start.py +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -7,7 +7,7 @@ class TestStart(CommonCase): def test_start(self): response = self.service.dispatch("start") - self.assert_response(response, next_state="select_location", data={}) + self.assert_response(response, next_state="select_location_or_package", data={}) def test_recover(self): product = self.product_a From 7fac53eb4a31d11804d2e423a91d3e852fa57ef0 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 15 May 2023 15:33:13 +0200 Subject: [PATCH 08/54] sf_single_product_transfer: scan_product with package / location context --- .../services/single_product_transfer.py | 255 +++++++++++------- .../tests/test_scan_location_or_package.py | 24 +- 2 files changed, 183 insertions(+), 96 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index c7d8e971357..d049ccf3288 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -58,8 +58,8 @@ class ShopfloorSingleProductTransfer(Component): # Responses - def _response_for_select_location(self, message=None): - return self._response(next_state="select_location", message=message) + def _response_for_select_location_or_package(self, message=None): + return self._response(next_state="select_location_or_package", message=message) def _response_for_select_product( self, location=None, package=None, message=None, popup=None @@ -84,60 +84,55 @@ def _response_for_set_quantity( # Handlers - def _scan_location__location_found(self, location): + def _scan_location__location_found(self, location, quants): """Check that the location exists.""" if not location: message = self.msg_store.no_location_found() - return self._response_for_select_location(message=message) + return self._response_for_select_location_or_package(message=message) - def _scan_location__check_location(self, location): + def _scan_location__check_location(self, location, quants): """Check that `location` belongs to the source location of the operation type.""" - locations = self.picking_types.default_location_src_id - child_locations = self.env["stock.location"].search( - [("id", "child_of", locations.ids)] - ) - if location not in (locations | child_locations): + if not self.is_src_location_valid(location): message = self.msg_store.location_content_unable_to_transfer(location) - return self._response_for_select_location(message=message) + return self._response_for_select_location_or_package(message=message) - def _scan_location__check_stock(self, location): - """Check if the location has products to move.""" - quants_in_location = self.env["stock.quant"].search( - [("location_id", "=", location.id), ("quantity", ">", 0)] - ) - if not quants_in_location: + def _scan_location__check_stock(self, location, quants): + """Check that the location has products to move.""" + if not quants: message = self.msg_store.location_empty(location) - return self._response_for_select_location(message=message) - - def _scan_location__check_package(self, location): - """Check if the location has lines without an assigned package.""" - lines_without_package = self.env["stock.move.line"].search( - [ - ("location_id", "=", location.id), - ("package_id", "=", False), - ("picking_id.picking_type_id", "in", self.picking_types.ids), - ] - ) - if not lines_without_package: - message = ( - self.msg_store.location_contains_only_lines_with_package_scan_one() + return self._response_for_select_location_or_package(message=message) + + def _scan_location__check_stock_packages(self, location, quants): + """Check that there are quants without an assigned package.""" + quant_packages = [quant.package_id for quant in quants] + if all(quant_packages): + message = self.msg_store.location_contains_only_packages_scan_one() + return self._response_for_select_location_or_package(message=message) + + def _scan_location__check_line_packages(self, location, quants): + """Check that the location has lines without an assigned package.""" + if not self.is_allow_move_create(): + lines_without_package = self.env["stock.move.line"].search( + [ + ("location_id", "=", location.id), + ("package_id", "=", False), + ("state", "in", ["assigned", "partially_available"]), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ] ) - return self._response_for_select_location(message=message) + if not lines_without_package: + message = self.msg_store.location_contains_only_packages_scan_one() + return self._response_for_select_location_or_package(message=message) def _scan_package__check_location(self, package): """Check if this package corresponds to any of the allowed locations.""" - locations = self.picking_types.default_location_src_id - child_locations = self.env["stock.location"].search( - [("id", "child_of", locations.ids)] - ) - allowed_locations = locations | child_locations - if package.location_id not in allowed_locations: + if package.location_id and not self.is_src_location_valid(package.location_id): message = self.msg_store.package_not_allowed_in_src_location( package.name, self.picking_types ) - return self._response_for_select_location(message=message) + return self._response_for_select_location_or_package(message=message) - def _scan_product__scan_packaging(self, location, barcode): + def _scan_product__scan_packaging(self, barcode, location=None, package=None): search = self._actions_for("search") packaging = search.packaging_from_scan(barcode) handlers = [ @@ -159,12 +154,16 @@ def _scan_product__scan_packaging(self, location, barcode): if packaging: product = packaging.product_id response = self._use_handlers( - handlers, product, location, packaging=packaging + handlers, + product, + location=location, + package=package, + packaging=packaging, ) if response: return response - def _scan_product__scan_product(self, location, barcode): + def _scan_product__scan_product(self, barcode, location=None, package=None): search = self._actions_for("search") product = search.product_from_scan(barcode) handlers = [ @@ -184,34 +183,40 @@ def _scan_product__scan_product(self, location, barcode): self._scan_product__no_stock_available, ] if product: - response = self._use_handlers(handlers, product, location) + response = self._use_handlers( + handlers, product, location=location, package=package + ) if response: return response def _scan_product__check_tracking( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): if product.tracking == "lot": message = self.msg_store.scan_lot_on_product_tracked_by_lot() - return self._response_for_select_product(location=location, message=message) + return self._response_for_select_product( + location=location, package=package, message=message + ) - def _scan_product__select_move_line_domain(self, product, location, lot=None): + def _scan_product__select_move_line_domain( + self, product, location=None, package=None, lot=None + ): domain = [ - ("location_id", "=", location.id), ("product_id", "=", product.id), ("state", "in", ("assigned", "partially_available")), ("picking_id.user_id", "in", (False, self.env.uid)), ("picking_id.picking_type_id", "in", self.picking_types.ids), ] - if lot: - lot_domain = [("lot_id", "=", lot.id)] - domain = AND([domain, lot_domain]) - return domain + return self._add_location_package_lot_domain( + domain, location=location, package=package, lot=lot + ) def _scan_product__select_move_line( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): - domain = self._scan_product__select_move_line_domain(product, location, lot=lot) + domain = self._scan_product__select_move_line_domain( + product, location=location, package=package, lot=lot + ) query = self.env["stock.move.line"]._search(domain, limit=1) order_elems = [ "stock_move_line__picking_id.user_id", @@ -237,18 +242,22 @@ def _scan_product__select_move_line( return self._response_for_set_quantity(move_line) def _scan_product__check_create_move_line( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): if not self.is_allow_move_create(): message = self.msg_store.no_operation_found() - return self._response_for_select_product(location=location, message=message) + return self._response_for_select_product( + location=location, package=package, message=message + ) def _scan_product__unreserve_move_line( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): unreserve = self._actions_for("stock.unreserve") if self.work.menu.allow_unreserve_other_moves: - move_lines = self._find_location_move_lines(location, product, lot=lot) + move_lines = self._find_location_or_package_move_lines( + product, location=location, package=package, lot=lot + ) response = unreserve.check_unreserve(location, move_lines, product, lot) if response: return response @@ -257,14 +266,21 @@ def _scan_product__unreserve_move_line( # If we get there then no qty is available, and we are not allowed to unreserve # other moves. No stock available for product. return self._scan_product__no_stock_available( - product, location, lot=lot, packaging=packaging + product, + location=location, + package=package, + lot=lot, + packaging=packaging, ) def _scan_product__create_move_line( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): + available_quantity = product.with_context( - location_id=location.id, lot=lot.id if lot else None + location_id=location.id if location else None, + package_id=package.id if package else None, + lot=lot.id if lot else None, ).free_qty is_product_available = ( float_compare( @@ -276,7 +292,12 @@ def _scan_product__create_move_line( ) if is_product_available: move = self._create_move_from_location( - location, product, available_quantity, lot=lot, packaging=packaging + product, + available_quantity, + location=location, + package=package, + lot=lot, + packaging=packaging, ) move_line = move.move_line_ids response = self._scan_product__check_putaway(move_line) @@ -285,10 +306,12 @@ def _scan_product__create_move_line( return self._response_for_set_quantity(move_line) def _scan_product__no_stock_available( - self, product, location, lot=None, packaging=None + self, product, location=None, package=None, lot=None, packaging=None ): message = self.msg_store.no_operation_found() - return self._response_for_select_product(location=location, message=message) + return self._response_for_select_product( + location=location, package=package, message=message + ) def _scan_product__check_putaway(self, move_line): stock = self._actions_for("stock") @@ -300,7 +323,7 @@ def _scan_product__check_putaway(self, move_line): location=move_line.location_id, package=move_line.package_id, message=message ) - def _scan_product__scan_lot(self, location, barcode): + def _scan_product__scan_lot(self, barcode, location=None, package=None): search = self._actions_for("search") handlers = [ self._scan_product__select_move_line, @@ -320,7 +343,9 @@ def _scan_product__scan_lot(self, location, barcode): lot = search.lot_from_scan(barcode) if lot: product = lot.product_id - product_response = self._use_handlers(handlers, product, location, lot=lot) + product_response = self._use_handlers( + handlers, product, location=location, package=package, lot=lot + ) if product_response: return product_response @@ -332,29 +357,44 @@ def _use_handlers(self, handlers, *args, **kwargs): if response: return response + def _add_location_package_lot_domain( + self, domain, location=None, package=None, lot=None + ): + if location: + domain = AND([domain, [("location_id", "=", location.id)]]) + if lot: + domain = AND([domain, [("lot_id", "=", lot.id)]]) + domain = AND([domain, [("package_id", "=", package.id if package else False)]]) + return domain + # Copied from manual_product_transfer - def _find_location_move_lines_domain(self, location, product, lot=None): + def _find_location_or_package_move_lines_domain( + self, product, location=None, package=None, lot=None + ): domain = [ - ("location_id", "=", location.id), ("product_id", "=", product.id), ("state", "in", ("assigned", "partially_available")), ("picking_id.user_id", "in", (False, self.env.uid)), ] - if lot: - domain = AND([domain, [("lot_id", "=", lot.id)]]) - return domain + return self._add_location_package_lot_domain( + domain, location=location, package=package, lot=lot + ) # Copied from manual_product_transfer - def _find_location_move_lines(self, location, product, lot=None): + def _find_location_or_package_move_lines( + self, product, location=None, package=None, lot=None + ): """Find existing move lines in progress related to the source location but not linked to any user. """ - domain = self._find_location_move_lines_domain(location, product, lot=lot) + domain = self._find_location_or_package_move_lines_domain( + product, location=location, package=package, lot=lot + ) return self.env["stock.move.line"].search(domain) # Copied from manual_product_transfer def _create_move_from_location( - self, location, product, quantity, lot=None, packaging=None + self, product, quantity, location=None, package=None, lot=None, packaging=None ): picking_type = self.picking_types move_vals = { @@ -363,13 +403,24 @@ def _create_move_from_location( "product_id": product.id, "product_uom": product.uom_id.id, "product_uom_qty": quantity, - "location_id": location.id, "location_dest_id": picking_type.default_location_dest_id.id, "origin": self.work.menu.name, "picking_type_id": picking_type.id, } + if location: + move_vals["location_id"] = location.id move = self.env["stock.move"].create(move_vals) move._action_confirm(merge=False) + if package: + package_level = self.env["stock.package_level"].create( + { + "picking_id": move.picking_id.id, + "package_id": package.id, + "location_dest_id": move.picking_id.location_dest_id.id, + "company_id": self.env.company.id, + } + ) + move.package_level_id = package_level move.with_context( {"force_reservation": self.work.menu.allow_force_reservation} )._action_assign() @@ -619,14 +670,15 @@ def _scan_package(self, package): return response return self._response_for_select_product(package=package) - def _scan_location(self, location): + def _scan_location(self, location, quants): handlers = [ self._scan_location__location_found, self._scan_location__check_location, self._scan_location__check_stock, - self._scan_location__check_package, + self._scan_location__check_stock_packages, + self._scan_location__check_line_packages, ] - response = self._use_handlers(handlers, location) + response = self._use_handlers(handlers, location, quants) if response: return response return self._response_for_select_product(location=location) @@ -638,7 +690,7 @@ def start(self): if move_line: message = self.msg_store.recovered_previous_session() return self._response_for_set_quantity(move_line, message=message) - return self._response_for_select_location() + return self._response_for_select_location_or_package() def scan_location_or_package(self, barcode): """Scan a source location or a source package. @@ -657,11 +709,22 @@ def scan_location_or_package(self, barcode): if package: return self._scan_package(package) location = search.location_from_scan(barcode) - return self._scan_location(location) + quants_in_location = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("quantity", ">", 0)] + ) + return self._scan_location(location, quants_in_location) @with_savepoint - def scan_product(self, location_id, barcode): - """Looks for a move line in the given location, from a barcode. + def scan_product(self, barcode, location_id=None, package_id=None): + """Looks for a move line in the given location or package, from a barcode. + + This endpoint will take either a location_id or a package_id, + depending on what the user has scanned in the previous screen. + This will be used as context to handle the scan and apply the necessary checks. + + We will receive either: + - location_id + - package_id Barcode can be: - a product @@ -669,14 +732,17 @@ def scan_product(self, location_id, barcode): - a lot """ location = self.env["stock.location"].browse(location_id) - if not location.exists(): - return self._response_for_select_product(location=location) + package = self.env["stock.quant.package"].browse(package_id) + if not location.exists() and not package.exists(): + return self._response_for_select_location_or_package() handlers = [ self._scan_product__scan_product, self._scan_product__scan_packaging, self._scan_product__scan_lot, ] - response = self._use_handlers(handlers, location, barcode) + response = self._use_handlers( + handlers, barcode, location=location, package=package + ) if response: return response message = self.msg_store.barcode_not_found() @@ -685,7 +751,7 @@ def scan_product(self, location_id, barcode): ) def scan_product__action_cancel(self): - return self._response_for_select_location() + return self._response_for_select_location_or_package() def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): """Sets quantity done if a product is scanned, @@ -714,7 +780,7 @@ def set_quantity__action_cancel(self, selected_line_id): stock = self._actions_for("stock") move_line = self.env["stock.move.line"].browse(selected_line_id).exists() stock.unmark_move_line_as_picked(move_line) - return self._response_for_select_location() + return self._response_for_select_location_or_package() class ShopfloorSingleProductTransferValidator(Component): @@ -730,7 +796,8 @@ def scan_location_or_package(self): def scan_product(self): return { - "location_id": {"coerce": to_int, "required": True, "type": "integer"}, + "location_id": {"coerce": to_int, "required": False, "type": "integer"}, + "package_id": {"coerce": to_int, "required": False, "type": "integer"}, "barcode": {"required": True, "type": "string"}, } @@ -756,11 +823,11 @@ class ShopfloorSingleProductTransferValidatorResponse(Component): _name = "shopfloor.single.product.transfer.validator.response" _usage = "single_product_transfer.validator.response" - _start_state = "select_location" + _start_state = "select_location_or_package" def _states(self): return { - "select_location": self._schema_select_location, + "select_location_or_package": self._schema_select_location_or_package, "select_product": self._schema_select_product, "set_quantity": self._schema_set_quantity, } @@ -788,25 +855,25 @@ def set_quantity__action_cancel(self): ) def _start_next_states(self): - return {"select_location", "set_quantity"} + return {"select_location_or_package", "set_quantity"} def _scan_location_next_states(self): - return {"select_location", "select_product"} + return {"select_location_or_package", "select_product"} def _scan_product_next_states(self): return {"select_product", "set_quantity"} def _scan_product__action_cancel_next_states(self): - return {"select_location"} + return {"select_location_or_package"} def _set_quantity_next_states(self): return {"set_quantity", "select_product"} def _set_quantity__action_cancel_next_states(self): - return {"select_location"} + return {"select_location_or_package"} @property - def _schema_select_location(self): + def _schema_select_location_or_package(self): return {} @property diff --git a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py index c732fb30a61..1cea54e534a 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -53,6 +53,7 @@ def test_scan_empty_location(self): ) def test_scan_location_ok(self): + self._enable_create_move_line() location = self.location_src response = self.service.dispatch( @@ -64,6 +65,26 @@ def test_scan_location_ok(self): data={"location": self._data_for_location(location)}, ) + def test_scan_location_stock_packages(self): + location = self.location_src + package = self.env["stock.quant.package"].sudo().create({}) + for quant in location.quant_ids: + quant.sudo().package_id = package + + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": location.name} + ) + expected_message = { + "message_type": "warning", + "body": "This location only contains packages, please scan one of them.", + } + self.assert_response( + response, + next_state="select_location_or_package", + data={}, + message=expected_message, + ) + def test_scan_location_only_lines_with_package(self): location = self.location_src package = self.env["stock.quant.package"].sudo().create({}) @@ -77,8 +98,7 @@ def test_scan_location_only_lines_with_package(self): ) expected_message = { "message_type": "warning", - "body": "This location only contains lines with a package, " - "please scan one of those packages.", + "body": "This location only contains packages, please scan one of them.", } self.assert_response( response, From dd1eb17599f44de59be51769d12cd5be3138f4d0 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 24 May 2023 10:18:53 +0200 Subject: [PATCH 09/54] sf_single_product_transfer: transfer product to package --- .../services/single_product_transfer.py | 140 +++++++++++++++--- .../tests/__init__.py | 1 + .../tests/test_set_location.py | 74 +++++++++ .../tests/test_set_quantity.py | 66 +++++++++ 4 files changed, 260 insertions(+), 21 deletions(-) create mode 100644 shopfloor_single_product_transfer/tests/test_set_location.py diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index d049ccf3288..6345e32b783 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -82,6 +82,13 @@ def _response_for_set_quantity( } return self._response(next_state="set_quantity", data=data, message=message) + def _response_for_set_location(self, move_line, package, message=None): + data = { + "move_line": self.data.move_line(move_line), + "package": self.data.package(package), + } + return self._response(next_state="set_location", data=data, message=message) + # Handlers def _scan_location__location_found(self, location, quants): @@ -278,9 +285,9 @@ def _scan_product__create_move_line( ): available_quantity = product.with_context( - location_id=location.id if location else None, + location=location.id if location else None, package_id=package.id if package else None, - lot=lot.id if lot else None, + lot_id=lot.id if lot else None, ).free_qty is_product_available = ( float_compare( @@ -397,27 +404,30 @@ def _create_move_from_location( self, product, quantity, location=None, package=None, lot=None, packaging=None ): picking_type = self.picking_types + location = location or package.location_id move_vals = { "name": product.name, "company_id": picking_type.company_id.id, "product_id": product.id, "product_uom": product.uom_id.id, "product_uom_qty": quantity, + "location_id": location.id, "location_dest_id": picking_type.default_location_dest_id.id, "origin": self.work.menu.name, "picking_type_id": picking_type.id, } - if location: - move_vals["location_id"] = location.id move = self.env["stock.move"].create(move_vals) move._action_confirm(merge=False) + picking = move.picking_id if package: + # When we create a package_level, we force the reservation of the scanned package. package_level = self.env["stock.package_level"].create( { - "picking_id": move.picking_id.id, + "picking_id": picking.id, "package_id": package.id, - "location_dest_id": move.picking_id.location_dest_id.id, - "company_id": self.env.company.id, + "location_id": package.location_id.id, + "location_dest_id": picking.location_dest_id.id, + "company_id": picking.company_id.id, } ) move.package_level_id = package_level @@ -456,7 +466,7 @@ def _set_quantity__check_product_in_line( return self._response_for_set_quantity(move_line, message=message) def _set_quantity__check_quantity_done( - self, location, move_line, confirmation=False + self, move_line, location=None, package=None, confirmation=False ): rounding = move_line.product_id.uom_id.rounding qty_done = move_line.qty_done @@ -551,7 +561,7 @@ def _valid_dest_location_for_menu(self): domain = self._valid_dest_location_for_menu_domain() return self.env["stock.location"].search(domain) - def _set_quantity__check_location(self, location, move_line, confirmation=False): + def _set_quantity__check_location(self, move_line, location, confirmation=False): valid_locations_for_move_line = ( self._set_quantity__valid_dest_location_for_move_line(move_line) ) @@ -595,7 +605,7 @@ def _write_destination_on_lines(self, lines, location): # And all of them has a different implementation, # To refactor later. try: - # TODO loose dependency on 'shopfloor_checkout_sync' to avoid having + # TODO lose dependency on 'shopfloor_checkout_sync' to avoid having # yet another glue module. In the long term we should make # 'shopfloor_checkout_sync' use events and trash the overrides made # on all scenarios. @@ -606,14 +616,12 @@ def _write_destination_on_lines(self, lines, location): self._lock_lines(checkout_sync._all_lines_to_lock(lines)) checkout_sync._sync_checkout(lines, location) lines.location_dest_id = location - lines.package_level_id.location_dest_id = location - def _set_quantity__post_move(self, location, move_line, confirmation=False): + def _set_quantity__post_move(self, move_line, location, confirmation=False): # TODO qty_done = 0: transfer_no_qty_done # TODO qty done < product_qty: transfer_confirm_done self._write_destination_on_lines(move_line, location) - stock = self._actions_for("stock") - stock.validate_moves(move_line.move_id) + self._post_move(move_line) message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) @@ -621,6 +629,43 @@ def _set_quantity__post_move(self, location, move_line, confirmation=False): location=move_line.location_id, package=move_line.package_id, message=message, popup=completion_info_popup ) + def _post_move(self, move_line): + # TODO: when we split the move, we still get a + # backorder, which should not be the case. + # See if there's a way to identify the moves + # generated through this mechanism and avoid creating them. + move_line._split_partial_quantity() + new_move = move_line.move_id.split_other_move_lines( + move_line, intersection=True + ) + if new_move: + # A new move is created in case of partial quantity + new_move.extract_and_action_done() + return + # In case of full quantity, post the initial move + move_line.move_id.extract_and_action_done() + + def _set_quantity__package_not_empty(self, move_line, package, confirmation=False): + if any(package.quant_ids): + location = package.location_id + handlers = [ + # Cannot confirm if qty_done is not valid (> qty todo) + self._set_quantity__check_location, + self._set_quantity__post_move, + ] + if location: + response = self._use_handlers( + handlers, move_line, location=location, confirmation=confirmation + ) + if response: + move_line.result_package_id = package + return response + + def _set_quantity__package_empty(self, move_line, package, confirmation=False): + if not package.quant_ids: + move_line.result_package_id = package + return self._response_for_set_location(move_line, package) + def _find_user_move_line_domain(self, user): return [ ("picking_id.user_id", "in", (False, self.env.uid)), @@ -648,15 +693,31 @@ def _set_quantity__by_product(self, move_line, barcode, confirmation=False): def _set_quantity__by_location(self, move_line, barcode, confirmation=False): search = self._actions_for("search") location = search.location_from_scan(barcode) + if location: + move_line.result_package_id = False + handlers = [ + # Cannot confirm if qty_done is not valid (> qty todo) + self._set_quantity__check_quantity_done, + self._set_quantity__check_location, + self._set_quantity__post_move, + ] + response = self._use_handlers( + handlers, move_line, location=location, confirmation=confirmation + ) + if response: + return response + + def _set_quantity__by_package(self, move_line, barcode, confirmation=False): + search = self._actions_for("search") + package = search.package_from_scan(barcode) handlers = [ - # Cannot confirm if qty_done is not valid (> qty todo) self._set_quantity__check_quantity_done, - self._set_quantity__check_location, - self._set_quantity__post_move, + self._set_quantity__package_not_empty, + self._set_quantity__package_empty, ] - if location: + if package: response = self._use_handlers( - handlers, location, move_line, confirmation=confirmation + handlers, move_line, package=package, confirmation=confirmation ) if response: return response @@ -755,7 +816,8 @@ def scan_product__action_cancel(self): def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): """Sets quantity done if a product is scanned, - or posts the move if a location is scanned. + posts the move if a location is scanned + or moves the products to a package if a package is scanned. """ move_line = self.env["stock.move.line"].browse(selected_line_id) if not move_line.exists(): @@ -767,6 +829,8 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): self._set_quantity__by_product, # Post the move if a location is scanned self._set_quantity__by_location, + # Puts the product in a new or an existing pack + self._set_quantity__by_package, ] response = self._use_handlers( handlers, move_line, barcode, confirmation=confirmation @@ -782,6 +846,19 @@ def set_quantity__action_cancel(self, selected_line_id): stock.unmark_move_line_as_picked(move_line) return self._response_for_select_location_or_package() + def set_location(self, selected_line_id, package_id, barcode): + """Sets the destination location + if a package is scanned using the set_quantity endpoint. + """ + move_line = self.env["stock.move.line"].browse(selected_line_id) + response = self._set_quantity__by_location(move_line, barcode) + if response: + move_line.result_package_id = package_id + return response + package = self.env["stock.quant.package"].browse(package_id) + message = self.msg_store.barcode_not_found() + return self._response_for_set_location(move_line, package, message=message) + class ShopfloorSingleProductTransferValidator(Component): _inherit = "base.shopfloor.validator" @@ -817,6 +894,13 @@ def set_quantity__action_cancel(self): "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, } + def set_location(self): + return { + "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "package_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + class ShopfloorSingleProductTransferValidatorResponse(Component): _inherit = "base.shopfloor.validator.response" @@ -830,6 +914,7 @@ def _states(self): "select_location_or_package": self._schema_select_location_or_package, "select_product": self._schema_select_product, "set_quantity": self._schema_set_quantity, + "set_location": self._schema_set_location, } def start(self): @@ -854,6 +939,9 @@ def set_quantity__action_cancel(self): next_states=self._set_quantity__action_cancel_next_states() ) + def set_location(self): + return self._response_schema(next_states=self._set_location_next_states()) + def _start_next_states(self): return {"select_location_or_package", "set_quantity"} @@ -867,11 +955,14 @@ def _scan_product__action_cancel_next_states(self): return {"select_location_or_package"} def _set_quantity_next_states(self): - return {"set_quantity", "select_product"} + return {"set_quantity", "select_product", "set_location"} def _set_quantity__action_cancel_next_states(self): return {"select_location_or_package"} + def _set_location_next_states(self): + return {"set_quantity", "select_product", "set_location"} + @property def _schema_select_location_or_package(self): return {} @@ -897,3 +988,10 @@ def _schema_set_quantity(self): "move_line": {"type": "dict", "schema": self.schemas.move_line()}, "asking_confirmation": {"type": "boolean", "nullable": True}, } + + @property + def _schema_set_location(self): + return { + "move_line": {"type": "dict", "schema": self.schemas.move_line()}, + "package": {"type": "dict", "schema": self.schemas.package()}, + } diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py index c8a71f6f1bd..7ec1261ca0e 100644 --- a/shopfloor_single_product_transfer/tests/__init__.py +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_scan_product from . import test_set_quantity from . import test_set_quantity_checkout_sync +from . import test_set_location diff --git a/shopfloor_single_product_transfer/tests/test_set_location.py b/shopfloor_single_product_transfer/tests/test_set_location.py new file mode 100644 index 00000000000..ba09a5d1a0c --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_location.py @@ -0,0 +1,74 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestSetLocation(CommonCase): + # set_location shoulf behave the same way as _set_quantity__by_location, + # which is tested in its own test file. + # Here we're only verifying that the set_location endpoint works. + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.location = cls.location_src + cls.product = cls.product_a + + @classmethod + def _setup_picking(cls): + cls._add_stock_to_product(cls.product, cls.location, 10) + return cls._create_picking(lines=[(cls.product, 10)]) + + def test_set_location_ok(self): + package = ( + self.env["stock.quant.package"].sudo().create({"name": "test-package"}) + ) + picking = self._setup_picking() + move_line = picking.move_line_ids + location = self.dispatch_location + response = self.service.dispatch( + "set_location", + params={ + "selected_line_id": move_line.id, + "package_id": package.id, + "barcode": location.name, + }, + ) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + completion_info = self.service._actions_for("completion.info") + expected_popup = completion_info.popup(move_line) + data = {"location": self._data_for_location(self.location)} + self.assert_response( + response, + next_state="select_product", + message=expected_message, + data=data, + popup=expected_popup, + ) + + def test_set_location_barcode_not_found(self): + package = ( + self.env["stock.quant.package"].sudo().create({"name": "test-package"}) + ) + picking = self._setup_picking() + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_location", + params={ + "selected_line_id": move_line.id, + "package_id": package.id, + "barcode": "wrong-barcode", + }, + ) + expected_data = { + "move_line": self._data_for_move_line(move_line), + "package": self._data_for_package(package), + } + expected_message = self.msg_store.barcode_not_found() + self.assert_response( + response, + next_state="set_location", + data=expected_data, + message=expected_message, + ) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 4ce73cf1f34..6322fbfdc39 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -590,3 +590,69 @@ def test_set_quantity_done_with_completion_info(self): data=data, popup=expected_popup, ) + + def test_set_quantity_scan_package_not_empty(self): + # We scan a package that's not empty + # and its location is selected. + package = ( + self.env["stock.quant.package"].sudo().create({"name": "test-package"}) + ) + self.env["stock.quant"].sudo().create( + { + "package_id": package.id, + "location_id": self.dispatch_location.id, + "product_id": self.product.id, + "quantity": 10.0, + } + ) + picking = self._setup_picking() + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 10.0, + "barcode": package.name, + }, + ) + expected_data = { + "location": self._data_for_location(self.location), + } + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + completion_info = self.service._actions_for("completion.info") + expected_popup = completion_info.popup(move_line) + self.assert_response( + response, + next_state="select_product", + data=expected_data, + message=expected_message, + popup=expected_popup, + ) + self.assertEqual(package, move_line.result_package_id) + + def test_set_quantity_scan_package_empty(self): + # We scan an empty package + # and are redirected to set_location. + package = ( + self.env["stock.quant.package"].sudo().create({"name": "test-package"}) + ) + picking = self._setup_picking() + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 10.0, + "barcode": package.name, + }, + ) + expected_data = { + "move_line": self._data_for_move_line(move_line), + "package": self._data_for_package(package), + } + self.assert_response( + response, + next_state="set_location", + data=expected_data, + ) + self.assertEqual(package, move_line.result_package_id) From d81ce4c20ad03cd5cbfa3ea39aa99429fede8233 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Fri, 2 Jun 2023 11:52:09 +0200 Subject: [PATCH 10/54] shopfloor_single_product_transfer: Use search.find() --- .../services/single_product_transfer.py | 278 ++++++++---------- 1 file changed, 125 insertions(+), 153 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 6345e32b783..a763c9751ef 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -139,9 +139,15 @@ def _scan_package__check_location(self, package): ) return self._response_for_select_location_or_package(message=message) - def _scan_product__scan_packaging(self, barcode, location=None, package=None): - search = self._actions_for("search") - packaging = search.packaging_from_scan(barcode) + def _scan_package__check_stock(self, package): + """Check if this package corresponds to any of the allowed locations.""" + if not package.quant_ids: + message = self.msg_store.package_not_allowed_in_src_location( + package.name, self.picking_types + ) + return self._response_for_select_location_or_package(message=message) + + def _scan_product__scan_packaging(self, packaging, location=None, package=None): handlers = [ self._scan_product__check_tracking, self._scan_product__select_move_line, @@ -158,21 +164,16 @@ def _scan_product__scan_packaging(self, barcode, location=None, package=None): # Then return a `no product available` error self._scan_product__no_stock_available, ] - if packaging: - product = packaging.product_id - response = self._use_handlers( - handlers, - product, - location=location, - package=package, - packaging=packaging, - ) - if response: - return response + product = packaging.product_id + return self._use_handlers( + handlers, + product, + location=location, + package=package, + packaging=packaging, + ) - def _scan_product__scan_product(self, barcode, location=None, package=None): - search = self._actions_for("search") - product = search.product_from_scan(barcode) + def _scan_product__scan_product(self, product, location=None, package=None): handlers = [ self._scan_product__check_tracking, self._scan_product__select_move_line, @@ -189,12 +190,7 @@ def _scan_product__scan_product(self, barcode, location=None, package=None): # Then return a `no product available` error self._scan_product__no_stock_available, ] - if product: - response = self._use_handlers( - handlers, product, location=location, package=package - ) - if response: - return response + return self._use_handlers(handlers, product, location=location, package=package) def _scan_product__check_tracking( self, product, location=None, package=None, lot=None, packaging=None @@ -327,11 +323,12 @@ def _scan_product__check_putaway(self, move_line): if ignore_no_putaway_available and no_putaway_available: message = self.msg_store.no_putaway_destination_available() return self._response_for_select_product( - location=move_line.location_id, package=move_line.package_id, message=message + location=move_line.location_id, + package=move_line.package_id, + message=message, ) - def _scan_product__scan_lot(self, barcode, location=None, package=None): - search = self._actions_for("search") + def _scan_product__scan_lot(self, lot, location=None, package=None): handlers = [ self._scan_product__select_move_line, # If no line is found, we might try to create one, @@ -347,14 +344,12 @@ def _scan_product__scan_lot(self, barcode, location=None, package=None): # Then return a `no product available` error self._scan_product__no_stock_available, ] - lot = search.lot_from_scan(barcode) - if lot: - product = lot.product_id - product_response = self._use_handlers( - handlers, product, location=location, package=package, lot=lot - ) - if product_response: - return product_response + product = lot.product_id + product_response = self._use_handlers( + handlers, product, location=location, package=package, lot=lot + ) + if product_response: + return product_response def _use_handlers(self, handlers, *args, **kwargs): # TODO: each handler should raise a Shopfloor dedicated exception @@ -507,36 +502,19 @@ def _set_quantity__scan_product_handlers(self): self._set_quantity__increment_qty_done, ) - def _set_quantity__scan_product(self, move_line, barcode, confirmation=False): - search = self._actions_for("search") - product = search.product_from_scan(barcode) + def _set_quantity__by_product(self, move_line, product, confirmation=False): handlers = self._set_quantity__scan_product_handlers() - if product: - response = self._use_handlers(handlers, move_line, product) - if response: - return response + return self._use_handlers(handlers, move_line, product) - def _set_quantity__scan_lot(self, move_line, barcode, confirmation=False): - search = self._actions_for("search") - lot = search.lot_from_scan(barcode) + def _set_quantity__by_lot(self, move_line, lot, confirmation=False): handlers = self._set_quantity__scan_product_handlers() - if lot: - product = lot.product_id - response = self._use_handlers(handlers, move_line, product, lot=lot) - if response: - return response + product = lot.product_id + return self._use_handlers(handlers, move_line, product, lot=lot) - def _set_quantity__scan_packaging(self, move_line, barcode, confirmation=False): - search = self._actions_for("search") - packaging = search.packaging_from_scan(barcode) + def _set_quantity__by_packaging(self, move_line, packaging, confirmation=False): handlers = self._set_quantity__scan_product_handlers() - if packaging: - product = packaging.product_id - response = self._use_handlers( - handlers, move_line, product, packaging=packaging - ) - if response: - return response + product = packaging.product_id + return self._use_handlers(handlers, move_line, product, packaging=packaging) def _set_quantity__valid_dest_location_for_move_line_domain(self, move_line): move_line_dest_location = move_line.location_dest_id @@ -626,7 +604,10 @@ def _set_quantity__post_move(self, move_line, location, confirmation=False): completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) return self._response_for_select_product( - location=move_line.location_id, package=move_line.package_id, message=message, popup=completion_info_popup + location=move_line.location_id, + package=move_line.package_id, + message=message, + popup=completion_info_popup, ) def _post_move(self, move_line): @@ -645,27 +626,6 @@ def _post_move(self, move_line): # In case of full quantity, post the initial move move_line.move_id.extract_and_action_done() - def _set_quantity__package_not_empty(self, move_line, package, confirmation=False): - if any(package.quant_ids): - location = package.location_id - handlers = [ - # Cannot confirm if qty_done is not valid (> qty todo) - self._set_quantity__check_location, - self._set_quantity__post_move, - ] - if location: - response = self._use_handlers( - handlers, move_line, location=location, confirmation=confirmation - ) - if response: - move_line.result_package_id = package - return response - - def _set_quantity__package_empty(self, move_line, package, confirmation=False): - if not package.quant_ids: - move_line.result_package_id = package - return self._response_for_set_location(move_line, package) - def _find_user_move_line_domain(self, user): return [ ("picking_id.user_id", "in", (False, self.env.uid)), @@ -680,58 +640,61 @@ def _find_user_move_line(self): domain = self._find_user_move_line_domain(user) return self.env["stock.move.line"].search(domain, limit=1) - def _set_quantity__by_product(self, move_line, barcode, confirmation=False): - product_handlers = [ - self._set_quantity__scan_product, - self._set_quantity__scan_packaging, - self._set_quantity__scan_lot, + def _set_quantity__by_location_handlers(self): + return [ + self._set_quantity__check_location, + self._set_quantity__post_move, ] - product_response = self._use_handlers(product_handlers, move_line, barcode) - if product_response: - return product_response - def _set_quantity__by_location(self, move_line, barcode, confirmation=False): - search = self._actions_for("search") - location = search.location_from_scan(barcode) - if location: - move_line.result_package_id = False - handlers = [ - # Cannot confirm if qty_done is not valid (> qty todo) - self._set_quantity__check_quantity_done, - self._set_quantity__check_location, - self._set_quantity__post_move, - ] - response = self._use_handlers( - handlers, move_line, location=location, confirmation=confirmation - ) - if response: - return response + def _set_quantity__by_location(self, move_line, location, confirmation=False): + # We're about to leave the `set_quantity` screen. + # First ensure that quantity is valid. + invalid_qty_response = self._set_quantity__check_quantity_done(move_line) + if invalid_qty_response: + return invalid_qty_response + move_line.result_package_id = False + handlers = self._set_quantity__by_location_handlers() + response = self._use_handlers( + handlers, move_line, location, confirmation=confirmation + ) + if response: + return response - def _set_quantity__by_package(self, move_line, barcode, confirmation=False): - search = self._actions_for("search") - package = search.package_from_scan(barcode) - handlers = [ - self._set_quantity__check_quantity_done, - self._set_quantity__package_not_empty, - self._set_quantity__package_empty, - ] - if package: + def _set_quantity__by_package(self, move_line, package, confirmation=False): + # We're about to leave the `set_quantity` screen. + # First ensure that quantity is valid. + invalid_qty_response = self._set_quantity__check_quantity_done(move_line) + if invalid_qty_response: + return invalid_qty_response + # If package isn't empty, then check its location then post the move + if package.quant_ids: + location = package.location_id + handlers = self._set_quantity__by_location_handlers() response = self._use_handlers( - handlers, move_line, package=package, confirmation=confirmation + handlers, move_line, location, confirmation=confirmation ) - if response: - return response + move_line.result_package_id = package + return response + # Else, go to `set_location` screen + move_line.result_package_id = package + return self._response_for_set_location(move_line, package) - def _scan_package(self, package): + def _scan_location_or_package__by_package(self, package): handlers = [ self._scan_package__check_location, + self._scan_package__check_stock, ] response = self._use_handlers(handlers, package) if response: return response - return self._response_for_select_product(package=package) + return self._response_for_select_product( + package=package, location=package.location_id + ) - def _scan_location(self, location, quants): + def _scan_location_or_package__by_location(self, location): + quants_in_location = self.env["stock.quant"].search( + [("location_id", "=", location.id), ("quantity", ">", 0)] + ) handlers = [ self._scan_location__location_found, self._scan_location__check_location, @@ -739,7 +702,7 @@ def _scan_location(self, location, quants): self._scan_location__check_stock_packages, self._scan_location__check_line_packages, ] - response = self._use_handlers(handlers, location, quants) + response = self._use_handlers(handlers, location, quants_in_location) if response: return response return self._response_for_select_product(location=location) @@ -766,14 +729,16 @@ def scan_location_or_package(self, barcode): * start: no stock found or wrong barcode """ search = self._actions_for("search") - package = search.package_from_scan(barcode) - if package: - return self._scan_package(package) - location = search.location_from_scan(barcode) - quants_in_location = self.env["stock.quant"].search( - [("location_id", "=", location.id), ("quantity", ">", 0)] - ) - return self._scan_location(location, quants_in_location) + handlers_by_type = { + "package": self._scan_location_or_package__by_package, + "location": self._scan_location_or_package__by_location, + } + search_result = search.find(barcode, types=handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(search_result.record) + message = self.msg_store.barcode_not_found() + return self._response_for_select_location_or_package(message=message) @with_savepoint def scan_product(self, barcode, location_id=None, package_id=None): @@ -796,16 +761,16 @@ def scan_product(self, barcode, location_id=None, package_id=None): package = self.env["stock.quant.package"].browse(package_id) if not location.exists() and not package.exists(): return self._response_for_select_location_or_package() - handlers = [ - self._scan_product__scan_product, - self._scan_product__scan_packaging, - self._scan_product__scan_lot, - ] - response = self._use_handlers( - handlers, barcode, location=location, package=package - ) - if response: - return response + handlers_by_type = { + "product": self._scan_product__scan_product, + "packaging": self._scan_product__scan_packaging, + "lot": self._scan_product__scan_lot, + } + search = self._actions_for("search") + search_result = search.find(barcode, types=handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(search_result.record, location=location, package=package) message = self.msg_store.barcode_not_found() return self._response_for_select_product( location=location, package=package, message=message @@ -824,19 +789,21 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): # TODO Should probably return to scan_product or scan_location? return self._response_for_set_quantity(move_line) self._set_quantity__set_picker_qty(move_line, quantity) - handlers = [ + handlers_by_type = { # Increment qty done if a product / lot / packaging is scanned - self._set_quantity__by_product, + "product": self._set_quantity__by_product, + "lot": self._set_quantity__by_lot, + "packaging": self._set_quantity__by_packaging, # Post the move if a location is scanned - self._set_quantity__by_location, + "location": self._set_quantity__by_location, # Puts the product in a new or an existing pack - self._set_quantity__by_package, - ] - response = self._use_handlers( - handlers, move_line, barcode, confirmation=confirmation - ) - if response: - return response + "package": self._set_quantity__by_package, + } + search = self._actions_for("search") + search_result = search.find(barcode, types=handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(move_line, search_result.record, confirmation=confirmation) message = self.msg_store.barcode_not_found() return self._response_for_set_quantity(move_line, message=message) @@ -851,10 +818,15 @@ def set_location(self, selected_line_id, package_id, barcode): if a package is scanned using the set_quantity endpoint. """ move_line = self.env["stock.move.line"].browse(selected_line_id) - response = self._set_quantity__by_location(move_line, barcode) - if response: - move_line.result_package_id = package_id - return response + handlers_by_type = { + # Post the move if a location is scanned + "location": self._set_quantity__by_location, + } + search = self._actions_for("search") + search_result = search.find(barcode, types=handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(move_line, search_result.record) package = self.env["stock.quant.package"].browse(package_id) message = self.msg_store.barcode_not_found() return self._response_for_set_location(move_line, package, message=message) From be859bca25176e28e5dca014adaf206533cc3377 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Tue, 13 Jun 2023 12:12:11 +0200 Subject: [PATCH 11/54] sf_single_product_transfer: cancel picking if allow_move_create enabled --- .../services/single_product_transfer.py | 8 ++++-- .../tests/test_set_quantity.py | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index a763c9751ef..8798ffba1ff 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -808,9 +808,13 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): return self._response_for_set_quantity(move_line, message=message) def set_quantity__action_cancel(self, selected_line_id): - stock = self._actions_for("stock") move_line = self.env["stock.move.line"].browse(selected_line_id).exists() - stock.unmark_move_line_as_picked(move_line) + picking = move_line.picking_id + if self.is_allow_move_create() and self.env.user == picking.create_uid: + picking.action_cancel() + else: + stock = self._actions_for("stock") + stock.unmark_move_line_as_picked(move_line) return self._response_for_select_location_or_package() def set_location(self, selected_line_id, package_id, barcode): diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 6322fbfdc39..1f140c5ba6c 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -559,6 +559,32 @@ def test_action_cancel(self): # Ensure the qty_done and user has been reset. self.assertFalse(move_line.picking_id.user_id) self.assertEqual(move_line.qty_done, 0.0) + # Ensure the picking is not cancelled if allow_move_create is not enabled + self.assertTrue(move_line.picking_id.state == "assigned") + + def test_action_cancel_allow_move_create(self): + # We perform the same actions as in test_action_cancel, + # but with the allow_move_create option enabled + self.menu.sudo().allow_move_create = True + picking = self._setup_picking() + self.service.dispatch( + "scan_product", + params={ + "location_id": self.location.id, + "barcode": self.product.barcode, + }, + ) + move_line = picking.move_line_ids + move_line.qty_done = 10.0 + response = self.service.dispatch( + "set_quantity__action_cancel", params={"selected_line_id": move_line.id} + ) + data = {} + self.assert_response( + response, next_state="select_location_or_package", data=data + ) + # Ensure the picking is cancelled if allow_move_create is enabled + self.assertTrue(move_line.picking_id.state == "cancel") def test_set_quantity_done_with_completion_info(self): self.picking_type.sudo().display_completion_info = "next_picking_ready" From 40e22662b5d3f5912c1240660bdcd4a29db80084 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Tue, 13 Jun 2023 16:47:36 +0200 Subject: [PATCH 12/54] sf_single_product_transfer: dont split move if allow_move_create enabled --- .../services/single_product_transfer.py | 10 ++- .../tests/test_set_quantity.py | 62 ++++++++++++++++++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 8798ffba1ff..15975c7b899 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -599,7 +599,12 @@ def _set_quantity__post_move(self, move_line, location, confirmation=False): # TODO qty_done = 0: transfer_no_qty_done # TODO qty done < product_qty: transfer_confirm_done self._write_destination_on_lines(move_line, location) - self._post_move(move_line) + if self.is_allow_move_create(): + self._post_move(move_line) + else: + # If allow_move_create is not enabled, + # we create a backorder. + self._split_move(move_line) message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) @@ -611,6 +616,9 @@ def _set_quantity__post_move(self, move_line, location, confirmation=False): ) def _post_move(self, move_line): + move_line.picking_id.with_context({"cancel_backorder": True})._action_done() + + def _split_move(self, move_line): # TODO: when we split the move, we still get a # backorder, which should not be the case. # See if there's a way to identify the moves diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 1f140c5ba6c..0243f793ce0 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -285,7 +285,7 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): } self.assert_response(response, next_state="set_quantity", data=data) self.assertEqual(move_line.qty_done, expected_qty) - # Nothign prevents the user to set qty_done > qty_todo + # Nothing prevents the user to set qty_done > qty_todo response = self.service.dispatch( "set_quantity", params={ @@ -617,6 +617,66 @@ def test_set_quantity_done_with_completion_info(self): popup=expected_popup, ) + def test_set_quantity_scan_location(self): + self.menu.sudo().allow_move_create = False + picking = self._setup_picking() + self._setup_chained_picking(picking) + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + # Change the destination on the move_line and take less than the total amount required. + move_line = picking.move_line_ids + self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 6, + "barcode": self.dispatch_location.name, + }, + ) + # If allow_move_create is disabled, a backorder is created. + backorder = self.env["stock.picking"].search( + [("backorder_id", "=", picking.id)] + ) + self.assertEqual( + backorder.move_line_ids.product_id, picking.move_line_ids.product_id + ) + self.assertEqual(backorder.move_line_ids.qty_done, 6.0) + self.assertEqual(backorder.move_line_ids.state, "done") + self.assertEqual(picking.move_line_ids.product_uom_qty, 4.0) + self.assertEqual(picking.move_line_ids.qty_done, 0.0) + self.assertEqual(picking.move_line_ids.state, "assigned") + + def test_set_quantity_scan_location_allow_move_create(self): + self.menu.sudo().allow_move_create = True + picking = self._setup_picking() + self._setup_chained_picking(picking) + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + # Change the destination on the move_line and take less than the total amount required. + move_line = picking.move_line_ids + self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 6, + "barcode": self.dispatch_location.name, + }, + ) + # If allow_move_create is enabled, a backorder is not created + # and the picking is marked as done with the scanned qty. + backorder = self.env["stock.picking"].search( + [("backorder_id", "=", picking.id)] + ) + self.assertFalse(backorder) + self.assertEqual(picking.move_line_ids.qty_done, 6.0) + self.assertEqual(picking.move_line_ids.state, "done") + def test_set_quantity_scan_package_not_empty(self): # We scan a package that's not empty # and its location is selected. From 5c8fae1cbf753c552e0cc1a942402c4aca2cc5e8 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Thu, 15 Jun 2023 10:51:40 +0200 Subject: [PATCH 13/54] sf_single_product_transfer: fix message bug in set_quantity__scan_product --- shopfloor_single_product_transfer/__manifest__.py | 2 +- .../services/single_product_transfer.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 0916deb96c9..fd06f98a62c 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.1.1.0", + "version": "14.0.2.0.0", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, Odoo Community Association (OCA)", diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 15975c7b899..2063fa93605 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -454,9 +454,9 @@ def _set_quantity__check_product_in_line( if lot: wrong_lot = move_line.lot_id != lot if wrong_lot: - message = self.msg_store.wrong_record(lot._name) + message = self.msg_store.wrong_record(lot) if move_line.product_id != product: - message = self.msg_store.wrong_record(product._name) + message = self.msg_store.wrong_record(product) if message: return self._response_for_set_quantity(move_line, message=message) From 60fdab9bba5535a23b744100978eba95187956bc Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 26 Jun 2023 14:08:44 +0200 Subject: [PATCH 14/54] sf_single_product_transfer: show message if line already done --- .../services/single_product_transfer.py | 5 +++ .../tests/test_set_quantity.py | 36 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 2063fa93605..919372b221e 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -796,6 +796,11 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=False): if not move_line.exists(): # TODO Should probably return to scan_product or scan_location? return self._response_for_set_quantity(move_line) + + self._lock_lines(move_line) + if move_line.state == "done": + message = self.msg_store.move_already_done() + return self._response_for_set_quantity(move_line, message=message) self._set_quantity__set_picker_qty(move_line, quantity) handlers_by_type = { # Increment qty done if a product / lot / packaging is scanned diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 0243f793ce0..775e40d4b69 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -63,6 +63,42 @@ def test_set_quantity_barcode_not_found(self): response, next_state="set_quantity", message=expected_message, data=data ) + def test_set_quantity_line_done(self): + picking = self._setup_picking() + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": self.product.barcode}, + ) + # We process the line correctly, which will mark the line as "done". + self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.dispatch_location.name, + }, + ) + self.assertEqual(move_line.state, "done") + # If we try to do it again, we're not allowed + # and we're notified that the move is alread done. + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.product_uom_qty, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": False, + } + expected_message = self.msg_store.move_already_done() + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + def test_set_quantity_scan_product_prefill_qty_disabled(self): # First, select a picking picking = self._setup_picking() From ff30f8ae79eda383ffd9b30ddc9e309bb07bea2b Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Fri, 30 Jun 2023 08:18:22 +0200 Subject: [PATCH 15/54] sf_single_product_transfer: update query to select line --- .../__manifest__.py | 2 +- .../services/single_product_transfer.py | 35 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index fd06f98a62c..e99a2725d5b 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.2.0.0", + "version": "14.0.2.0.1", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, Odoo Community Association (OCA)", diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 919372b221e..b736bbe9bf4 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -217,18 +217,7 @@ def _scan_product__select_move_line_domain( def _scan_product__select_move_line( self, product, location=None, package=None, lot=None, packaging=None ): - domain = self._scan_product__select_move_line_domain( - product, location=location, package=package, lot=lot - ) - query = self.env["stock.move.line"]._search(domain, limit=1) - order_elems = [ - "stock_move_line__picking_id.user_id", - "stock_move_line__picking_id.priority DESC", - "stock_move_line__picking_id.scheduled_date ASC", - "id DESC", - ] - query.order = ",".join(order_elems) - move_line = self.env["stock.move.line"].browse(query) + move_line = self._select_move_line_from_product(product, location, package, lot) if move_line: stock = self._actions_for("stock") if self.work.menu.no_prefill_qty: @@ -244,6 +233,28 @@ def _scan_product__select_move_line( stock.mark_move_line_as_picked(move_line) return self._response_for_set_quantity(move_line) + def _select_move_line_from_product(self, product, location, package, lot): + domain = self._scan_product__select_move_line_domain( + product, location=location, package=package, lot=lot + ) + # We add a default order by "id" to avoid the _search method + # setting up its own order, which will result in an error. + query = self.env["stock.move.line"]._search(domain, order="id", limit=1) + # After we retrieve the query, we update the order ourselves. + order_elems = [ + "stock_move_line__picking_id.user_id", + "stock_move_line__picking_id.priority DESC", + "stock_move_line__picking_id.scheduled_date ASC", + "id DESC", + ] + query.order = ",".join(order_elems) + query_str, query_params = query.select() + query_str += " FOR UPDATE" + self.env.cr.execute(query_str, query_params) + ml_ids = [row[0] for row in self.env.cr.fetchall()] + move_line = self.env["stock.move.line"].browse(ml_ids) + return move_line + def _scan_product__check_create_move_line( self, product, location=None, package=None, lot=None, packaging=None ): From 88ade866a257a414f8006449f54a62cc8792e8a2 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Tue, 16 May 2023 15:02:59 +0200 Subject: [PATCH 16/54] sf-single-product-transfer: location progress --- .../services/single_product_transfer.py | 4 +++- shopfloor_single_product_transfer/tests/common.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index b736bbe9bf4..507390136a2 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -66,7 +66,9 @@ def _response_for_select_product( ): data = {} if location: - data["location"] = self.data.location(location) + data["location"] = self.data.location( + location, with_operation_progress=True + ) if package: data["package"] = self.data.package(package) return self._response( diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index d62a5608038..4780b5def10 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -113,7 +113,7 @@ def _enable_no_prefill_qty(cls): # Data methods def _data_for_location(self, location): - return self.data.location(location) + return self.data.location(location, with_operation_progress=True) def _data_for_move_line(self, move_line): return self.data.move_line(move_line) From ffb3e853643bcf6752adceb8f7737e2ae0071f09 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 7 Jun 2023 11:07:32 +0200 Subject: [PATCH 17/54] sf-single-product-transfer: package progress --- .../services/single_product_transfer.py | 2 +- shopfloor_single_product_transfer/tests/common.py | 6 +++++- .../tests/test_scan_location_or_package.py | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 507390136a2..53a43f5b3c6 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -70,7 +70,7 @@ def _response_for_select_product( location, with_operation_progress=True ) if package: - data["package"] = self.data.package(package) + data["package"] = self.data.package(package, with_operation_progress=True) return self._response( next_state="select_product", data=data, message=message, popup=popup ) diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index 4780b5def10..8f6cfc7c52b 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -118,7 +118,11 @@ def _data_for_location(self, location): def _data_for_move_line(self, move_line): return self.data.move_line(move_line) - def _data_for_package(self, package): + def _data_for_package(self, package, with_operation_progress=False): + if with_operation_progress: + return self.data.package( + package, with_operation_progress=with_operation_progress + ) return self.data.package(package) @classmethod diff --git a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py index 1cea54e534a..e94b79cc4e6 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -115,7 +115,9 @@ def test_scan_location_only_lines_with_package(self): response, next_state="select_product", data={ - "package": self._data_for_package(package), "location": self._data_for_location(package.location_id), + "package": self._data_for_package( + package, with_operation_progress=True + ), }, ) From 2ac0d9b82397f3806f1194a871b6207a21a31f2e Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Mon, 10 Jul 2023 14:45:04 +0200 Subject: [PATCH 18/54] sf_single_product_transfer: blacklist lines for progressbar --- .../services/single_product_transfer.py | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 53a43f5b3c6..88c218ac375 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -19,6 +19,16 @@ def with_savepoint(method): @wraps(method) def wrapper(self, *args, **kwargs): savepoint = self._actions_for("savepoint").new() + # TODO: This wrapper depends on the result of the response + # in order to determine whether it should rollback the changes or not. + # As the content of the response is generated before rolling back, + # there will be cases where the response returned to the frontend + # is not in line with the backend. + # For now, we are manually modifying the response object before returning + # errors that will roll back the transaction + # (see "progress_lines_blacklist" mechanism). + # However, we should find a better solution for this issue to + # make sure the information returned to the frontend is always true. response = method(self, *args, **kwargs) message_type = response.get("message", {}).get("message_type") if message_type in ("error", "warning"): @@ -62,15 +72,26 @@ def _response_for_select_location_or_package(self, message=None): return self._response(next_state="select_location_or_package", message=message) def _response_for_select_product( - self, location=None, package=None, message=None, popup=None + self, + location=None, + package=None, + message=None, + popup=None, + progress_lines_blacklist=None, ): data = {} if location: data["location"] = self.data.location( - location, with_operation_progress=True + location, + with_operation_progress=True, + progress_lines_blacklist=progress_lines_blacklist, ) if package: - data["package"] = self.data.package(package, with_operation_progress=True) + data["package"] = self.data.package( + package, + with_operation_progress=True, + progress_lines_blacklist=progress_lines_blacklist, + ) return self._response( next_state="select_product", data=data, message=message, popup=popup ) @@ -339,6 +360,11 @@ def _scan_product__check_putaway(self, move_line): location=move_line.location_id, package=move_line.package_id, message=message, + # We blacklist the line that has been created + # because the transaction will only be rolled back + # after the response is generated, + # and we do not want this line in the response. + progress_lines_blacklist=move_line, ) def _scan_product__scan_lot(self, lot, location=None, package=None): From 115276a4a498be0de1911baa7c40e1d229e0faa2 Mon Sep 17 00:00:00 2001 From: JuMiSanAr Date: Wed, 19 Jul 2023 09:34:05 +0200 Subject: [PATCH 19/54] sf_single_product_transfer: allow package progress src or dest --- shopfloor_single_product_transfer/__manifest__.py | 2 +- .../services/single_product_transfer.py | 2 +- shopfloor_single_product_transfer/tests/common.py | 6 +++--- .../tests/test_scan_location_or_package.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index e99a2725d5b..dfd5df2e5a0 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.2.0.1", + "version": "14.0.2.1.0", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, Odoo Community Association (OCA)", diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 88c218ac375..016a722fdcc 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -89,7 +89,7 @@ def _response_for_select_product( if package: data["package"] = self.data.package( package, - with_operation_progress=True, + with_operation_progress_src=True, progress_lines_blacklist=progress_lines_blacklist, ) return self._response( diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index 8f6cfc7c52b..66010584f93 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -118,10 +118,10 @@ def _data_for_location(self, location): def _data_for_move_line(self, move_line): return self.data.move_line(move_line) - def _data_for_package(self, package, with_operation_progress=False): - if with_operation_progress: + def _data_for_package(self, package, with_operation_progress_src=False): + if with_operation_progress_src: return self.data.package( - package, with_operation_progress=with_operation_progress + package, with_operation_progress_src=with_operation_progress_src ) return self.data.package(package) diff --git a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py index e94b79cc4e6..1163ca837bb 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -117,7 +117,7 @@ def test_scan_location_only_lines_with_package(self): data={ "location": self._data_for_location(package.location_id), "package": self._data_for_package( - package, with_operation_progress=True + package, with_operation_progress_src=True ), }, ) From bf6f55c8649a545ea72017040efe0223981cbd86 Mon Sep 17 00:00:00 2001 From: Simone Orsi Date: Tue, 1 Aug 2023 12:37:26 +0200 Subject: [PATCH 20/54] shopfloor_*: fix install/uninstall Due to the way sf.app endpoints are registered if at install/uninstall you don't take care of refreshing routes you'll end up w/ non working services or stale entries in the route table. --- shopfloor_single_product_transfer/README.rst | 15 ++++--- shopfloor_single_product_transfer/__init__.py | 2 +- .../__manifest__.py | 3 +- shopfloor_single_product_transfer/hooks.py | 12 +++++- .../static/description/index.html | 40 ++++++++++--------- 5 files changed, 44 insertions(+), 28 deletions(-) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index f43e568886f..5c00cd2e740 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -2,10 +2,13 @@ Shopfloor Single Product Transfer ================================= -.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:57e66bdea11f3ed9e8be78dcec5d686b55ee0fd4b810ca4746bb3f44f858ede5 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status @@ -19,11 +22,11 @@ Shopfloor Single Product Transfer .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_single_product_transfer :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png - :target: https://runbot.odoo-community.org/runbot/285/14.0 - :alt: Try me on Runbot +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/wms&target_branch=14.0 + :alt: Try me on Runboat -|badge1| |badge2| |badge3| |badge4| |badge5| +|badge1| |badge2| |badge3| |badge4| |badge5| Allow to move a single product from a location to another one. @@ -66,7 +69,7 @@ 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 smashing it by providing a detailed and welcomed +If you spotted it first, help us to smash it by providing a detailed and welcomed `feedback `_. Do not contact contributors directly about support or help with technical issues. diff --git a/shopfloor_single_product_transfer/__init__.py b/shopfloor_single_product_transfer/__init__.py index 95d8980c806..ae16eb245f6 100644 --- a/shopfloor_single_product_transfer/__init__.py +++ b/shopfloor_single_product_transfer/__init__.py @@ -1,2 +1,2 @@ from . import services -from .hooks import post_init_hook +from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index dfd5df2e5a0..6143af624a8 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.2.1.0", + "version": "14.0.2.1.1", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, Odoo Community Association (OCA)", @@ -18,4 +18,5 @@ "demo/shopfloor_menu_demo.xml", ], "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", } diff --git a/shopfloor_single_product_transfer/hooks.py b/shopfloor_single_product_transfer/hooks.py index b8bde8ff0f8..74eea7dbf96 100644 --- a/shopfloor_single_product_transfer/hooks.py +++ b/shopfloor_single_product_transfer/hooks.py @@ -5,10 +5,20 @@ from odoo import SUPERUSER_ID, api +from odoo.addons.shopfloor_base.utils import purge_endpoints, register_new_services + +from .services.single_product_transfer import ShopfloorSingleProductTransfer as Service + _logger = logging.getLogger(__file__) def post_init_hook(cr, registry): env = api.Environment(cr, SUPERUSER_ID, {}) - env["shopfloor.app"].search([])._handle_registry_sync() + _logger.info("Register routes for %s", Service._usage) + register_new_services(env, Service) + + +def uninstall_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) _logger.info("Refreshing routes for existing apps") + purge_endpoints(env, Service._usage) diff --git a/shopfloor_single_product_transfer/static/description/index.html b/shopfloor_single_product_transfer/static/description/index.html index df719c4b489..836ef8303f9 100644 --- a/shopfloor_single_product_transfer/static/description/index.html +++ b/shopfloor_single_product_transfer/static/description/index.html @@ -1,20 +1,20 @@ - + - + Shopfloor Single Product Transfer -
-

Shopfloor Single Product Transfer

+
+ + +Odoo Community Association + +
+

Shopfloor Single Product Transfer

-

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

Allow to move a single product from a location to another one.

Table of contents

@@ -385,7 +390,7 @@

Shopfloor Single Product Transfer

-

Usage

+

Usage

Source location selection
Select a source location. @@ -416,7 +421,7 @@

Usage

-

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 @@ -424,22 +429,22 @@

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 @@ -454,5 +459,6 @@

Maintainers

+
diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 23e21cdff27..1353e224138 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -8,7 +8,7 @@ class TestSetQuantity(CommonCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.location = cls.location_src + cls.location = cls.env.ref("stock.stock_location_components") cls.product = cls.product_a cls.packaging = cls.product_a_packaging cls.packaging.qty = 5 @@ -429,6 +429,10 @@ def test_set_quantity_scan_packaging_with_allow_move_create(self): self.assert_response( response, next_state="select_product", message=expected_message, data=data ) + self.assertEqual(move_line.location_dest_id, self.dispatch_location) + self.assertEqual(move_line.location_id, location) + self.assertEqual(move_line.move_id.location_dest_id, self.dispatch_location) + self.assertEqual(move_line.move_id.location_id, location) def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( self, @@ -725,6 +729,15 @@ def test_set_quantity_scan_location(self): self.assertFalse(picking.move_line_ids.result_package_id) self.assertEqual(picking.user_id.id, False) self.assertEqual(picking.move_line_ids.shopfloor_user_id.id, False) + self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) + self.assertEqual(picking.move_line_ids.location_id, self.location) + self.assertEqual( + picking.move_line_ids.move_id.location_dest_id, self.dispatch_location + ) + self.assertEqual( + picking.move_line_ids.move_id.location_id, + self.picking_type.default_location_src_id, + ) def test_set_quantity_scan_location_allow_move_create(self): self.menu.sudo().allow_move_create = True @@ -754,6 +767,15 @@ def test_set_quantity_scan_location_allow_move_create(self): self.assertFalse(backorder) self.assertEqual(picking.move_line_ids.qty_done, 6.0) self.assertEqual(picking.move_line_ids.state, "done") + self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) + self.assertEqual(picking.move_line_ids.location_id, self.location) + self.assertEqual( + picking.move_line_ids.move_id.location_dest_id, self.dispatch_location + ) + self.assertEqual( + picking.move_line_ids.move_id.location_id, + self.picking_type.default_location_src_id, + ) def test_set_quantity_scan_package_not_empty(self): # We scan a package that's not empty From 2073bf3ee92f75ac7d538ee775228d29cc6fa3fb Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Fri, 25 Jul 2025 13:24:50 +0200 Subject: [PATCH 29/54] shopfloor_single_product_transfer: pre-commit auto fixes --- shopfloor_single_product_transfer/README.rst | 118 ------------------ .../data/shopfloor_scenario_data.xml | 12 +- .../demo/shopfloor_menu_demo.xml | 3 - .../demo/stock_picking_type_demo.xml | 3 - .../pyproject.toml | 3 + .../readme/CONTRIBUTORS.md | 2 + .../readme/CONTRIBUTORS.rst | 2 - .../{DESCRIPTION.rst => DESCRIPTION.md} | 0 .../readme/USAGE.md | 23 ++++ .../readme/USAGE.rst | 25 ---- .../services/single_product_transfer.py | 1 - .../static/description/index.html | 97 +------------- 12 files changed, 34 insertions(+), 255 deletions(-) create mode 100644 shopfloor_single_product_transfer/pyproject.toml create mode 100644 shopfloor_single_product_transfer/readme/CONTRIBUTORS.md delete mode 100644 shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst rename shopfloor_single_product_transfer/readme/{DESCRIPTION.rst => DESCRIPTION.md} (100%) create mode 100644 shopfloor_single_product_transfer/readme/USAGE.md delete mode 100644 shopfloor_single_product_transfer/readme/USAGE.rst diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index fd53a01e7b8..e69de29bb2d 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -1,118 +0,0 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - -================================= -Shopfloor Single Product Transfer -================================= - -.. - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! This file is generated by oca-gen-addon-readme !! - !! changes will be overwritten. !! - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:16ebf4819053f846c770e5faadb957ea18ae3d05ae166e0ee7a8d9898645635e - !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - -.. |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 - :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 - :target: https://github.com/OCA/wms/tree/14.0/shopfloor_single_product_transfer - :alt: OCA/wms -.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_single_product_transfer - :alt: Translate me on Weblate -.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/wms&target_branch=14.0 - :alt: Try me on Runboat - -|badge1| |badge2| |badge3| |badge4| |badge5| - -Allow to move a single product from a location to another one. - -**Table of contents** - -.. contents:: - :local: - -Usage -===== - -**Source location selection** - Select a source location. - It must be a valid location according to the configuration of the scenario, - and there must be stock in the selected location. - -**Move line selection** - Select a product or a lot in this location. - If an unassigned move line for this product / lot exists in the previously selected - location, then it is selected. - Otherwise, if the `Allow Move Creation` is enabled, it will try to create a move line. - If the `Allow to process reserved quantities` option is enabled, other moves - will be unreserved. - If there's unreserved goods in the location, a new move is created with quantity equal - to the unreserved goods in the location. - -**Set quantity / destination location** - 1. **Scan a product / lot to set the quantity** - If the `Do not pre-fill quantity to pick` option is enabled, it will increment the - done quantity by 1 each time the product or lot barcode is scanned. - Else, it will set the quantity done as the reserved quantity. - 2. **Scan a destination location** - The scanned location will be checked. - It must be a child of the current line destination location or a child of - the scenario default destination location. - If this is ok, then the move is processed. - -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 -`feedback `_. - -Do not contact contributors directly about support or help with technical issues. - -Credits -======= - -Authors -~~~~~~~ - -* Camptocamp - -Contributors -~~~~~~~~~~~~ - -* Matthieu Méquignon -* Michael Tietz (MT Software) - -Maintainers -~~~~~~~~~~~ - -This module is maintained by the OCA. - -.. image:: https://odoo-community.org/logo.png - :alt: Odoo Community Association - :target: https://odoo-community.org - -OCA, or the Odoo Community Association, is a nonprofit organization whose -mission is to support the collaborative development of Odoo features and -promote its widespread use. - -.. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px - :target: https://github.com/mmequignon - :alt: mmequignon - -Current `maintainer `__: - -|maintainer-mmequignon| - -This module is part of the `OCA/wms `_ project on GitHub. - -You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml index 4793d287ff8..01cfdd95856 100644 --- a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -2,11 +2,10 @@ - - - Single Product Transfer - single_product_transfer - + + Single Product Transfer + single_product_transfer + { "allow_create_moves": true, "allow_unreserve_other_moves": true, @@ -15,6 +14,5 @@ "no_prefill_qty": true } - - + diff --git a/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml b/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml index acdd2666b51..eb59f01ded9 100644 --- a/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml +++ b/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml @@ -2,7 +2,6 @@ - Single Product Transfer 45 @@ -16,6 +15,4 @@ eval="[(4, ref('shopfloor_single_product_transfer.picking_type_single_product_transfer_demo'))]" /> - - diff --git a/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml b/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml index 2669f4748bd..9ee87ded808 100644 --- a/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml +++ b/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml @@ -2,7 +2,6 @@ - Single Product Transfer SPT @@ -18,6 +17,4 @@ - - diff --git a/shopfloor_single_product_transfer/pyproject.toml b/shopfloor_single_product_transfer/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/shopfloor_single_product_transfer/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..20ac54db895 --- /dev/null +++ b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Matthieu Méquignon \<\> +- Michael Tietz (MT Software) \<\> diff --git a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst deleted file mode 100644 index 246993b54e3..00000000000 --- a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.rst +++ /dev/null @@ -1,2 +0,0 @@ -* Matthieu Méquignon -* Michael Tietz (MT Software) diff --git a/shopfloor_single_product_transfer/readme/DESCRIPTION.rst b/shopfloor_single_product_transfer/readme/DESCRIPTION.md similarity index 100% rename from shopfloor_single_product_transfer/readme/DESCRIPTION.rst rename to shopfloor_single_product_transfer/readme/DESCRIPTION.md diff --git a/shopfloor_single_product_transfer/readme/USAGE.md b/shopfloor_single_product_transfer/readme/USAGE.md new file mode 100644 index 00000000000..691d9fa7978 --- /dev/null +++ b/shopfloor_single_product_transfer/readme/USAGE.md @@ -0,0 +1,23 @@ +**Source location selection** +Select a source location. It must be a valid location according to the +configuration of the scenario, and there must be stock in the selected +location. + +**Move line selection** +Select a product or a lot in this location. If an unassigned move line +for this product / lot exists in the previously selected location, then +it is selected. Otherwise, if the Allow Move Creation is enabled, it +will try to create a move line. If the Allow to process reserved +quantities option is enabled, other moves will be unreserved. If there's +unreserved goods in the location, a new move is created with quantity +equal to the unreserved goods in the location. + +**Set quantity / destination location** +1. **Scan a product / lot to set the quantity** If the Do not pre-fill + quantity to pick option is enabled, it will increment the done + quantity by 1 each time the product or lot barcode is scanned. Else, + it will set the quantity done as the reserved quantity. +2. **Scan a destination location** The scanned location will be + checked. It must be a child of the current line destination location + or a child of the scenario default destination location. If this is + ok, then the move is processed. diff --git a/shopfloor_single_product_transfer/readme/USAGE.rst b/shopfloor_single_product_transfer/readme/USAGE.rst deleted file mode 100644 index c4e9758581e..00000000000 --- a/shopfloor_single_product_transfer/readme/USAGE.rst +++ /dev/null @@ -1,25 +0,0 @@ -**Source location selection** - Select a source location. - It must be a valid location according to the configuration of the scenario, - and there must be stock in the selected location. - -**Move line selection** - Select a product or a lot in this location. - If an unassigned move line for this product / lot exists in the previously selected - location, then it is selected. - Otherwise, if the `Allow Move Creation` is enabled, it will try to create a move line. - If the `Allow to process reserved quantities` option is enabled, other moves - will be unreserved. - If there's unreserved goods in the location, a new move is created with quantity equal - to the unreserved goods in the location. - -**Set quantity / destination location** - 1. **Scan a product / lot to set the quantity** - If the `Do not pre-fill quantity to pick` option is enabled, it will increment the - done quantity by 1 each time the product or lot barcode is scanned. - Else, it will set the quantity done as the reserved quantity. - 2. **Scan a destination location** - The scanned location will be checked. - It must be a child of the current line destination location or a child of - the scenario default destination location. - If this is ok, then the move is processed. diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index fcdb3f4159c..aa37bcbb6db 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -325,7 +325,6 @@ def _scan_product__unreserve_move_line( def _scan_product__create_move_line( self, product, location=None, package=None, lot=None, packaging=None ): - available_quantity = product.with_context( location=location.id if location else None, package_id=package.id if package else None, diff --git a/shopfloor_single_product_transfer/static/description/index.html b/shopfloor_single_product_transfer/static/description/index.html index 1388283950b..f380a061344 100644 --- a/shopfloor_single_product_transfer/static/description/index.html +++ b/shopfloor_single_product_transfer/static/description/index.html @@ -363,102 +363,7 @@
- -Odoo Community Association - -
-

Shopfloor Single Product Transfer

- -

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

-

Allow to move a single product from a location to another one.

-

Table of contents

- -
-

Usage

-
-
Source location selection
-
Select a source location. -It must be a valid location according to the configuration of the scenario, -and there must be stock in the selected location.
-
Move line selection
-
Select a product or a lot in this location. -If an unassigned move line for this product / lot exists in the previously selected -location, then it is selected. -Otherwise, if the Allow Move Creation is enabled, it will try to create a move line. -If the Allow to process reserved quantities option is enabled, other moves -will be unreserved. -If there’s unreserved goods in the location, a new move is created with quantity equal -to the unreserved goods in the location.
-
Set quantity / destination location
-
    -
  1. Scan a product / lot to set the quantity -If the Do not pre-fill quantity to pick option is enabled, it will increment the -done quantity by 1 each time the product or lot barcode is scanned. -Else, it will set the quantity done as the reserved quantity.
  2. -
  3. Scan a destination location -The scanned location will be checked. -It must be a child of the current line destination location or a child of -the scenario default destination location. -If this is ok, then the move is processed.
  4. -
-
-
-
-
-

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 -feedback.

-

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

-
-
-

Credits

-
-

Authors

-
    -
  • Camptocamp
  • -
-
-
-

Contributors

- -
-
-

Maintainers

-

This module is maintained by the OCA.

- -Odoo Community Association - -

OCA, or the Odoo Community Association, is a nonprofit organization whose -mission is to support the collaborative development of Odoo features and -promote its widespread use.

-

Current maintainer:

-

mmequignon

-

This module is part of the OCA/wms project on GitHub.

-

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

-
-
-
+
From e1253952adbbc7e88a140f60075c2d44f0b5fb77 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Wed, 30 Jul 2025 12:20:54 +0200 Subject: [PATCH 30/54] Migrate shopfloor_single_product_transfer to 18.0 --- .../__manifest__.py | 4 +- shopfloor_single_product_transfer/hooks.py | 8 +- .../migrations/14.0.1.1.0/post-migrate.py | 17 --- .../services/single_product_transfer.py | 90 ++++++------ .../tests/common.py | 23 +-- .../tests/test_scan_location_or_package.py | 12 +- .../tests/test_scan_product.py | 31 ++-- .../tests/test_set_quantity.py | 135 ++++++++++-------- .../tests/test_set_quantity_checkout_sync.py | 2 +- .../tests/test_start.py | 3 +- 10 files changed, 160 insertions(+), 165 deletions(-) delete mode 100644 shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 8a85cd31736..c3819aee7c3 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,9 +1,9 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "14.0.2.5.0", + "version": "18.0.1.0.0", "category": "Inventory", - "website": "https://github.com/OCA/wms", + "website": "https://github.com/OCA/stock-logistics-shopfloor", "author": "Camptocamp, Odoo Community Association (OCA)", "maintainers": ["mmequignon"], "license": "AGPL-3", diff --git a/shopfloor_single_product_transfer/hooks.py b/shopfloor_single_product_transfer/hooks.py index 74eea7dbf96..06aee169831 100644 --- a/shopfloor_single_product_transfer/hooks.py +++ b/shopfloor_single_product_transfer/hooks.py @@ -3,8 +3,6 @@ import logging -from odoo import SUPERUSER_ID, api - from odoo.addons.shopfloor_base.utils import purge_endpoints, register_new_services from .services.single_product_transfer import ShopfloorSingleProductTransfer as Service @@ -12,13 +10,11 @@ _logger = logging.getLogger(__file__) -def post_init_hook(cr, registry): - env = api.Environment(cr, SUPERUSER_ID, {}) +def post_init_hook(env): _logger.info("Register routes for %s", Service._usage) register_new_services(env, Service) -def uninstall_hook(cr, registry): - env = api.Environment(cr, SUPERUSER_ID, {}) +def uninstall_hook(env): _logger.info("Refreshing routes for existing apps") purge_endpoints(env, Service._usage) diff --git a/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py b/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py deleted file mode 100644 index a0149474ae3..00000000000 --- a/shopfloor_single_product_transfer/migrations/14.0.1.1.0/post-migrate.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2023 Camptocamp SA (http://www.camptocamp.com) -# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). - -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, {}) - env["shopfloor.app"].search([])._handle_registry_sync() - _logger.info("Activate sync for existing apps") diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index aa37bcbb6db..44970dcc20d 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -122,7 +122,7 @@ def _scan_location__location_found(self, location, quants): return self._response_for_select_location_or_package(message=message) def _scan_location__check_location(self, location, quants): - """Check that `location` belongs to the source location of the operation type.""" + """Check that location belongs to the source location of the operation type.""" if not self.is_src_location_valid(location): message = self.msg_store.location_content_unable_to_transfer(location) return self._response_for_select_location_or_package(message=message) @@ -245,14 +245,11 @@ def _scan_product__select_move_line( if move_line: stock = self._actions_for("stock") if self.work.menu.no_prefill_qty: - # First, mark move line as picked with qty_done = 0, - # so the move wont be split because 0 < qty_done < product_uom_qty + # First, mark move line as picked with qty_picked = 0, + # so the move wont be split because 0 < qty_picked < quantity stock.mark_move_line_as_picked(move_line, quantity=0) # Then, set the no prefill qty on the move line - qty_done = 1 - if packaging: - qty_done = packaging.qty - move_line.qty_done = qty_done + stock.move_line_increment_qty_picked(move_line, packaging=packaging) else: stock.mark_move_line_as_picked(move_line) return self._response_for_set_quantity(move_line) @@ -312,8 +309,9 @@ def _scan_product__unreserve_move_line( packaging=packaging, ) else: - # If we get there then no qty is available, and we are not allowed to unreserve - # other moves. No stock available for product. + # If we get there then no qty is available, + # and we are not allowed to unreserve other moves. + # No stock available for product. return self._scan_product__no_stock_available( product, location=location, @@ -473,7 +471,8 @@ def _create_move_from_location( move._action_confirm(merge=False) picking = move.picking_id if package: - # When we create a package_level, we force the reservation of the scanned package. + # When we create a package_level, + # we force the reservation of the scanned package. package_level = self.env["stock.package_level"].create( { "picking_id": picking.id, @@ -484,23 +483,18 @@ def _create_move_from_location( } ) move.package_level_id = package_level - move.with_context( - {"force_reservation": self.work.menu.allow_force_reservation} - )._action_assign() + ctx = {"force_reservation": self.work.menu.allow_force_reservation} + move.with_context(**ctx)._action_assign() assert move.state == "assigned", "The reservation of quantities has failed" # we expect to get only one move line as we are # moving only bulk products w/o lot or package. move_line = move.move_line_ids[0] stock = self._actions_for("stock") if self.work.menu.no_prefill_qty: - # We ensure the qty_done is 0 here, so we can set it manually after + # We ensure the qty_picked is 0 here, so we can set it manually after # to avoid the split of the move line by 'mark_move_line_as_picked'. stock.mark_move_line_as_picked(move_line, quantity=0) - # Set the initial qty_done to 1 for product and lot - qty_done = 1 - if packaging: - qty_done = packaging.qty - move_line.qty_done = qty_done + stock.move_line_increment_qty_picked(move_line, packaging=packaging) else: stock.mark_move_line_as_picked(move_line) return move @@ -521,43 +515,37 @@ def _set_quantity__check_product_in_line( def _set_quantity__check_quantity_done( self, move_line, location=None, package=None, confirmation=None ): - rounding = move_line.product_id.uom_id.rounding - qty_done = move_line.qty_done - qty_todo = move_line.product_uom_qty - # If qty done is >= qty todo, then there's nothing more to pick - if float_compare(qty_done, qty_todo, precision_rounding=rounding) > 0: - message = self.msg_store.unable_to_pick_more(qty_todo) + stock = self._actions_for("stock") + if not stock.move_line_check_qty_picked(move_line): + message = self.msg_store.unable_to_pick_more(move_line.quantity) return self._response_for_set_quantity(move_line, message=message) def _set_quantity__check_no_prefill_qty( self, move_line, product, lot=None, packaging=None ): if not self.work.menu.no_prefill_qty: - # If no_prefill_qty is False, then qty_done should have been prefilled + # If no_prefill_qty is False, then qty_picked should have been prefilled # with product_uom_qty in the select_product screen message = self.msg_store.unable_to_pick_more(move_line.product_uom_qty) return self._response_for_set_quantity(move_line, message=message) - def _set_quantity__increment_qty_done( + def _set_quantity__increment_qty_picked( self, move_line, product, lot=None, packaging=None ): """Increment the quantity done depending on the item scanned.""" + + # TODO: Implement an action move_line_increment + # When we reach this handler, the 'no_prefill_qty' is enabled # For product or lot, we increment by 1 by default - qty_done = 1 - if packaging: - qty_done = packaging.qty - move_line.qty_done += qty_done + stock = self._actions_for("stock") + stock.move_line_increment_qty_picked(move_line, packaging=packaging) return self._response_for_set_quantity(move_line) - def _set_quantity__set_picker_qty(self, move_line, quantity): - """Sets move_line qty_done according to picker quantity.""" - move_line.qty_done = quantity - def _set_quantity__scan_product_handlers(self): return ( self._set_quantity__check_product_in_line, - self._set_quantity__increment_qty_done, + self._set_quantity__increment_qty_picked, ) def _set_quantity__by_product(self, move_line, product, confirmation=False): @@ -630,11 +618,15 @@ def _set_quantity__check_location( move_line, message=message, asking_confirmation=confirmation or None ) - def _write_destination_on_lines(self, lines, location): - stock = self._actions_for("stock") - stock.set_destination_and_unload_lines(lines, location) + def _write_destination_on_lines(self, lines, location, unload=False): + lines.picking_id.location_dest_id = location + if unload: + lines.result_package_id = False + # FIXME commit isn't migrated yet + # stock.set_destination_and_unload_lines(lines, location) def _set_quantity__post_move(self, move_line, location, confirmation=None): + # TODO still valid ? # TODO qty_done = 0: transfer_no_qty_done # TODO qty done < product_qty: transfer_confirm_done self._write_destination_on_lines(move_line, location) @@ -655,7 +647,8 @@ def _set_quantity__post_move(self, move_line, location, confirmation=None): ) def _post_move(self, move_line): - move_line.picking_id.with_context({"cancel_backorder": True})._action_done() + ctx = {"cancel_backorder": True} + move_line.picking_id.with_context(**ctx)._action_done() def _split_move(self, move_line): # TODO: when we split the move, we still get a @@ -663,24 +656,23 @@ def _split_move(self, move_line): # See if there's a way to identify the moves # generated through this mechanism and avoid creating them. new_move_line = move_line._split_partial_quantity() - new_move = move_line.move_id.split_other_move_lines( - move_line, intersection=True - ) - if new_move: + move = move_line.move_id + if new_move_line: # A new move is created in case of partial quantity + new_move = move.split_other_move_lines(move_line, intersection=True) new_move.extract_and_action_done() stock = self._actions_for("stock") stock.unmark_move_line_as_picked(new_move_line) return # In case of full quantity, post the initial move - move_line.move_id.extract_and_action_done() + move.extract_and_action_done() def _find_user_move_line_domain(self, user): return [ ("picking_id.user_id", "in", (False, self.env.uid)), ("picking_id.picking_type_id", "in", self.picking_types.ids), ("state", "in", ("assigned", "partially_available")), - ("qty_done", ">", 0), + ("qty_picked", ">", 0), ] def _find_user_move_line(self): @@ -860,11 +852,13 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=None): # TODO Should probably return to scan_product or scan_location? return self._response_for_set_quantity(move_line) - self._actions_for("stock")._lock_lines(move_line) + self._actions_for("lock").for_update(move_line) + # FIXME commit isn't migrated yet + # stock._lock_lines(move_line) if move_line.state == "done": message = self.msg_store.move_already_done() return self._response_for_set_quantity(move_line, message=message) - self._set_quantity__set_picker_qty(move_line, quantity) + move_line.qty_picked = quantity handlers_by_type = { # Increment qty done if a product / lot / packaging is scanned "product": self._set_quantity__by_product, diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index 543e3b94b50..a319928e236 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -5,6 +5,7 @@ from odoo.addons.shopfloor.tests.common import CommonCase as BaseCommonCase +# pylint: disable=missing-return class CommonCase(BaseCommonCase): def setUp(self): super().setUp() @@ -70,19 +71,23 @@ def cache_existing_record_ids(cls): @classmethod def _add_stock_to_product(cls, product, location, qty, lot=None): """Set the stock quantity of the product.""" - values = { - "product_id": product.id, - "location_id": location.id, - "inventory_quantity": qty, - } - if lot: - values["lot_id"] = lot.id - cls.env["stock.quant"].sudo().with_context(inventory_mode=True).create(values) + cls._update_qty_in_location(location, product, qty, lot=lot) + # FIXME: can we drop this? + # values = { + # "product_id": product.id, + # "location_id": location.id, + # "inventory_quantity": qty, + # } + # if lot: + # values["lot_id"] = lot.id + # import pdb; pdb.set_trace() + # quant_model = cls.env["stock.quant"].sudo() + # quant = quant_model.with_context(inventory_mode=True).create(values) cls.cache_existing_record_ids() @classmethod def _create_lot_for_product(cls, product, name): - return cls.env["stock.production.lot"].create( + return cls.env["stock.lot"].create( { "product_id": product.id, "name": name, diff --git a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py index 28a8fc9dcc3..5af0364d4ff 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -27,7 +27,10 @@ def test_scan_wrong_location(self): ) expected_message = { "message_type": "error", - "body": f"The content of {location.name} cannot be transferred with this scenario.", + "body": ( + f"The content of {location.name} cannot be " + "transferred with this scenario." + ), } self.assert_response( response, @@ -88,9 +91,10 @@ def test_scan_location_stock_packages(self): def test_scan_location_only_lines_with_package(self): location = self.location_src package = self._create_empty_package() - for line in location.source_move_line_ids: - # There are no lines without a package in this location. - line.package_id = package + location.source_move_line_ids.package_id = package + + # TODO No compute anymore, or doesn't work + package.location_id = location # Scan a location, user is asked to scan a package. response = self.service.dispatch( diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index 8436402ef25..55d7753c7c2 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -65,7 +65,9 @@ def test_scan_product_multiple_lines_in_picking_no_prefill_qty_enabled(self): # Without argument, a multi line picking is created # with product_a and product_b picking = self._create_picking() - move_line = picking.move_line_ids.filtered(lambda l: l.product_id == product) + move_line = picking.move_line_ids.filtered( + lambda line: line.product_id == product + ) self.service.dispatch( "scan_product", params={"location_id": location.id, "barcode": product.barcode}, @@ -74,8 +76,8 @@ def test_scan_product_multiple_lines_in_picking_no_prefill_qty_enabled(self): new_picking = self.get_new_picking() self.assertTrue(new_picking) self.assertEqual(move_line.picking_id, new_picking) - self.assertEqual(move_line.qty_done, 1) - self.assertEqual(move_line.product_uom_qty, 10.0) + self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.quantity, 10.0) def test_scan_product_no_move_line(self): # No move with product in location, create move line is disabled. @@ -158,7 +160,7 @@ def test_scan_product_with_stock_create_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.product_qty, 10.0) + self.assertEqual(move_line.quantity, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -234,7 +236,7 @@ def test_scan_product_with_reserved_stock_unreserve_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.product_qty, 10.0) + self.assertEqual(move_line.quantity, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -371,7 +373,7 @@ def test_scan_lot_with_reserved_stock_unreserve_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.product_qty, 10.0) + self.assertEqual(move_line.quantity, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -459,8 +461,9 @@ def test_create_move_line_by_product_no_prefill_qty_disabled(self): params={"location_id": location.id, "barcode": product.barcode}, ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_done, max_qty_done) - self.assertEqual(move_line.product_uom_qty, max_qty_done) + self.assertTrue(move_line.picked) + self.assertEqual(move_line.quantity, max_qty_done) + self.assertEqual(move_line.qty_picked, max_qty_done) def test_create_move_line_by_product_no_prefill_qty_enabled(self): location = self.location_src @@ -474,8 +477,8 @@ def test_create_move_line_by_product_no_prefill_qty_enabled(self): params={"location_id": location.id, "barcode": product.barcode}, ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_done, 1) - self.assertEqual(move_line.product_uom_qty, max_qty_done) + self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.quantity, max_qty_done) def test_create_move_line_by_lot_no_prefill_qty_disabled(self): location = self.location_src @@ -489,8 +492,8 @@ def test_create_move_line_by_lot_no_prefill_qty_disabled(self): "scan_product", params={"location_id": location.id, "barcode": lot.name} ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_done, max_qty_done) - self.assertEqual(move_line.product_uom_qty, max_qty_done) + self.assertEqual(move_line.qty_picked, max_qty_done) + self.assertEqual(move_line.quantity, max_qty_done) def test_create_move_line_by_lot_no_prefill_qty_enabled(self): location = self.location_src @@ -506,8 +509,8 @@ def test_create_move_line_by_lot_no_prefill_qty_enabled(self): params={"location_id": location.id, "barcode": lot.name}, ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_done, 1) - self.assertEqual(move_line.product_uom_qty, max_qty_done) + self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.quantity, max_qty_done) def test_action_cancel(self): response = self.service.dispatch("scan_product__action_cancel") diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 1353e224138..23bdb8c848f 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -22,8 +22,8 @@ def _setup_picking(cls, lot=None): @classmethod def _setup_chained_picking(cls, picking): - next_moves = picking.move_lines.browse() - for move in picking.move_lines: + next_moves = picking.move_ids.browse() + for move in picking.move_ids: next_moves |= move.copy( { "move_orig_ids": [(6, 0, move.ids)], @@ -50,7 +50,7 @@ def test_set_quantity_barcode_not_found(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.quantity, "barcode": "NOPE", }, ) @@ -75,7 +75,7 @@ def test_set_quantity_line_done(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.product_uom_qty, + "quantity": move_line.quantity, "barcode": self.dispatch_location.name, }, ) @@ -86,7 +86,7 @@ def test_set_quantity_line_done(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.product_uom_qty, + "quantity": move_line.quantity, "barcode": self.product.barcode, }, ) @@ -109,13 +109,16 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): ) # Without no_prefill_qty, once selected, a moveline qty done is already # equal to the qty todo. - self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + self.assertTrue(move_line.picked) + self.assertEqual(move_line.quantity, 10) + self.assertEqual(move_line.qty_picked, 10) # We do not prevent the user to set a bigger qty + # No qty check when scanning a product response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.product.barcode, }, ) @@ -124,12 +127,13 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - # However, we prevent the user to post the line if qty_done > qty_todo + # However, we prevent the user to post the line if qty_picked > quantity + # quantity is checked when scanning a location response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.location.barcode, }, ) @@ -139,7 +143,7 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): } expected_message = { "message_type": "error", - "body": f"You must not pick more than {move_line.product_uom_qty} units.", + "body": f"You must not pick more than {move_line.quantity} units.", } self.assert_response( response, next_state="set_quantity", message=expected_message, data=data @@ -154,7 +158,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "scan_product", params={"location_id": self.location.id, "barcode": self.product.barcode}, ) - self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.qty_picked, 1) # We can scan the same product 9 times, and the qty will increment by 1 # each time. for expected_qty in range(2, 11): @@ -162,7 +166,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.product.barcode, }, ) @@ -171,13 +175,13 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_done, expected_qty) - # We do not prevent the user to set a qty_done > qty_todo in the picker + self.assertEqual(move_line.qty_picked, expected_qty) + # We do not prevent the user to set a qty_picked > quantity in the picker response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.product.barcode, }, ) @@ -186,12 +190,12 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - # However, we prevent the user to post the line if qty_done > qty_todo + # However, we prevent the user to post the line if qty_picked > quantity response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.location.barcode, }, ) @@ -201,7 +205,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): } expected_message = { "message_type": "error", - "body": f"You must not pick more than {move_line.product_uom_qty} units.", + "body": f"You must not pick more than {move_line.quantity} units.", } self.assert_response( response, next_state="set_quantity", message=expected_message, data=data @@ -215,7 +219,7 @@ def test_set_picker_quantity(self): "scan_product", params={"location_id": self.location.id, "barcode": self.product.barcode}, ) - self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.qty_picked, 1) response = self.service.dispatch( "set_quantity", params={ @@ -225,13 +229,13 @@ def test_set_picker_quantity(self): }, ) # Here, user manually set 5.0 as qty done and scanned a product, - # expected qty_done on move line is 6.0 + # expected qty_picked on move line is 6.0 data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_done, 6.0) + self.assertEqual(move_line.qty_picked, 6.0) response = self.service.dispatch( "set_quantity", params={ @@ -241,14 +245,14 @@ def test_set_picker_quantity(self): }, ) # Here user sets 10.0 then scans a product. - # Expected qty_done is 11.0 + # Expected qty_picked is 11.0 data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_done, 11.0) - # When scanning a location, a qty_done is checked. + self.assertEqual(move_line.qty_picked, 11.0) + # When scanning a location, a qty_picked is checked. # Since qty done > qty todo, an error should be raised def test_set_quantity_scan_lot_prefill_qty_disabled(self): @@ -260,12 +264,12 @@ def test_set_quantity_scan_lot_prefill_qty_disabled(self): "scan_product", params={"location_id": self.location.id, "barcode": lot.name}, ) - self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + self.assertEqual(move_line.qty_picked, move_line.quantity) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": lot.name, }, ) @@ -275,12 +279,12 @@ def test_set_quantity_scan_lot_prefill_qty_disabled(self): } self.assert_response(response, next_state="set_quantity", data=data) # However, we shouldn't be able to confirm (scan a location) - # since qty_done > qty_todo (max is 10.0) + # since qty_picked > quantity (max is 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.location.barcode, }, ) @@ -303,15 +307,15 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): params={"location_id": self.location.id, "barcode": lot.name}, ) move_line = picking.move_line_ids - self.assertEqual(move_line.qty_done, 1) - # We can scan the same lot 9 times (until qty_done == product_uom_qty), + self.assertEqual(move_line.qty_picked, 1) + # We can scan the same lot 9 times (until qty_picked == quantity), # and the qty will increment by 1 each time. for expected_qty in range(2, 11): response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": lot.name, }, ) @@ -320,13 +324,13 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_done, expected_qty) - # Nothing prevents the user to set qty_done > qty_todo + self.assertEqual(move_line.qty_picked, expected_qty) + # Nothing prevents the user to set qty_picked > quantity response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": lot.name, }, ) @@ -336,12 +340,12 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): } self.assert_response(response, next_state="set_quantity", data=data) # However, we shouldn't be able to confirm (scan a location) - # since qty_done > qty_todo (max is 10.0) + # since qty_picked > quantity (max is 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.location.barcode, }, ) @@ -363,12 +367,13 @@ def test_set_quantity_scan_packaging(self): "scan_product", params={"location_id": self.location.id, "barcode": self.packaging.barcode}, ) - self.assertEqual(move_line.qty_done, move_line.product_uom_qty) + self.assertEqual(move_line.qty_picked, move_line.quantity) + # picker qty is 10 + scanned packaging qty is 5 = 15 response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.quantity, "barcode": self.packaging.barcode, }, ) @@ -377,14 +382,14 @@ def test_set_quantity_scan_packaging(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_done, 15.0) + self.assertEqual(move_line.qty_picked, 15.0) # However, we shouldn't be able to confirm (scan a location) - # since qty_done > qty_todo (max is 10.0) + # since quantity_picked > quantity (max is 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.location.barcode, }, ) @@ -413,12 +418,13 @@ def test_set_quantity_scan_packaging_with_allow_move_create(self): self.product, location ) move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertEqual(move_line.qty_done, 10.0) + self.assertTrue(move_line.quantity) + self.assertEqual(move_line.quantity, 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.barcode, }, ) @@ -453,16 +459,16 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( self.product, location ) move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertEqual(move_line.qty_done, 5.0) + self.assertEqual(move_line.qty_picked, 5.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.packaging.barcode, }, ) - self.assertEqual(move_line.qty_done, 10.0) + self.assertEqual(move_line.qty_picked, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -472,7 +478,7 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.barcode, }, ) @@ -496,7 +502,7 @@ def test_set_quantity_invalid_dest_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": wrong_location.barcode, }, ) @@ -523,7 +529,7 @@ def test_set_quantity_menu_default_location(self): # Scanning a child of the menu, shopfloor should ask for a confirmation params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.barcode, } response = self.service.dispatch("set_quantity", params=params) @@ -560,7 +566,7 @@ def test_set_quantity_confirm_with_different_barcode(self): move_line.location_dest_id = self.env.ref("stock.stock_location_14") params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.barcode, } # Setting the confirmation to another location barcode @@ -601,7 +607,7 @@ def test_set_quantity_child_move_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.name, }, ) @@ -622,7 +628,7 @@ def test_action_cancel(self): }, ) move_line = picking.move_line_ids - move_line.qty_done = 10.0 + move_line._pick_qty(10.0) # Result here already tested in # `test_scan_product::TestScanProduct::test_scan_product_with_move_line` response = self.service.dispatch( @@ -632,9 +638,9 @@ def test_action_cancel(self): self.assert_response( response, next_state="select_location_or_package", data=data ) - # Ensure the qty_done and user has been reset. + # Ensure qty_picked and user has been reset. self.assertFalse(move_line.picking_id.user_id) - self.assertEqual(move_line.qty_done, 0.0) + self.assertEqual(move_line.qty_picked, 0.0) # Ensure the picking is not cancelled if allow_move_create is not enabled self.assertTrue(move_line.picking_id.state == "assigned") @@ -651,7 +657,7 @@ def test_action_cancel_allow_move_create(self): }, ) move_line = picking.move_line_ids - move_line.qty_done = 10.0 + move_line._pick_qty(10.0) response = self.service.dispatch( "set_quantity__action_cancel", params={"selected_line_id": move_line.id} ) @@ -660,7 +666,8 @@ def test_action_cancel_allow_move_create(self): response, next_state="select_location_or_package", data=data ) # Ensure the picking is cancelled if allow_move_create is enabled - self.assertTrue(move_line.picking_id.state == "cancel") + self.assertFalse(move_line.exists()) + self.assertTrue(picking.state == "cancel") def test_set_quantity_done_with_completion_info(self): self.picking_type.sudo().display_completion_info = "next_picking_ready" @@ -677,7 +684,7 @@ def test_set_quantity_done_with_completion_info(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.qty_picked, "barcode": self.dispatch_location.name, }, ) @@ -702,7 +709,8 @@ def test_set_quantity_scan_location(self): "scan_product", params={"location_id": location.id, "barcode": self.product.barcode}, ) - # Change the destination on the move_line and take less than the total amount required. + # Change the destination on the move_line and take less than the total + # amount required. move_line = picking.move_line_ids self.service.dispatch( "set_quantity", @@ -719,12 +727,12 @@ def test_set_quantity_scan_location(self): self.assertEqual( backorder.move_line_ids.product_id, picking.move_line_ids.product_id ) - self.assertEqual(backorder.move_line_ids.qty_done, 6.0) + self.assertEqual(backorder.move_line_ids.qty_picked, 6.0) self.assertEqual(backorder.move_line_ids.state, "done") self.assertEqual(backorder.user_id, self.env.user) self.assertEqual(backorder.move_line_ids.shopfloor_user_id, self.env.user) - self.assertEqual(picking.move_line_ids.product_uom_qty, 4.0) - self.assertEqual(picking.move_line_ids.qty_done, 0.0) + self.assertEqual(picking.move_line_ids.quantity, 4.0) + self.assertEqual(picking.move_line_ids.qty_picked, 0.0) self.assertEqual(picking.move_line_ids.state, "assigned") self.assertFalse(picking.move_line_ids.result_package_id) self.assertEqual(picking.user_id.id, False) @@ -748,7 +756,8 @@ def test_set_quantity_scan_location_allow_move_create(self): "scan_product", params={"location_id": location.id, "barcode": self.product.barcode}, ) - # Change the destination on the move_line and take less than the total amount required. + # Change the destination on the move_line and take less than the total + # amount required. move_line = picking.move_line_ids self.service.dispatch( @@ -765,7 +774,7 @@ def test_set_quantity_scan_location_allow_move_create(self): [("backorder_id", "=", picking.id)] ) self.assertFalse(backorder) - self.assertEqual(picking.move_line_ids.qty_done, 6.0) + self.assertEqual(picking.move_line_ids.qty_picked, 6.0) self.assertEqual(picking.move_line_ids.state, "done") self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) self.assertEqual(picking.move_line_ids.location_id, self.location) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py index 2aeca2ed2ef..6e2ea1703cf 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -72,7 +72,7 @@ def test_set_quantity_child_move_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_done, + "quantity": move_line.quantity, "barcode": self.dispatch_location.name, }, ) diff --git a/shopfloor_single_product_transfer/tests/test_start.py b/shopfloor_single_product_transfer/tests/test_start.py index f6b116f9f2c..57b497f8fdd 100644 --- a/shopfloor_single_product_transfer/tests/test_start.py +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -14,9 +14,10 @@ def test_recover(self): location = self.location_src self._add_stock_to_product(product, location, 10) picking = self._create_picking(lines=[(product, 10)]) + picking.user_id = self.env.user move_line = picking.move_line_ids - move_line.qty_done = move_line.product_uom_qty + move_line._pick_qty(move_line.quantity) response = self.service.dispatch("start") data = { "move_line": self._data_for_move_line(move_line), From cc5e011a3988733972bf36fc8c22b610658bf030 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Tue, 7 Oct 2025 17:09:18 +0200 Subject: [PATCH 31/54] shopfloor_single_product_transfer: Scan location perf improvement --- .../services/single_product_transfer.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 44970dcc20d..fd43ea26a7d 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -115,32 +115,41 @@ def _response_for_set_location(self, move_line, package, message=None): # Handlers - def _scan_location__location_found(self, location, quants): + def _scan_location__quant_domain(self, location): + return [("location_id", "=", location.id), ("quantity", ">", 0)] + + def _scan_location__location_found(self, location): """Check that the location exists.""" if not location: message = self.msg_store.no_location_found() return self._response_for_select_location_or_package(message=message) - def _scan_location__check_location(self, location, quants): + def _scan_location__check_location(self, location): """Check that location belongs to the source location of the operation type.""" if not self.is_src_location_valid(location): message = self.msg_store.location_content_unable_to_transfer(location) return self._response_for_select_location_or_package(message=message) - def _scan_location__check_stock(self, location, quants): + def _scan_location__check_stock(self, location): """Check that the location has products to move.""" + quants = self.env["stock.quant"].search( + self._scan_location__quant_domain(location), limit=1 + ) if not quants: message = self.msg_store.location_empty(location) return self._response_for_select_location_or_package(message=message) - def _scan_location__check_stock_packages(self, location, quants): + def _scan_location__check_stock_packages(self, location): """Check that there are quants without an assigned package.""" - quant_packages = [quant.package_id for quant in quants] - if all(quant_packages): + domain = AND( + [self._scan_location__quant_domain(location), [("package_id", "=", False)]] + ) + quant_without_package = self.env["stock.quant"].search(domain, limit=1) + if not quant_without_package: message = self.msg_store.location_contains_only_packages_scan_one() return self._response_for_select_location_or_package(message=message) - def _scan_location__check_line_packages(self, location, quants): + def _scan_location__check_line_packages(self, location): """Check that the location has lines without an assigned package.""" if not self.is_allow_move_create(): lines_without_package = self.env["stock.move.line"].search( @@ -755,9 +764,6 @@ def _scan_location_or_package__by_package(self, package): ) def _scan_location_or_package__by_location(self, location): - quants_in_location = self.env["stock.quant"].search( - [("location_id", "=", location.id), ("quantity", ">", 0)] - ) handlers = [ self._scan_location__location_found, self._scan_location__check_location, @@ -765,7 +771,7 @@ def _scan_location_or_package__by_location(self, location): self._scan_location__check_stock_packages, self._scan_location__check_line_packages, ] - response = self._use_handlers(handlers, location, quants_in_location) + response = self._use_handlers(handlers, location) if response: return response return self._response_for_select_product(location=location) From c45e88a1433171a4912f7ca7626dcdc72841c814 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Thu, 23 Oct 2025 11:55:35 +0200 Subject: [PATCH 32/54] shopfloor_single_product_transfer: Set lot when creating move line --- .../services/single_product_transfer.py | 2 + .../tests/common.py | 4 +- .../tests/test_set_quantity.py | 53 +++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index fd43ea26a7d..fae16dbd740 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -498,6 +498,8 @@ def _create_move_from_location( # we expect to get only one move line as we are # moving only bulk products w/o lot or package. move_line = move.move_line_ids[0] + if lot: + move_line.lot_id = lot stock = self._actions_for("stock") if self.work.menu.no_prefill_qty: # We ensure the qty_picked is 0 here, so we can set it manually after diff --git a/shopfloor_single_product_transfer/tests/common.py b/shopfloor_single_product_transfer/tests/common.py index a319928e236..57de4286466 100644 --- a/shopfloor_single_product_transfer/tests/common.py +++ b/shopfloor_single_product_transfer/tests/common.py @@ -69,9 +69,9 @@ def cache_existing_record_ids(cls): cls.existing_move_line_ids = cls.env["stock.move.line"].search([]).ids @classmethod - def _add_stock_to_product(cls, product, location, qty, lot=None): + def _add_stock_to_product(cls, product, location, qty, lot=None, package=None): """Set the stock quantity of the product.""" - cls._update_qty_in_location(location, product, qty, lot=lot) + cls._update_qty_in_location(location, product, qty, lot=lot, package=package) # FIXME: can we drop this? # values = { # "product_id": product.id, diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 23bdb8c848f..dbf4cdffcc6 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -849,3 +849,56 @@ def test_set_quantity_scan_package_empty(self): data=expected_data, ) self.assertEqual(package, move_line.result_package_id) + + def test_return_lot_from_customer(self): + product = self.product + self._set_product_tracking_by_lot(product) + self._enable_create_move_line() + quant_model = self.env["stock.quant"] + # Set picking type locations for returns + customer_location = self.customer_location + stock_location = self.stock_location + self.picking_type.sudo().write( + { + "default_location_src_id": customer_location.id, + "default_location_dest_id": stock_location.id, + } + ) + # A lot has been shipped to customer, in a pack + lot = self._create_lot_for_product(product, "LOTABCD") + package = self._create_empty_package(name="PACKABCD") + self._add_stock_to_product( + product, customer_location, 1, lot=lot, package=package + ) + qty_customer = quant_model._get_available_quantity( + product, customer_location, lot_id=lot, package_id=package + ) + self.assertEqual(qty_customer, 1) + # Now try to return it in stock, scan the product, and ensure it creates a + # new move line + self.service.dispatch( + "scan_product", + params={"location_id": customer_location.id, "barcode": lot.name}, + ) + move_line = self.get_new_move_line() + self.assertTrue(move_line) + self.assertEqual(move_line.lot_id, lot) + self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.move_id.picking_type_id, self.picking_type) + # Move qty to stock + self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 1, + "barcode": stock_location.barcode, + }, + ) + qty_customer = quant_model._get_available_quantity( + product, customer_location, lot_id=lot + ) + self.assertEqual(qty_customer, 0) + qty_stock = quant_model._get_available_quantity( + product, stock_location, lot_id=lot + ) + self.assertEqual(qty_stock, 1) From af52fef96aa689e9aff6551cdde9b93d2e8032f7 Mon Sep 17 00:00:00 2001 From: Mmequignon Date: Thu, 23 Oct 2025 15:46:34 +0200 Subject: [PATCH 33/54] shopfloor_single_product_transfer: temporary fix for checkout_sync --- .../services/single_product_transfer.py | 26 +++++++++++++++++-- .../tests/test_set_quantity_checkout_sync.py | 4 +-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index fae16dbd740..339744fdc00 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -11,6 +11,7 @@ from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component +from odoo.addons.component.exception import NoComponentError from odoo.addons.shopfloor.utils import to_float _logger = logging.getLogger("shopfloor.services.single_product_transfer") @@ -630,11 +631,32 @@ def _set_quantity__check_location( ) def _write_destination_on_lines(self, lines, location, unload=False): + # TODO A commit isn't yet ported to 14.0. + # In the meantime restore this + # '_write_destination_on_lines' is implemented in: + # + # - 'location_content_transfer' + # - 'zone_picking' + # - 'cluster_picking' (but it is called '_unload_write_destination_on_lines') + # + # And all of them has a different implementation, + # To refactor later. + try: + # TODO lose dependency on 'shopfloor_checkout_sync' to avoid having + # yet another glue module. In the long term we should make + # 'shopfloor_checkout_sync' use events and trash the overrides made + # on all scenarios. + checkout_sync = self._actions_for("checkout.sync") + except NoComponentError: + self._actions_for("lock").for_update(lines) + else: + self._actions_for("lock").for_update( + checkout_sync._all_lines_to_lock(lines) + ) + checkout_sync._sync_checkout(lines, location) lines.picking_id.location_dest_id = location if unload: lines.result_package_id = False - # FIXME commit isn't migrated yet - # stock.set_destination_and_unload_lines(lines, location) def _set_quantity__post_move(self, move_line, location, confirmation=None): # TODO still valid ? diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py index 6e2ea1703cf..71494bc9ef0 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -56,8 +56,8 @@ def test_set_quantity_child_move_location(self): return picking1 = self._setup_picking() picking2 = self._setup_picking() - move1 = picking1.move_lines - move2 = picking2.move_lines + move1 = picking1.move_ids + move2 = picking2.move_ids pack_move1 = self._add_pack_move_after_pick_move(move1, self.wh.pack_type_id) pack_move2 = self._add_pack_move_after_pick_move(move2, self.wh.pack_type_id) (pack_move1 | pack_move2)._assign_picking() From f10d46ca789a9a1d436c25ddc4be45fc325e6e0e Mon Sep 17 00:00:00 2001 From: oca-ci Date: Tue, 2 Dec 2025 12:42:13 +0000 Subject: [PATCH 34/54] [UPD] Update shopfloor_single_product_transfer.pot --- .../i18n/shopfloor_single_product_transfer.pot | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot b/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot index 8a1a2e3573c..624483da7e4 100644 --- a/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot +++ b/shopfloor_single_product_transfer/i18n/shopfloor_single_product_transfer.pot @@ -4,7 +4,7 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 14.0\n" +"Project-Id-Version: Odoo Server 18.0\n" "Report-Msgid-Bugs-To: \n" "Last-Translator: \n" "Language-Team: \n" From 6046fe0160745cc260c4a3a8290f0c622dc6cffe Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Tue, 2 Dec 2025 12:53:22 +0000 Subject: [PATCH 35/54] [BOT] post-merge updates --- shopfloor_single_product_transfer/README.rst | 117 +++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index e69de29bb2d..dccd6b1c4ba 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -0,0 +1,117 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +================================= +Shopfloor Single Product Transfer +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:1d8be188ed1e57fe136ab2e1897c276c36694c8d96937fb9b12f9c3d309848fd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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 + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fstock--logistics--shopfloor-lightgray.png?logo=github + :target: https://github.com/OCA/stock-logistics-shopfloor/tree/18.0/shopfloor_single_product_transfer + :alt: OCA/stock-logistics-shopfloor +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/stock-logistics-shopfloor-18-0/stock-logistics-shopfloor-18-0-shopfloor_single_product_transfer + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-shopfloor&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Allow to move a single product from a location to another one. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +| **Source location selection** +| Select a source location. It must be a valid location according to the + configuration of the scenario, and there must be stock in the selected + location. + +| **Move line selection** +| Select a product or a lot in this location. If an unassigned move line + for this product / lot exists in the previously selected location, + then it is selected. Otherwise, if the Allow Move Creation is enabled, + it will try to create a move line. If the Allow to process reserved + quantities option is enabled, other moves will be unreserved. If + there's unreserved goods in the location, a new move is created with + quantity equal to the unreserved goods in the location. + +**Set quantity / destination location** + +1. **Scan a product / lot to set the quantity** If the Do not pre-fill + quantity to pick option is enabled, it will increment the done + quantity by 1 each time the product or lot barcode is scanned. Else, + it will set the quantity done as the reserved quantity. +2. **Scan a destination location** The scanned location will be checked. + It must be a child of the current line destination location or a + child of the scenario default destination location. If this is ok, + then the move is processed. + +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 +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Camptocamp + +Contributors +------------ + +- Matthieu Méquignon +- Michael Tietz (MT Software) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px + :target: https://github.com/mmequignon + :alt: mmequignon + +Current `maintainer `__: + +|maintainer-mmequignon| + +This module is part of the `OCA/stock-logistics-shopfloor `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. From f0eed1b1dab390ab6e179e5adec3577d8d76729b Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Fri, 27 Feb 2026 21:08:22 +0100 Subject: [PATCH 36/54] shopfloor_single_product_transfer: fix tests Ensure pack moves are in the same transfer --- .../tests/test_set_quantity_checkout_sync.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py index 71494bc9ef0..12327c5a6eb 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -58,6 +58,9 @@ def test_set_quantity_child_move_location(self): picking2 = self._setup_picking() move1 = picking1.move_ids move2 = picking2.move_ids + (move1 | move2).group_id = self.env["procurement.group"].create( + {"name": "Test shopfloor sync"} + ) pack_move1 = self._add_pack_move_after_pick_move(move1, self.wh.pack_type_id) pack_move2 = self._add_pack_move_after_pick_move(move2, self.wh.pack_type_id) (pack_move1 | pack_move2)._assign_picking() From b5251a479abd578c26c2194c742864a774ad94b1 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Mon, 2 Mar 2026 10:17:37 +0000 Subject: [PATCH 37/54] [BOT] post-merge updates --- shopfloor_single_product_transfer/README.rst | 2 +- shopfloor_single_product_transfer/__manifest__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index dccd6b1c4ba..26b30c00e4a 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -11,7 +11,7 @@ Shopfloor Single Product Transfer !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:1d8be188ed1e57fe136ab2e1897c276c36694c8d96937fb9b12f9c3d309848fd + !! source digest: sha256:e83c89b3659624fbf3a7197067308cd3db72f5196012903d014dde12b15a0642 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index c3819aee7c3..4e1dab713c7 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "18.0.1.0.0", + "version": "18.0.1.0.1", "category": "Inventory", "website": "https://github.com/OCA/stock-logistics-shopfloor", "author": "Camptocamp, Odoo Community Association (OCA)", From 709c53bd6a4c411b9b0b6c1886b028da24499d2c Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Sat, 28 Feb 2026 09:56:05 +0100 Subject: [PATCH 38/54] shopfloor_single_product_transfer: maintainers --- shopfloor_single_product_transfer/__manifest__.py | 4 ++-- shopfloor_single_product_transfer/readme/CONTRIBUTORS.md | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 4e1dab713c7..77c4a5bbbee 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -4,8 +4,8 @@ "version": "18.0.1.0.1", "category": "Inventory", "website": "https://github.com/OCA/stock-logistics-shopfloor", - "author": "Camptocamp, Odoo Community Association (OCA)", - "maintainers": ["mmequignon"], + "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", + "maintainers": ["mmequignon", "jbaudoux", "TDu"], "license": "AGPL-3", "installable": True, "auto_install": False, diff --git a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md index 20ac54db895..8375deed594 100644 --- a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md +++ b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md @@ -1,2 +1,6 @@ - Matthieu Méquignon \<\> - Michael Tietz (MT Software) \<\> + +## Design + +- Jacques-Etienne Baudoux \<\> From dde4eb88aab7c13aafd52fe9cc42dd87104906d0 Mon Sep 17 00:00:00 2001 From: Thierry Ducrest Date: Tue, 6 Jan 2026 10:26:05 +0100 Subject: [PATCH 39/54] [IMP] shopfloor_single_product_transfer: use action set destination --- .../services/single_product_transfer.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 339744fdc00..d3ab0b8c148 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -631,16 +631,6 @@ def _set_quantity__check_location( ) def _write_destination_on_lines(self, lines, location, unload=False): - # TODO A commit isn't yet ported to 14.0. - # In the meantime restore this - # '_write_destination_on_lines' is implemented in: - # - # - 'location_content_transfer' - # - 'zone_picking' - # - 'cluster_picking' (but it is called '_unload_write_destination_on_lines') - # - # And all of them has a different implementation, - # To refactor later. try: # TODO lose dependency on 'shopfloor_checkout_sync' to avoid having # yet another glue module. In the long term we should make @@ -654,7 +644,8 @@ def _write_destination_on_lines(self, lines, location, unload=False): checkout_sync._all_lines_to_lock(lines) ) checkout_sync._sync_checkout(lines, location) - lines.picking_id.location_dest_id = location + stock = self._actions_for("stock") + stock.set_destination_on_lines(lines, location, lock_lines=False) if unload: lines.result_package_id = False From f38e7a8b5a4c89145b7dede55a83139e05e06c2b Mon Sep 17 00:00:00 2001 From: Jacques-Etienne Baudoux Date: Fri, 27 Feb 2026 18:03:38 +0100 Subject: [PATCH 40/54] [FIX] shopfloor_single_product_transfer: set_destination_on_lines Never update move destination --- .../tests/test_set_quantity.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index dbf4cdffcc6..41f068efbca 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -437,7 +437,10 @@ def test_set_quantity_scan_packaging_with_allow_move_create(self): ) self.assertEqual(move_line.location_dest_id, self.dispatch_location) self.assertEqual(move_line.location_id, location) - self.assertEqual(move_line.move_id.location_dest_id, self.dispatch_location) + self.assertEqual( + move_line.move_id.location_dest_id, + self.picking_type.default_location_dest_id, + ) self.assertEqual(move_line.move_id.location_id, location) def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( @@ -740,7 +743,8 @@ def test_set_quantity_scan_location(self): self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) self.assertEqual(picking.move_line_ids.location_id, self.location) self.assertEqual( - picking.move_line_ids.move_id.location_dest_id, self.dispatch_location + picking.move_line_ids.move_id.location_dest_id, + self.picking_type.default_location_dest_id, ) self.assertEqual( picking.move_line_ids.move_id.location_id, @@ -779,7 +783,8 @@ def test_set_quantity_scan_location_allow_move_create(self): self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) self.assertEqual(picking.move_line_ids.location_id, self.location) self.assertEqual( - picking.move_line_ids.move_id.location_dest_id, self.dispatch_location + picking.move_line_ids.move_id.location_dest_id, + self.picking_type.default_location_dest_id, ) self.assertEqual( picking.move_line_ids.move_id.location_id, From 8e66ab87d98e62b96b7a23d5b5d91e174fc36289 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Wed, 4 Mar 2026 11:14:43 +0000 Subject: [PATCH 41/54] [BOT] post-merge updates --- shopfloor_single_product_transfer/README.rst | 2 +- shopfloor_single_product_transfer/__manifest__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index 26b30c00e4a..c0e12bd2a60 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -11,7 +11,7 @@ Shopfloor Single Product Transfer !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:e83c89b3659624fbf3a7197067308cd3db72f5196012903d014dde12b15a0642 + !! source digest: sha256:005ce028ddc6f2ad47635acd8f9d8a6f2bb76ceaaf17231d99565a3aba98dd58 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 77c4a5bbbee..e5684e889c5 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "18.0.1.0.1", + "version": "18.0.1.1.0", "category": "Inventory", "website": "https://github.com/OCA/stock-logistics-shopfloor", "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", From 49c27061b9ef2c0b8bc1e6c2a4e5901288f5f590 Mon Sep 17 00:00:00 2001 From: OCA-git-bot Date: Thu, 5 Mar 2026 08:23:16 +0000 Subject: [PATCH 42/54] [BOT] post-merge updates --- shopfloor_single_product_transfer/README.rst | 18 +++++++++++++++--- .../__manifest__.py | 2 +- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index c0e12bd2a60..6c90bd054e8 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -11,7 +11,7 @@ Shopfloor Single Product Transfer !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:005ce028ddc6f2ad47635acd8f9d8a6f2bb76ceaaf17231d99565a3aba98dd58 + !! source digest: sha256:9137949c3bafa93853948ac4a237d3ee6118a0ba89df49484edea9bfd99c7235 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png @@ -84,6 +84,7 @@ Authors ------- * Camptocamp +* BCIM Contributors ------------ @@ -91,6 +92,11 @@ Contributors - Matthieu Méquignon - Michael Tietz (MT Software) +Design +~~~~~~ + +- Jacques-Etienne Baudoux + Maintainers ----------- @@ -107,10 +113,16 @@ promote its widespread use. .. |maintainer-mmequignon| image:: https://github.com/mmequignon.png?size=40px :target: https://github.com/mmequignon :alt: mmequignon +.. |maintainer-jbaudoux| image:: https://github.com/jbaudoux.png?size=40px + :target: https://github.com/jbaudoux + :alt: jbaudoux +.. |maintainer-TDu| image:: https://github.com/TDu.png?size=40px + :target: https://github.com/TDu + :alt: TDu -Current `maintainer `__: +Current `maintainers `__: -|maintainer-mmequignon| +|maintainer-mmequignon| |maintainer-jbaudoux| |maintainer-TDu| This module is part of the `OCA/stock-logistics-shopfloor `_ project on GitHub. diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index e5684e889c5..9cf56f7b369 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "18.0.1.1.0", + "version": "18.0.1.2.0", "category": "Inventory", "website": "https://github.com/OCA/stock-logistics-shopfloor", "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", From ac5367d1090673519d71919b0178aa14e958858c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Thu, 11 Jun 2026 13:37:49 +0200 Subject: [PATCH 43/54] [IMP] shopfloor_single_product_transfer: pre-commit auto fixes --- shopfloor_single_product_transfer/README.rst | 16 ++++++++-------- .../__manifest__.py | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/shopfloor_single_product_transfer/README.rst b/shopfloor_single_product_transfer/README.rst index 6c90bd054e8..ad393c0e45e 100644 --- a/shopfloor_single_product_transfer/README.rst +++ b/shopfloor_single_product_transfer/README.rst @@ -20,14 +20,14 @@ Shopfloor Single Product Transfer .. |badge2| image:: https://img.shields.io/badge/license-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%2Fstock--logistics--shopfloor-lightgray.png?logo=github - :target: https://github.com/OCA/stock-logistics-shopfloor/tree/18.0/shopfloor_single_product_transfer - :alt: OCA/stock-logistics-shopfloor +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/shopfloor_single_product_transfer + :alt: OCA/wms .. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png - :target: https://translation.odoo-community.org/projects/stock-logistics-shopfloor-18-0/stock-logistics-shopfloor-18-0-shopfloor_single_product_transfer + :target: https://translation.odoo-community.org/projects/wms-16-0/wms-16-0-shopfloor_single_product_transfer :alt: Translate me on Weblate .. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png - :target: https://runboat.odoo-community.org/builds?repo=OCA/stock-logistics-shopfloor&target_branch=18.0 + :target: https://runboat.odoo-community.org/builds?repo=OCA/wms&target_branch=16.0 :alt: Try me on Runboat |badge1| |badge2| |badge3| |badge4| |badge5| @@ -70,10 +70,10 @@ Usage Bug Tracker =========== -Bugs are tracked on `GitHub Issues `_. +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 -`feedback `_. +`feedback `_. Do not contact contributors directly about support or help with technical issues. @@ -124,6 +124,6 @@ Current `maintainers `__: |maintainer-mmequignon| |maintainer-jbaudoux| |maintainer-TDu| -This module is part of the `OCA/stock-logistics-shopfloor `_ project on GitHub. +This module is part of the `OCA/wms `_ project on GitHub. You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index 9cf56f7b369..c37145d95f5 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -3,7 +3,7 @@ "summary": "Move an item from one location to another.", "version": "18.0.1.2.0", "category": "Inventory", - "website": "https://github.com/OCA/stock-logistics-shopfloor", + "website": "https://github.com/OCA/wms", "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", "maintainers": ["mmequignon", "jbaudoux", "TDu"], "license": "AGPL-3", From 225b271a98b32cc946df1ebb1f689a92f0a2459e Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Thu, 11 Jun 2026 15:46:56 +0200 Subject: [PATCH 44/54] [MIG] shopfloor_single_product_transfer: Backport to 16.0 from 18.0 --- .../addons/shopfloor_single_product_transfer | 1 + .../setup.py | 6 + .../__manifest__.py | 2 +- shopfloor_single_product_transfer/hooks.py | 8 +- .../pyproject.toml | 3 - .../services/single_product_transfer.py | 10 +- .../tests/test_scan_product.py | 27 ++--- .../tests/test_set_quantity.py | 114 +++++++++--------- .../tests/test_set_quantity_checkout_sync.py | 2 +- .../tests/test_start.py | 2 +- 10 files changed, 88 insertions(+), 87 deletions(-) create mode 120000 setup/shopfloor_single_product_transfer/odoo/addons/shopfloor_single_product_transfer create mode 100644 setup/shopfloor_single_product_transfer/setup.py delete mode 100644 shopfloor_single_product_transfer/pyproject.toml diff --git a/setup/shopfloor_single_product_transfer/odoo/addons/shopfloor_single_product_transfer b/setup/shopfloor_single_product_transfer/odoo/addons/shopfloor_single_product_transfer new file mode 120000 index 00000000000..957a268eea7 --- /dev/null +++ b/setup/shopfloor_single_product_transfer/odoo/addons/shopfloor_single_product_transfer @@ -0,0 +1 @@ +../../../../shopfloor_single_product_transfer \ No newline at end of file diff --git a/setup/shopfloor_single_product_transfer/setup.py b/setup/shopfloor_single_product_transfer/setup.py new file mode 100644 index 00000000000..28c57bb6403 --- /dev/null +++ b/setup/shopfloor_single_product_transfer/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index c37145d95f5..bba234fba1a 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "18.0.1.2.0", + "version": "16.0.1.0.0", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", diff --git a/shopfloor_single_product_transfer/hooks.py b/shopfloor_single_product_transfer/hooks.py index 06aee169831..a4bf5d0b697 100644 --- a/shopfloor_single_product_transfer/hooks.py +++ b/shopfloor_single_product_transfer/hooks.py @@ -3,6 +3,8 @@ import logging +from odoo import SUPERUSER_ID, api + from odoo.addons.shopfloor_base.utils import purge_endpoints, register_new_services from .services.single_product_transfer import ShopfloorSingleProductTransfer as Service @@ -10,11 +12,13 @@ _logger = logging.getLogger(__file__) -def post_init_hook(env): +def post_init_hook(cr, registry): _logger.info("Register routes for %s", Service._usage) + env = api.Environment(cr, SUPERUSER_ID, {}) register_new_services(env, Service) -def uninstall_hook(env): +def uninstall_hook(cr, registry): _logger.info("Refreshing routes for existing apps") + env = api.Environment(cr, SUPERUSER_ID, {}) purge_endpoints(env, Service._usage) diff --git a/shopfloor_single_product_transfer/pyproject.toml b/shopfloor_single_product_transfer/pyproject.toml deleted file mode 100644 index 4231d0cccb3..00000000000 --- a/shopfloor_single_product_transfer/pyproject.toml +++ /dev/null @@ -1,3 +0,0 @@ -[build-system] -requires = ["whool"] -build-backend = "whool.buildapi" diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index d3ab0b8c148..480e6b2c9a0 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -529,7 +529,7 @@ def _set_quantity__check_quantity_done( ): stock = self._actions_for("stock") if not stock.move_line_check_qty_picked(move_line): - message = self.msg_store.unable_to_pick_more(move_line.quantity) + message = self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty) return self._response_for_set_quantity(move_line, message=message) def _set_quantity__check_no_prefill_qty( @@ -538,7 +538,7 @@ def _set_quantity__check_no_prefill_qty( if not self.work.menu.no_prefill_qty: # If no_prefill_qty is False, then qty_picked should have been prefilled # with product_uom_qty in the select_product screen - message = self.msg_store.unable_to_pick_more(move_line.product_uom_qty) + message = self.msg_store.unable_to_pick_more(move_line.reserved_uom_qty) return self._response_for_set_quantity(move_line, message=message) def _set_quantity__increment_qty_picked( @@ -645,7 +645,7 @@ def _write_destination_on_lines(self, lines, location, unload=False): ) checkout_sync._sync_checkout(lines, location) stock = self._actions_for("stock") - stock.set_destination_on_lines(lines, location, lock_lines=False) + stock.set_destination_on_lines(lines, location) if unload: lines.result_package_id = False @@ -696,7 +696,7 @@ def _find_user_move_line_domain(self, user): ("picking_id.user_id", "in", (False, self.env.uid)), ("picking_id.picking_type_id", "in", self.picking_types.ids), ("state", "in", ("assigned", "partially_available")), - ("qty_picked", ">", 0), + ("qty_done", ">", 0), ] def _find_user_move_line(self): @@ -879,7 +879,7 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=None): if move_line.state == "done": message = self.msg_store.move_already_done() return self._response_for_set_quantity(move_line, message=message) - move_line.qty_picked = quantity + move_line.qty_done = quantity handlers_by_type = { # Increment qty done if a product / lot / packaging is scanned "product": self._set_quantity__by_product, diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index 55d7753c7c2..cbef3c25230 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -76,8 +76,8 @@ def test_scan_product_multiple_lines_in_picking_no_prefill_qty_enabled(self): new_picking = self.get_new_picking() self.assertTrue(new_picking) self.assertEqual(move_line.picking_id, new_picking) - self.assertEqual(move_line.qty_picked, 1) - self.assertEqual(move_line.quantity, 10.0) + self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.reserved_uom_qty, 10.0) def test_scan_product_no_move_line(self): # No move with product in location, create move line is disabled. @@ -160,7 +160,7 @@ def test_scan_product_with_stock_create_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.quantity, 10.0) + self.assertEqual(move_line.reserved_uom_qty, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -236,7 +236,7 @@ def test_scan_product_with_reserved_stock_unreserve_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.quantity, 10.0) + self.assertEqual(move_line.reserved_uom_qty, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -373,7 +373,7 @@ def test_scan_lot_with_reserved_stock_unreserve_move_enabled(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertTrue(move_line.picking_id.user_id) - self.assertEqual(move_line.quantity, 10.0) + self.assertEqual(move_line.reserved_uom_qty, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -461,9 +461,8 @@ def test_create_move_line_by_product_no_prefill_qty_disabled(self): params={"location_id": location.id, "barcode": product.barcode}, ) move_line = self.get_new_move_line() - self.assertTrue(move_line.picked) - self.assertEqual(move_line.quantity, max_qty_done) - self.assertEqual(move_line.qty_picked, max_qty_done) + self.assertEqual(move_line.reserved_uom_qty, max_qty_done) + self.assertEqual(move_line.qty_done, max_qty_done) def test_create_move_line_by_product_no_prefill_qty_enabled(self): location = self.location_src @@ -477,8 +476,8 @@ def test_create_move_line_by_product_no_prefill_qty_enabled(self): params={"location_id": location.id, "barcode": product.barcode}, ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_picked, 1) - self.assertEqual(move_line.quantity, max_qty_done) + self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.reserved_uom_qty, max_qty_done) def test_create_move_line_by_lot_no_prefill_qty_disabled(self): location = self.location_src @@ -492,8 +491,8 @@ def test_create_move_line_by_lot_no_prefill_qty_disabled(self): "scan_product", params={"location_id": location.id, "barcode": lot.name} ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_picked, max_qty_done) - self.assertEqual(move_line.quantity, max_qty_done) + self.assertEqual(move_line.qty_done, max_qty_done) + self.assertEqual(move_line.reserved_uom_qty, max_qty_done) def test_create_move_line_by_lot_no_prefill_qty_enabled(self): location = self.location_src @@ -509,8 +508,8 @@ def test_create_move_line_by_lot_no_prefill_qty_enabled(self): params={"location_id": location.id, "barcode": lot.name}, ) move_line = self.get_new_move_line() - self.assertEqual(move_line.qty_picked, 1) - self.assertEqual(move_line.quantity, max_qty_done) + self.assertEqual(move_line.qty_done, 1) + self.assertEqual(move_line.reserved_uom_qty, max_qty_done) def test_action_cancel(self): response = self.service.dispatch("scan_product__action_cancel") diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 41f068efbca..d4ba5c3f7ec 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -50,7 +50,7 @@ def test_set_quantity_barcode_not_found(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.quantity, + "quantity": move_line.reserved_uom_qty, "barcode": "NOPE", }, ) @@ -75,7 +75,7 @@ def test_set_quantity_line_done(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.quantity, + "quantity": move_line.reserved_uom_qty, "barcode": self.dispatch_location.name, }, ) @@ -86,7 +86,7 @@ def test_set_quantity_line_done(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.quantity, + "quantity": move_line.reserved_uom_qty, "barcode": self.product.barcode, }, ) @@ -109,16 +109,15 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): ) # Without no_prefill_qty, once selected, a moveline qty done is already # equal to the qty todo. - self.assertTrue(move_line.picked) - self.assertEqual(move_line.quantity, 10) - self.assertEqual(move_line.qty_picked, 10) + self.assertEqual(move_line.reserved_uom_qty, 10) + self.assertEqual(move_line.qty_done, 10) # We do not prevent the user to set a bigger qty # No qty check when scanning a product response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.product.barcode, }, ) @@ -133,7 +132,7 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.location.barcode, }, ) @@ -143,7 +142,7 @@ def test_set_quantity_scan_product_prefill_qty_disabled(self): } expected_message = { "message_type": "error", - "body": f"You must not pick more than {move_line.quantity} units.", + "body": f"You must not pick more than {move_line.reserved_uom_qty} units.", } self.assert_response( response, next_state="set_quantity", message=expected_message, data=data @@ -158,7 +157,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "scan_product", params={"location_id": self.location.id, "barcode": self.product.barcode}, ) - self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.qty_done, 1) # We can scan the same product 9 times, and the qty will increment by 1 # each time. for expected_qty in range(2, 11): @@ -166,7 +165,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.product.barcode, }, ) @@ -175,13 +174,13 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_picked, expected_qty) + self.assertEqual(move_line.qty_done, expected_qty) # We do not prevent the user to set a qty_picked > quantity in the picker response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.product.barcode, }, ) @@ -195,7 +194,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.location.barcode, }, ) @@ -205,7 +204,7 @@ def test_set_quantity_scan_product_prefill_qty_enabled(self): } expected_message = { "message_type": "error", - "body": f"You must not pick more than {move_line.quantity} units.", + "body": f"You must not pick more than {move_line.reserved_uom_qty} units.", } self.assert_response( response, next_state="set_quantity", message=expected_message, data=data @@ -219,7 +218,7 @@ def test_set_picker_quantity(self): "scan_product", params={"location_id": self.location.id, "barcode": self.product.barcode}, ) - self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.qty_done, 1) response = self.service.dispatch( "set_quantity", params={ @@ -235,7 +234,7 @@ def test_set_picker_quantity(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_picked, 6.0) + self.assertEqual(move_line.qty_done, 6.0) response = self.service.dispatch( "set_quantity", params={ @@ -251,7 +250,7 @@ def test_set_picker_quantity(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_picked, 11.0) + self.assertEqual(move_line.qty_done, 11.0) # When scanning a location, a qty_picked is checked. # Since qty done > qty todo, an error should be raised @@ -264,12 +263,12 @@ def test_set_quantity_scan_lot_prefill_qty_disabled(self): "scan_product", params={"location_id": self.location.id, "barcode": lot.name}, ) - self.assertEqual(move_line.qty_picked, move_line.quantity) + self.assertEqual(move_line.qty_done, move_line.reserved_uom_qty) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": lot.name, }, ) @@ -284,7 +283,7 @@ def test_set_quantity_scan_lot_prefill_qty_disabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.location.barcode, }, ) @@ -307,7 +306,7 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): params={"location_id": self.location.id, "barcode": lot.name}, ) move_line = picking.move_line_ids - self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.qty_done, 1) # We can scan the same lot 9 times (until qty_picked == quantity), # and the qty will increment by 1 each time. for expected_qty in range(2, 11): @@ -315,7 +314,7 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": lot.name, }, ) @@ -324,13 +323,13 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_picked, expected_qty) + self.assertEqual(move_line.qty_done, expected_qty) # Nothing prevents the user to set qty_picked > quantity response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": lot.name, }, ) @@ -345,7 +344,7 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.location.barcode, }, ) @@ -367,13 +366,13 @@ def test_set_quantity_scan_packaging(self): "scan_product", params={"location_id": self.location.id, "barcode": self.packaging.barcode}, ) - self.assertEqual(move_line.qty_picked, move_line.quantity) + self.assertEqual(move_line.qty_done, move_line.reserved_uom_qty) # picker qty is 10 + scanned packaging qty is 5 = 15 response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.quantity, + "quantity": move_line.reserved_uom_qty, "barcode": self.packaging.barcode, }, ) @@ -382,14 +381,14 @@ def test_set_quantity_scan_packaging(self): "asking_confirmation": None, } self.assert_response(response, next_state="set_quantity", data=data) - self.assertEqual(move_line.qty_picked, 15.0) + self.assertEqual(move_line.qty_done, 15.0) # However, we shouldn't be able to confirm (scan a location) # since quantity_picked > quantity (max is 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.location.barcode, }, ) @@ -418,13 +417,13 @@ def test_set_quantity_scan_packaging_with_allow_move_create(self): self.product, location ) move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertTrue(move_line.quantity) - self.assertEqual(move_line.quantity, 10.0) + self.assertTrue(move_line.reserved_uom_qty) + self.assertEqual(move_line.reserved_uom_qty, 10.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, }, ) @@ -437,10 +436,7 @@ def test_set_quantity_scan_packaging_with_allow_move_create(self): ) self.assertEqual(move_line.location_dest_id, self.dispatch_location) self.assertEqual(move_line.location_id, location) - self.assertEqual( - move_line.move_id.location_dest_id, - self.picking_type.default_location_dest_id, - ) + self.assertEqual(move_line.move_id.location_dest_id, self.dispatch_location) self.assertEqual(move_line.move_id.location_id, location) def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( @@ -462,16 +458,16 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( self.product, location ) move_line = self.env["stock.move.line"].search(domain, limit=1) - self.assertEqual(move_line.qty_picked, 5.0) + self.assertEqual(move_line.qty_done, 5.0) response = self.service.dispatch( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.packaging.barcode, }, ) - self.assertEqual(move_line.qty_picked, 10.0) + self.assertEqual(move_line.qty_done, 10.0) data = { "move_line": self._data_for_move_line(move_line), "asking_confirmation": None, @@ -481,13 +477,12 @@ def test_set_quantity_scan_packaging_with_allow_move_create_and_no_prefill_qty( "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, }, ) - expected_message = self.msg_store.unable_to_pick_more(self.packaging.qty) - data = {"location": self._data_for_location(location)} expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + data = {"location": self._data_for_location(location)} self.assert_response( response, next_state="select_product", message=expected_message, data=data ) @@ -505,7 +500,7 @@ def test_set_quantity_invalid_dest_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": wrong_location.barcode, }, ) @@ -532,7 +527,7 @@ def test_set_quantity_menu_default_location(self): # Scanning a child of the menu, shopfloor should ask for a confirmation params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, } response = self.service.dispatch("set_quantity", params=params) @@ -569,7 +564,7 @@ def test_set_quantity_confirm_with_different_barcode(self): move_line.location_dest_id = self.env.ref("stock.stock_location_14") params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, } # Setting the confirmation to another location barcode @@ -610,7 +605,7 @@ def test_set_quantity_child_move_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.name, }, ) @@ -631,7 +626,7 @@ def test_action_cancel(self): }, ) move_line = picking.move_line_ids - move_line._pick_qty(10.0) + move_line.qty_done = 10.0 # Result here already tested in # `test_scan_product::TestScanProduct::test_scan_product_with_move_line` response = self.service.dispatch( @@ -643,7 +638,7 @@ def test_action_cancel(self): ) # Ensure qty_picked and user has been reset. self.assertFalse(move_line.picking_id.user_id) - self.assertEqual(move_line.qty_picked, 0.0) + self.assertEqual(move_line.qty_done, 0.0) # Ensure the picking is not cancelled if allow_move_create is not enabled self.assertTrue(move_line.picking_id.state == "assigned") @@ -660,7 +655,7 @@ def test_action_cancel_allow_move_create(self): }, ) move_line = picking.move_line_ids - move_line._pick_qty(10.0) + move_line.qty_done = 10.0 response = self.service.dispatch( "set_quantity__action_cancel", params={"selected_line_id": move_line.id} ) @@ -669,8 +664,7 @@ def test_action_cancel_allow_move_create(self): response, next_state="select_location_or_package", data=data ) # Ensure the picking is cancelled if allow_move_create is enabled - self.assertFalse(move_line.exists()) - self.assertTrue(picking.state == "cancel") + self.assertTrue(move_line.picking_id.state == "cancel") def test_set_quantity_done_with_completion_info(self): self.picking_type.sudo().display_completion_info = "next_picking_ready" @@ -687,7 +681,7 @@ def test_set_quantity_done_with_completion_info(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.name, }, ) @@ -730,12 +724,12 @@ def test_set_quantity_scan_location(self): self.assertEqual( backorder.move_line_ids.product_id, picking.move_line_ids.product_id ) - self.assertEqual(backorder.move_line_ids.qty_picked, 6.0) + self.assertEqual(backorder.move_line_ids.qty_done, 6.0) self.assertEqual(backorder.move_line_ids.state, "done") self.assertEqual(backorder.user_id, self.env.user) self.assertEqual(backorder.move_line_ids.shopfloor_user_id, self.env.user) - self.assertEqual(picking.move_line_ids.quantity, 4.0) - self.assertEqual(picking.move_line_ids.qty_picked, 0.0) + self.assertEqual(picking.move_line_ids.reserved_uom_qty, 4.0) + self.assertEqual(picking.move_line_ids.qty_done, 0.0) self.assertEqual(picking.move_line_ids.state, "assigned") self.assertFalse(picking.move_line_ids.result_package_id) self.assertEqual(picking.user_id.id, False) @@ -744,7 +738,7 @@ def test_set_quantity_scan_location(self): self.assertEqual(picking.move_line_ids.location_id, self.location) self.assertEqual( picking.move_line_ids.move_id.location_dest_id, - self.picking_type.default_location_dest_id, + self.dispatch_location, ) self.assertEqual( picking.move_line_ids.move_id.location_id, @@ -778,13 +772,13 @@ def test_set_quantity_scan_location_allow_move_create(self): [("backorder_id", "=", picking.id)] ) self.assertFalse(backorder) - self.assertEqual(picking.move_line_ids.qty_picked, 6.0) + self.assertEqual(picking.move_line_ids.qty_done, 6.0) self.assertEqual(picking.move_line_ids.state, "done") self.assertEqual(picking.move_line_ids.location_dest_id, self.dispatch_location) self.assertEqual(picking.move_line_ids.location_id, self.location) self.assertEqual( picking.move_line_ids.move_id.location_dest_id, - self.picking_type.default_location_dest_id, + self.dispatch_location, ) self.assertEqual( picking.move_line_ids.move_id.location_id, @@ -888,7 +882,7 @@ def test_return_lot_from_customer(self): move_line = self.get_new_move_line() self.assertTrue(move_line) self.assertEqual(move_line.lot_id, lot) - self.assertEqual(move_line.qty_picked, 1) + self.assertEqual(move_line.qty_done, 1) self.assertEqual(move_line.move_id.picking_type_id, self.picking_type) # Move qty to stock self.service.dispatch( diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py index 12327c5a6eb..53d81bbf755 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -75,7 +75,7 @@ def test_set_quantity_child_move_location(self): "set_quantity", params={ "selected_line_id": move_line.id, - "quantity": move_line.quantity, + "quantity": move_line.reserved_uom_qty, "barcode": self.dispatch_location.name, }, ) diff --git a/shopfloor_single_product_transfer/tests/test_start.py b/shopfloor_single_product_transfer/tests/test_start.py index 57b497f8fdd..b3e33547da1 100644 --- a/shopfloor_single_product_transfer/tests/test_start.py +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -17,7 +17,7 @@ def test_recover(self): picking.user_id = self.env.user move_line = picking.move_line_ids - move_line._pick_qty(move_line.quantity) + move_line.qty_done = move_line.reserved_uom_qty response = self.service.dispatch("start") data = { "move_line": self._data_for_move_line(move_line), From 9cd4740eb4e8f79e7d103eeafc11572a5ff12cf1 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 12 Jun 2026 16:20:10 +0200 Subject: [PATCH 45/54] [IMP] shopfloor_single_product_transfer: Back to select_location_or_package When the user sets a location for a move line, we used to go back to the select_product state. This is not ideal in the case where the no more product needs to be selected at the current location (no more pending move line at this location), as the user would have to select a new location to be able to select the next move line. Now, if the scenario is configured to forbid the creation of new move lines and there is no more pending move line at the current location, when the user sets a location for a move line, the next state will be select_location_or_package instead of select_product. --- .../services/single_product_transfer.py | 26 ++++++- .../tests/test_set_location.py | 4 +- .../tests/test_set_quantity.py | 67 +++++++++++++++---- .../tests/test_set_quantity_checkout_sync.py | 4 +- 4 files changed, 80 insertions(+), 21 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 480e6b2c9a0..956003bb89d 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -70,8 +70,10 @@ class ShopfloorSingleProductTransfer(Component): # Responses - def _response_for_select_location_or_package(self, message=None): - return self._response(next_state="select_location_or_package", message=message) + def _response_for_select_location_or_package(self, message=None, popup=None): + return self._response( + next_state="select_location_or_package", message=message, popup=popup + ) def _response_for_select_product( self, @@ -649,6 +651,19 @@ def _write_destination_on_lines(self, lines, location, unload=False): if unload: lines.result_package_id = False + def _has_pending_operations_at_same_location(self, move_line): + return bool( + self.env["stock.move.line"].search_count( + [ + ("state", "in", ("assigned", "partially_available")), + ("location_id", "=", move_line.location_id.id), + ("picking_id.picking_type_id", "in", self.picking_types.ids), + ("id", "!=", move_line.id), + ], + limit=1, + ) + ) + def _set_quantity__post_move(self, move_line, location, confirmation=None): # TODO still valid ? # TODO qty_done = 0: transfer_no_qty_done @@ -663,6 +678,13 @@ def _set_quantity__post_move(self, move_line, location, confirmation=None): message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) + if ( + not self.is_allow_move_create() + and not self._has_pending_operations_at_same_location(move_line) + ): + return self._response_for_select_location_or_package( + message=message, popup=completion_info_popup + ) return self._response_for_select_product( location=move_line.location_id, package=move_line.package_id, diff --git a/shopfloor_single_product_transfer/tests/test_set_location.py b/shopfloor_single_product_transfer/tests/test_set_location.py index 7b35bc46af7..8292de62317 100644 --- a/shopfloor_single_product_transfer/tests/test_set_location.py +++ b/shopfloor_single_product_transfer/tests/test_set_location.py @@ -40,12 +40,10 @@ def test_set_location_ok(self): expected_message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self.service._actions_for("completion.info") expected_popup = completion_info.popup(move_line) - data = {"location": self._data_for_location(self.location)} self.assert_response( response, - next_state="select_product", + next_state="select_location_or_package", message=expected_message, - data=data, popup=expected_popup, ) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index d4ba5c3f7ec..4db6dfa98d9 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -551,11 +551,60 @@ def test_set_quantity_menu_default_location(self): expected_message = self.service.msg_store.transfer_done_success( move_line.picking_id ) - data = {"location": self._data_for_location(location)} + self.assert_response( + response, next_state="select_location_or_package", message=expected_message + ) + + def test_set_quantity_multi_operation_same_location(self): + self._add_stock_to_product(self.product, self.location, 10) + self._add_stock_to_product(self.product_b, self.location, 10) + picking = self._create_picking(lines=[(self.product, 10), (self.product_b, 10)]) + + location = self.location + + move_line = picking.move_line_ids.filtered( + lambda ml: ml.product_id == self.product + ) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + params = { + "selected_line_id": move_line.id, + "quantity": move_line.qty_picked, + "barcode": self.dispatch_location.barcode, + } + response = self.service.dispatch("set_quantity", params=params) + expected_message = self.service.msg_store.transfer_done_success( + move_line.picking_id + ) + data = { + "location": self._data_for_location(location), + } self.assert_response( response, next_state="select_product", message=expected_message, data=data ) + move_line = picking.move_line_ids.filtered( + lambda ml: ml.product_id == self.product_b + ) + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product_b.barcode}, + ) + params = { + "selected_line_id": move_line.id, + "quantity": move_line.qty_picked, + "barcode": self.dispatch_location.barcode, + } + response = self.service.dispatch("set_quantity", params=params) + expected_message = self.service.msg_store.transfer_done_success( + move_line.picking_id + ) + self.assert_response( + response, next_state="select_location_or_package", message=expected_message + ) + def test_set_quantity_confirm_with_different_barcode(self): picking = self._setup_picking() self.menu.sudo().allow_alternative_destination = True @@ -587,9 +636,8 @@ def test_set_quantity_confirm_with_different_barcode(self): expected_message = self.service.msg_store.transfer_done_success( move_line.picking_id ) - data = {"location": self._data_for_location(self.location)} self.assert_response( - response, next_state="select_product", message=expected_message, data=data + response, next_state="select_location_or_package", message=expected_message ) def test_set_quantity_child_move_location(self): @@ -610,9 +658,8 @@ def test_set_quantity_child_move_location(self): }, ) expected_message = self.msg_store.transfer_done_success(move_line.picking_id) - data = {"location": self._data_for_location(location)} self.assert_response( - response, next_state="select_product", message=expected_message, data=data + response, next_state="select_location_or_package", message=expected_message ) def test_action_cancel(self): @@ -688,12 +735,10 @@ def test_set_quantity_done_with_completion_info(self): expected_message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self.service._actions_for("completion.info") expected_popup = completion_info.popup(move_line) - data = {"location": self._data_for_location(location)} self.assert_response( response, - next_state="select_product", + next_state="select_location_or_package", message=expected_message, - data=data, popup=expected_popup, ) @@ -807,16 +852,12 @@ def test_set_quantity_scan_package_not_empty(self): "barcode": package.name, }, ) - expected_data = { - "location": self._data_for_location(self.location), - } expected_message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self.service._actions_for("completion.info") expected_popup = completion_info.popup(move_line) self.assert_response( response, - next_state="select_product", - data=expected_data, + next_state="select_location_or_package", message=expected_message, popup=expected_popup, ) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py index 53d81bbf755..ad9f4dd897e 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -80,14 +80,12 @@ def test_set_quantity_child_move_location(self): }, ) expected_message = self.msg_store.transfer_done_success(move_line.picking_id) - data = {"location": self._data_for_location(self.location)} completion_info = self.service._actions_for("completion.info") expected_popup = completion_info.popup(move_line) self.assert_response( response, - next_state="select_product", + next_state="select_location_or_package", message=expected_message, - data=data, popup=expected_popup, ) self.assertEqual(move1.move_line_ids.location_dest_id, self.dispatch_location) From 8d1aace9157e76ed0f83aabcbcb4f03476045ffb Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 12 Jun 2026 13:20:28 +0200 Subject: [PATCH 46/54] [IMP] shopfloor_single_product_transfer: Improve lot scan uniqueness on location Before this commit, when scanning a lot for a single product transfer, the system searched for the lot name without restricting it to the products available in the scanned location. This could lead to issues when there were multiple lots with the same name in different locations. With this commit, the search for the lot name is now restricted to the products available in the scanned location. This ensures that the correct lot is identified during the scanning process, even if there are duplicate lot names in different locations. (At least if products in the location doesn't share lot names, but at least it reduces the risk of picking the wrong lot) --- .../services/single_product_transfer.py | 17 +++++++++++++++-- .../tests/test_scan_product.py | 10 +++++++++- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 956003bb89d..4a9d38064f3 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -867,16 +867,29 @@ def scan_product(self, barcode, location_id=None, package_id=None): package = self.env["stock.quant.package"].browse(package_id) if not location.exists() and not package.exists(): return self._response_for_select_location_or_package() + products = ( + self.env["stock.quant"] + .search([("location_id", "=", location.id or package.location_id.id)]) + .product_id + ) handlers_by_type = { "product": self._scan_product__scan_product, "packaging": self._scan_product__scan_packaging, "lot": self._scan_product__scan_lot, } search = self._actions_for("search") - search_result = search.find(barcode, types=handlers_by_type.keys()) + search_result = search.find( + barcode, + types=handlers_by_type.keys(), + handler_kw={"lot": {"products": products}}, + ) handler = handlers_by_type.get(search_result.type) if handler: - return handler(search_result.record, location=location, package=package) + return handler( + search_result.record, + location=location, + package=package, + ) message = self.msg_store.barcode_not_found() return self._response_for_select_product( location=location, package=package, message=message diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index cbef3c25230..7765de02544 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -255,7 +255,7 @@ def test_scan_lot_no_move_line(self): self.assertIn(ROLLBACK_LOG, log_catcher.output) expected_message = { "message_type": "error", - "body": "No operation found for this menu and profile.", + "body": "Barcode not found", } data = {"location": self._data_for_location(location)} self.assert_response( @@ -263,6 +263,14 @@ def test_scan_lot_no_move_line(self): ) def test_scan_lot_with_move_line(self): + # create a duplicate lot to ensure that the search + # on lot name is restricted to the products of the location + self._set_product_tracking_by_lot(self.product_b) + duplicate_lot = self._create_lot_for_product(self.product_b, "LOT_BARCODE") + self._add_stock_to_product( + self.product_b, self.location_dest, 10, lot=duplicate_lot + ) + location = self.location_src product = self.product_a self._set_product_tracking_by_lot(product) From 1f481d6929ead2ac71ba7ee69b13b8a388897df0 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 17 Jun 2026 08:45:22 +0200 Subject: [PATCH 47/54] [FIX] shopfloor_single_product_transfer: Backport of Back to select_location_or_package --- shopfloor_single_product_transfer/tests/test_set_quantity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 4db6dfa98d9..4d498c0b2fb 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -571,7 +571,7 @@ def test_set_quantity_multi_operation_same_location(self): ) params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, } response = self.service.dispatch("set_quantity", params=params) @@ -594,7 +594,7 @@ def test_set_quantity_multi_operation_same_location(self): ) params = { "selected_line_id": move_line.id, - "quantity": move_line.qty_picked, + "quantity": move_line.qty_done, "barcode": self.dispatch_location.barcode, } response = self.service.dispatch("set_quantity", params=params) From 189fc73bacb9f6defc7c23096adb12016d469ed7 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Thu, 11 Jun 2026 15:49:42 +0200 Subject: [PATCH 48/54] [DO NOT MERGE] dev dependency --- test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test-requirements.txt b/test-requirements.txt index 689482e20df..90335c8a277 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,2 +1,3 @@ vcrpy-unittest odoo_test_helper +odoo-addon-shopfloor @ git+https://github.com/OCA/wms.git@refs/pull/1180/head#subdirectory=setup/shopfloor From bb36b9e041e0431aafbb348d63641fc9352f419c Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Wed, 17 Jun 2026 08:56:41 +0200 Subject: [PATCH 49/54] [IMP] shopfloor_single_product_transfer: Implement allow_get_work --- .../__manifest__.py | 2 +- .../data/shopfloor_scenario_data.xml | 3 + .../migrations/16.0.2.0.0/post-migrate.py | 36 +++++++++ .../readme/CONTRIBUTORS.md | 1 + .../services/single_product_transfer.py | 81 ++++++++++++++++--- .../tests/test_find_work.py | 62 ++++++++++++++ .../tests/test_scan_product.py | 5 ++ .../tests/test_set_quantity.py | 24 ++++++ .../tests/test_start.py | 15 +++- 9 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py create mode 100644 shopfloor_single_product_transfer/tests/test_find_work.py diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py index bba234fba1a..93d92b729f3 100644 --- a/shopfloor_single_product_transfer/__manifest__.py +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -1,7 +1,7 @@ { "name": "Shopfloor Single Product Transfer", "summary": "Move an item from one location to another.", - "version": "16.0.1.0.0", + "version": "16.0.2.0.0", "category": "Inventory", "website": "https://github.com/OCA/wms", "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", diff --git a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml index 01cfdd95856..a83b9d1e56f 100644 --- a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -8,6 +8,9 @@ { "allow_create_moves": true, + "allow_get_work": true, + "allow_move_line_search_sort_order": true, + "allow_move_line_search_additional_domain": true, "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true, "allow_alternative_destination": true, diff --git a/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py b/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py new file mode 100644 index 00000000000..6a2f52aed53 --- /dev/null +++ b/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py @@ -0,0 +1,36 @@ +# Copyright 2026 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + _logger.info("Updating scenario options for shopfloor_single_product_transfer") + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + single_product_transfer_scenario = env.ref( + "shopfloor_single_product_transfer.scenario_single_product_transfer" + ) + _update_scenario_options(single_product_transfer_scenario) + + +def _update_scenario_options(scenario): + options = scenario.options + if "allow_get_work" not in options: + options["allow_get_work"] = True + _logger.info("Option allow_get_work added to scenario %s", scenario.name) + if "allow_move_line_search_sort_order" not in options: + options["allow_move_line_search_sort_order"] = True + options["allow_move_line_search_additional_domain"] = True + _logger.info( + "Option allow_alternative_destination_package added to scenario %s", + scenario.name, + ) + options_edit = json.dumps(options or {}, indent=4, sort_keys=True) + scenario.write({"options_edit": options_edit}) diff --git a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md index 8375deed594..415333a0dfd 100644 --- a/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md +++ b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md @@ -1,5 +1,6 @@ - Matthieu Méquignon \<\> - Michael Tietz (MT Software) \<\> +- Laurent Mignon (ACSONE SA/NV) \<\> ## Design diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 4a9d38064f3..9a24791557b 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -68,7 +68,19 @@ class ShopfloorSingleProductTransfer(Component): _usage = "single_product_transfer" _description = __doc__ + _advisory_lock_find_work = "single_product_transfer_find_work" + # Responses + def _response_for_start(self, message=None, popup=None): + """Transition to the 'start' or 'get_work' state + + The switch to 'get_work' is done if the option is enabled on the scenario + """ + if self.work.menu.allow_get_work: + return self._response( + next_state="get_work", data={}, message=message, popup=popup + ) + return self._response_for_select_location_or_package(message=message) def _response_for_select_location_or_package(self, message=None, popup=None): return self._response( @@ -678,6 +690,10 @@ def _set_quantity__post_move(self, move_line, location, confirmation=None): message = self.msg_store.transfer_done_success(move_line.picking_id) completion_info = self._actions_for("completion.info") completion_info_popup = completion_info.popup(move_line) + if self.work.menu.allow_get_work: + return self._response_for_start( + message=message, popup=completion_info_popup + ) if ( not self.is_allow_move_create() and not self._has_pending_operations_at_same_location(move_line) @@ -813,14 +829,51 @@ def _scan_location_or_package__by_location(self, location): return response return self._response_for_select_product(location=location) - # Endpoints + def _recover_previous_session(self): + """When a user starts a transfer, then leaves the session and comes back later, + we want to be able to restore the previous session so they can continue where + they left off. + This method looks for any move line in progress for the user and returns the + corresponding response to restore the session. - def start(self): + :return: A response to restore the previous session, or False if no session to + recover + """ + + response = False move_line = self._find_user_move_line() if move_line: message = self.msg_store.recovered_previous_session() - return self._response_for_set_quantity(move_line, message=message) - return self._response_for_select_location_or_package() + response = self._response_for_set_quantity(move_line, message=message) + return response + + # Endpoints + + def start(self): + response = self._recover_previous_session() + return response or self._response_for_start() + + def find_work(self): + """Find the new location to work from, for a user. + + First recover any started pickings. + The find the first move line from the oldest transfer that can be worked on. + Mark all move lines on that location as picked. + And ask the user to confirm. + + Transitions: + * start: no work found + * scan_location: with the location to work form for confirmation + """ + response = self._recover_previous_session() + if response: + return response + self._actions_for("lock").advisory(self._advisory_lock_find_work) + move_lines = self.search_move_line.search_move_lines(match_user=True) + if not move_lines: + return self._response_for_start(message=self.msg_store.no_work_found()) + location = fields.first(move_lines).location_id + return self._response_for_select_product(location=location) def scan_location_or_package(self, barcode): """Scan a source location or a source package. @@ -896,7 +949,7 @@ def scan_product(self, barcode, location_id=None, package_id=None): ) def scan_product__action_cancel(self): - return self._response_for_select_location_or_package() + return self._response_for_start() def set_quantity(self, selected_line_id, barcode, quantity, confirmation=None): """Sets quantity done if a product is scanned, @@ -942,7 +995,7 @@ def set_quantity__action_cancel(self, selected_line_id): else: stock = self._actions_for("stock") stock.unmark_move_line_as_picked(move_line) - return self._response_for_select_location_or_package() + return self._response_for_start() def set_location(self, selected_line_id, package_id, barcode): """Sets the destination location @@ -971,6 +1024,9 @@ class ShopfloorSingleProductTransferValidator(Component): def start(self): return {} + def get_work(self): + return {} + def scan_location_or_package(self): return {"barcode": {"required": True, "type": "string"}} @@ -1018,6 +1074,7 @@ def _states(self): "select_product": self._schema_select_product, "set_quantity": self._schema_set_quantity, "set_location": self._schema_set_location, + "get_work": {}, } def start(self): @@ -1045,8 +1102,11 @@ def set_quantity__action_cancel(self): def set_location(self): return self._response_schema(next_states=self._set_location_next_states()) + def find_work(self): + return self._response_schema(next_states=self._find_work_next_states()) + def _start_next_states(self): - return {"select_location_or_package", "set_quantity"} + return {"select_location_or_package", "set_quantity", "get_work"} def _scan_location_next_states(self): return {"select_location_or_package", "select_product"} @@ -1055,17 +1115,20 @@ def _scan_product_next_states(self): return {"select_product", "set_quantity"} def _scan_product__action_cancel_next_states(self): - return {"select_location_or_package"} + return {"select_location_or_package", "get_work"} def _set_quantity_next_states(self): return {"set_quantity", "select_product", "set_location"} def _set_quantity__action_cancel_next_states(self): - return {"select_location_or_package"} + return {"select_location_or_package", "get_work"} def _set_location_next_states(self): return {"set_quantity", "select_product", "set_location"} + def _find_work_next_states(self): + return {"start_line", "get_work"} + @property def _schema_select_location_or_package(self): return {} diff --git a/shopfloor_single_product_transfer/tests/test_find_work.py b/shopfloor_single_product_transfer/tests/test_find_work.py new file mode 100644 index 00000000000..40c8ee66cfb --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_find_work.py @@ -0,0 +1,62 @@ +# Copyright 2026 ACSONE SA/NV (https://www.acsone.eu) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import CommonCase + + +class TestFindWork(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.menu.sudo().allow_get_work = True + cls.location_src_a = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Source A", + "location_id": cls.location_src.id, + } + ) + ) + cls.location_src_b = ( + cls.env["stock.location"] + .sudo() + .create( + { + "name": "Source B", + "location_id": cls.location_src.id, + } + ) + ) + cls.product = cls.product_a + cls._add_stock_to_product(cls.product_a, cls.location_src_a, 10) + cls._add_stock_to_product(cls.product_b, cls.location_src_b, 10) + cls.picking_1 = cls._create_picking(lines=[(cls.product_a, 10)]) + cls.picking_2 = cls._create_picking(lines=[(cls.product_b, 10)]) + + def test_find_work(self): + response = self.service.dispatch("find_work") + data = {"location": self._data_for_location(self.location_src_a)} + self.assert_response( + response, + next_state="select_product", + data=data, + ) + + # cancel select product to go back to find_work + response = self.service.dispatch("scan_product__action_cancel") + self.assert_response( + response, + next_state="get_work", + ) + + # cancel the first picking + self.picking_1.action_cancel() + response = self.service.dispatch("find_work") + data = {"location": self._data_for_location(self.location_src_b)} + self.assert_response( + response, + next_state="select_product", + data=data, + ) diff --git a/shopfloor_single_product_transfer/tests/test_scan_product.py b/shopfloor_single_product_transfer/tests/test_scan_product.py index 7765de02544..357cb45c87c 100644 --- a/shopfloor_single_product_transfer/tests/test_scan_product.py +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -523,6 +523,11 @@ def test_action_cancel(self): response = self.service.dispatch("scan_product__action_cancel") self.assert_response(response, next_state="select_location_or_package", data={}) + def test_action_cancel_with_get_work(self): + self.menu.sudo().allow_get_work = True + response = self.service.dispatch("scan_product__action_cancel") + self.assert_response(response, next_state="get_work", data={}) + def test_scan_product_packaging(self): location = self.location_src packaging = self.product_a_packaging diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 4d498c0b2fb..479286217f8 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -689,6 +689,30 @@ def test_action_cancel(self): # Ensure the picking is not cancelled if allow_move_create is not enabled self.assertTrue(move_line.picking_id.state == "assigned") + def test_action_cancel_with_get_work(self): + self.menu.sudo().allow_get_work = True + picking = self._setup_picking() + self.service.dispatch( + "scan_product", + params={ + "location_id": self.location.id, + "barcode": self.product.barcode, + }, + ) + move_line = picking.move_line_ids + move_line.qty_done = 10.0 + response = self.service.dispatch( + "set_quantity__action_cancel", + params={"selected_line_id": move_line.id}, + ) + data = {} + self.assert_response(response, next_state="get_work", data=data) + # Ensure qty_picked and user has been reset. + self.assertFalse(move_line.picking_id.user_id) + self.assertEqual(move_line.qty_done, 0.0) + # Ensure the picking is not cancelled if allow_move_create is not enabled + self.assertTrue(move_line.picking_id.state == "assigned") + def test_action_cancel_allow_move_create(self): # We perform the same actions as in test_action_cancel, # but with the allow_move_create option enabled diff --git a/shopfloor_single_product_transfer/tests/test_start.py b/shopfloor_single_product_transfer/tests/test_start.py index b3e33547da1..63b4b1469a7 100644 --- a/shopfloor_single_product_transfer/tests/test_start.py +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -9,7 +9,13 @@ def test_start(self): response = self.service.dispatch("start") self.assert_response(response, next_state="select_location_or_package", data={}) - def test_recover(self): + def test_start_with_work(self): + self.menu.sudo().allow_get_work = True + response = self.service.dispatch("start") + + self.assert_response(response, next_state="get_work") + + def _check_recover(self): product = self.product_a location = self.location_src self._add_stock_to_product(product, location, 10) @@ -30,3 +36,10 @@ def test_recover(self): self.assert_response( response, next_state="set_quantity", data=data, message=message ) + + def test_recover(self): + self._check_recover() + + def test_recover_with_work(self): + self.menu.sudo().allow_get_work = True + self._check_recover() From 06219bbff46b6e64413d32e216ef7c16303abbd5 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Thu, 18 Jun 2026 11:15:29 +0200 Subject: [PATCH 50/54] [IMP] shopfloor_single_product_transfer: Add confirmation step for selected line When validating the selected line, we currently support scanning the location, product lot and package. --- .../services/single_product_transfer.py | 98 +++++++++- .../tests/__init__.py | 1 + .../tests/test_find_work.py | 184 +++++++++++++++++- 3 files changed, 275 insertions(+), 8 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 9a24791557b..7e7dceb9350 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -82,6 +82,18 @@ def _response_for_start(self, message=None, popup=None): ) return self._response_for_select_location_or_package(message=message) + def _response_for_start_line(self, move_line, message=None): + """Transition to the 'start_line' state + + This is used to confirm the processing of a move line + by the user. The user will be requested to select the + product or the package to process the move line. + """ + data = { + "move_line": self.data.move_line(move_line), + } + return self._response(next_state="start_line", data=data, message=message) + def _response_for_select_location_or_package(self, message=None, popup=None): return self._response( next_state="select_location_or_package", message=message, popup=popup @@ -847,6 +859,38 @@ def _recover_previous_session(self): response = self._response_for_set_quantity(move_line, message=message) return response + def _scan_line__by_package(self, package, move_line): + if move_line.package_id == package: + return self._response_for_set_quantity(move_line) + + def _scan_line__by_product(self, product, move_line): + if product == move_line.product_id: + if product.tracking in ("lot", "serial"): + return self._response_for_start_line( + move_line, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + else: + return self._response_for_set_quantity(move_line) + + def _scan_line__by_packaging(self, packaging, move_line): + return self._scan_line__by_product(packaging.product_id, move_line) + + def _scan_line__by_lot(self, lot, move_line): + if lot == move_line.lot_id: + return self._response_for_set_quantity(move_line) + + def _scan_line__fallback(self, record, move_line): + # Nothing matches what is expected from the move line. + if record: + return self._response_for_start_line( + move_line, + message=self.msg_store.wrong_record(record), + ) + return self._response_for_start_line( + move_line, message=self.msg_store.barcode_not_found() + ) + # Endpoints def start(self): @@ -858,12 +902,13 @@ def find_work(self): First recover any started pickings. The find the first move line from the oldest transfer that can be worked on. - Mark all move lines on that location as picked. + Mark the first move lines as picked. And ask the user to confirm. Transitions: * start: no work found - * scan_location: with the location to work form for confirmation + * select_line: a move line has been found and marked as picked, + ask the user to confirm """ response = self._recover_previous_session() if response: @@ -872,8 +917,34 @@ def find_work(self): move_lines = self.search_move_line.search_move_lines(match_user=True) if not move_lines: return self._response_for_start(message=self.msg_store.no_work_found()) - location = fields.first(move_lines).location_id - return self._response_for_select_product(location=location) + move_line = fields.first(move_lines) + stock = self._actions_for("stock") + stock.mark_move_line_as_picked(move_line, quantity=0) + return self._response_for_start_line(move_line) + + def confirm_start_line(self, selected_line_id, barcode): + """Validate the selected line by scanning the location, product, lot + or package.""" + move_line = self.env["stock.move.line"].browse(selected_line_id) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + + search = self._actions_for("search") + handlers = { + "package": self._scan_line__by_package, + "product": self._scan_line__by_product, + "packaging": self._scan_line__by_packaging, + "lot": self._scan_line__by_lot, + "none": self._scan_line__fallback, + } + search_result = search.find( + barcode, + types=handlers.keys(), + handler_kw=dict(lot=dict(products=move_line.product_id)), + ) + handler = handlers.get(search_result.type, self._scan_line__fallback) + response = handler(search_result.record, move_line) + return response or self._scan_line__fallback(search_result.record, move_line) def scan_location_or_package(self, barcode): """Scan a source location or a source package. @@ -1060,6 +1131,12 @@ def set_location(self): "barcode": {"required": True, "type": "string"}, } + def confirm_start_line(self): + return { + "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + class ShopfloorSingleProductTransferValidatorResponse(Component): _inherit = "base.shopfloor.validator.response" @@ -1074,6 +1151,7 @@ def _states(self): "select_product": self._schema_select_product, "set_quantity": self._schema_set_quantity, "set_location": self._schema_set_location, + "start_line": self._schema_start_line, "get_work": {}, } @@ -1099,6 +1177,9 @@ def set_quantity__action_cancel(self): next_states=self._set_quantity__action_cancel_next_states() ) + def confirm_start_line(self): + return self._response_schema(next_states=self._confirm_start_line_next_states()) + def set_location(self): return self._response_schema(next_states=self._set_location_next_states()) @@ -1129,6 +1210,9 @@ def _set_location_next_states(self): def _find_work_next_states(self): return {"start_line", "get_work"} + def _confirm_start_line_next_states(self): + return {"start_line", "set_quantity", "get_work"} + @property def _schema_select_location_or_package(self): return {} @@ -1161,3 +1245,9 @@ def _schema_set_location(self): "move_line": {"type": "dict", "schema": self.schemas.move_line()}, "package": {"type": "dict", "schema": self.schemas.package()}, } + + @property + def _schema_start_line(self): + return { + "move_line": {"type": "dict", "schema": self.schemas.move_line()}, + } diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py index 7ec1261ca0e..eb7eb1e2371 100644 --- a/shopfloor_single_product_transfer/tests/__init__.py +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -1,3 +1,4 @@ +from . import test_find_work from . import test_start from . import test_scan_location_or_package from . import test_scan_product diff --git a/shopfloor_single_product_transfer/tests/test_find_work.py b/shopfloor_single_product_transfer/tests/test_find_work.py index 40c8ee66cfb..04230ddc2a1 100644 --- a/shopfloor_single_product_transfer/tests/test_find_work.py +++ b/shopfloor_single_product_transfer/tests/test_find_work.py @@ -1,6 +1,8 @@ # Copyright 2026 ACSONE SA/NV (https://www.acsone.eu) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +from odoo import fields + from .common import CommonCase @@ -37,10 +39,14 @@ def setUpClass(cls): def test_find_work(self): response = self.service.dispatch("find_work") - data = {"location": self._data_for_location(self.location_src_a)} + data = { + "move_line": self._data_for_move_line( + fields.first(self.picking_1.move_line_ids) + ) + } self.assert_response( response, - next_state="select_product", + next_state="start_line", data=data, ) @@ -54,9 +60,179 @@ def test_find_work(self): # cancel the first picking self.picking_1.action_cancel() response = self.service.dispatch("find_work") - data = {"location": self._data_for_location(self.location_src_b)} + data = { + "move_line": self._data_for_move_line( + fields.first(self.picking_2.move_line_ids) + ) + } + self.assert_response( + response, + next_state="start_line", + data=data, + ) + + def test_confirm_start_line_line_not_found(self): + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": 0, "barcode": "whatever"}, + ) + self.assert_response( + response, + next_state="get_work", + message=self.msg_store.record_not_found(), + ) + + def test_confirm_start_line_barcode_not_found(self): + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": "NOPE"}, + ) + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.barcode_not_found(), + ) + + def test_confirm_start_line_scan_product(self): + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_confirm_start_line_scan_wrong_product(self): + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_b.barcode, + }, + ) + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.wrong_record(self.product_b), + ) + + def test_confirm_start_line_scan_product_tracked_by_lot(self): + self._set_product_tracking_by_lot(self.product_a) + lot = self._create_lot_for_product(self.product_a, "LOT001") + self._add_stock_to_product(self.product_a, self.location_src, 5, lot=lot) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + }, + ) + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def test_confirm_start_line_scan_packaging(self): + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_confirm_start_line_scan_lot(self): + self._set_product_tracking_by_lot(self.product_a) + lot = self._create_lot_for_product(self.product_a, "LOT001") + self._add_stock_to_product(self.product_a, self.location_src, 5, lot=lot) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": lot.name}, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_confirm_start_line_scan_wrong_lot(self): + self._set_product_tracking_by_lot(self.product_a) + lot = self._create_lot_for_product(self.product_a, "LOT001") + wrong_lot = self._create_lot_for_product(self.product_a, "LOT_WRONG") + self._add_stock_to_product(self.product_a, self.location_src, 5, lot=lot) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": wrong_lot.name}, + ) + data = {"move_line": self._data_for_move_line(move_line)} + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.wrong_record(wrong_lot), + ) + + def test_confirm_start_line_scan_package(self): + package = self._create_empty_package("PKG001") + self._add_stock_to_product( + self.product_a, self.location_src_a, 5, package=package + ) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + self.assertEqual(move_line.package_id, package) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": package.name}, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_confirm_start_line_scan_wrong_package(self): + package = self._create_empty_package("PKG001") + wrong_package = self._create_empty_package("PKG_WRONG") + self._add_stock_to_product( + self.product_a, self.location_src_a, 5, package=package + ) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": wrong_package.name}, + ) + data = {"move_line": self._data_for_move_line(move_line)} self.assert_response( response, - next_state="select_product", + next_state="start_line", data=data, + message=self.msg_store.wrong_record(wrong_package), ) From 6370fc3c4fe087c4d0434d73176211f0fefe615d Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 19 Jun 2026 10:51:28 +0200 Subject: [PATCH 51/54] [IMP] shopfloor_single_product_transfer: Implement scan_location_or_pack_first option When the `scan_location_or_pack_first` option is enabled at same time as `get_work`, the user is required to scan either a location or a package before being able to scan a product. This commit implements the necessary logic to support this option in the single product transfer work. --- .../data/shopfloor_scenario_data.xml | 1 + .../migrations/16.0.2.0.0/post-migrate.py | 5 + .../services/single_product_transfer.py | 198 ++++++++- .../tests/test_find_work.py | 383 ++++++++++++++++-- 4 files changed, 530 insertions(+), 57 deletions(-) diff --git a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml index a83b9d1e56f..cbedce71405 100644 --- a/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -11,6 +11,7 @@ "allow_get_work": true, "allow_move_line_search_sort_order": true, "allow_move_line_search_additional_domain": true, + "scan_location_or_pack_first": true, "allow_unreserve_other_moves": true, "allow_ignore_no_putaway_available": true, "allow_alternative_destination": true, diff --git a/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py b/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py index 6a2f52aed53..5d9deb2d89d 100644 --- a/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py +++ b/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py @@ -32,5 +32,10 @@ def _update_scenario_options(scenario): "Option allow_alternative_destination_package added to scenario %s", scenario.name, ) + if "scan_location_or_pack_first" not in options: + options["scan_location_or_pack_first"] = True + _logger.info( + "Option scan_location_or_pack_first added to scenario %s", scenario.name + ) options_edit = json.dumps(options or {}, indent=4, sort_keys=True) scenario.write({"options_edit": options_edit}) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index 7e7dceb9350..e07b8f5e9c0 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -82,7 +82,13 @@ def _response_for_start(self, message=None, popup=None): ) return self._response_for_select_location_or_package(message=message) - def _response_for_start_line(self, move_line, message=None): + def _response_for_start_line( + self, + move_line, + message=None, + selected_location_id=None, + selected_package_id=None, + ): """Transition to the 'start_line' state This is used to confirm the processing of a move line @@ -91,6 +97,9 @@ def _response_for_start_line(self, move_line, message=None): """ data = { "move_line": self.data.move_line(move_line), + "selected_location_id": selected_location_id, + "selected_package_id": selected_package_id, + "scan_location_or_pack_first": self.work.menu.scan_location_or_pack_first, } return self._response(next_state="start_line", data=data, message=message) @@ -859,38 +868,161 @@ def _recover_previous_session(self): response = self._response_for_set_quantity(move_line, message=message) return response - def _scan_line__by_package(self, package, move_line): + def _scan_line_scan_loc__check_product_tracking( + self, + move_line, + selected_location_id=None, + selected_package_id=None, + ): + product = move_line.product_id + if product.tracking == "lot": + return self._response_for_start_line( + move_line, + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + + def _scan_line__by_location( + self, location, move_line, selected_location_id=None, selected_package_id=None + ): + if location == move_line.location_id: + message = self._check_first_scan_location_or_pack_first( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + scanned_location=location, + ) + if message: + return message + response = self._scan_line_scan_loc__check_product_tracking( + move_line, + selected_location_id=location.id, + selected_package_id=selected_package_id, + ) + if response: + return response + return self._response_for_set_quantity(move_line) + + def _scan_line__by_package( + self, package, move_line, selected_location_id=None, selected_package_id=None + ): if move_line.package_id == package: + message = self._check_first_scan_location_or_pack_first( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + scanned_package=package, + ) + if message: + return message return self._response_for_set_quantity(move_line) - def _scan_line__by_product(self, product, move_line): + def _scan_line__by_product( + self, product, move_line, selected_location_id=None, selected_package_id=None + ): if product == move_line.product_id: - if product.tracking in ("lot", "serial"): - return self._response_for_start_line( - move_line, - message=self.msg_store.scan_lot_on_product_tracked_by_lot(), - ) + message = self._check_first_scan_location_or_pack_first( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + if message: + return message + + response = self._scan_line_scan_loc__check_product_tracking( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + if response: + return response else: return self._response_for_set_quantity(move_line) - def _scan_line__by_packaging(self, packaging, move_line): - return self._scan_line__by_product(packaging.product_id, move_line) + def _scan_line__by_packaging( + self, packaging, move_line, selected_location_id=None, selected_package_id=None + ): + response = self._scan_line_scan_loc__check_product_tracking( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + if response: + return response + return self._scan_line__by_product( + packaging.product_id, + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) - def _scan_line__by_lot(self, lot, move_line): + def _scan_line__by_lot( + self, lot, move_line, selected_location_id=None, selected_package_id=None + ): if lot == move_line.lot_id: + message = self._check_first_scan_location_or_pack_first( + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + if message: + return message return self._response_for_set_quantity(move_line) - def _scan_line__fallback(self, record, move_line): + def _scan_line__fallback( + self, record, move_line, selected_location_id=None, selected_package_id=None + ): # Nothing matches what is expected from the move line. if record: return self._response_for_start_line( move_line, message=self.msg_store.wrong_record(record), + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, ) return self._response_for_start_line( - move_line, message=self.msg_store.barcode_not_found() + move_line, + message=self.msg_store.barcode_not_found(), + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, ) + def _check_first_scan_location_or_pack_first( + self, + move_line, + selected_location_id=None, + selected_package_id=None, + scanned_location=None, + scanned_package=None, + ): + """Restrict scanning product or lot first with option on. + + When the option first scan location or pack first is on. + When the line being worked on has a package, asked to scan the package first. + When the line as a lot ask to scan the location first. + """ + if not self.work.menu.scan_location_or_pack_first: + return None + message = None + if move_line.package_id: + if not selected_package_id and not scanned_package: + message = self.msg_store.line_has_package_scan_package() + elif not selected_location_id and not scanned_location: + message = self.msg_store.scan_the_location_first() + if message: + return self._response_for_start_line( + move_line, + message=message, + selected_location_id=selected_location_id or scanned_location.id + if scanned_location + else None, + selected_package_id=selected_package_id or scanned_package.id + if scanned_package + else None, + ) + return None + # Endpoints def start(self): @@ -922,7 +1054,13 @@ def find_work(self): stock.mark_move_line_as_picked(move_line, quantity=0) return self._response_for_start_line(move_line) - def confirm_start_line(self, selected_line_id, barcode): + def confirm_start_line( + self, + selected_line_id, + barcode, + selected_location_id=None, + selected_package_id=None, + ): """Validate the selected line by scanning the location, product, lot or package.""" move_line = self.env["stock.move.line"].browse(selected_line_id) @@ -931,6 +1069,7 @@ def confirm_start_line(self, selected_line_id, barcode): search = self._actions_for("search") handlers = { + "location": self._scan_line__by_location, "package": self._scan_line__by_package, "product": self._scan_line__by_product, "packaging": self._scan_line__by_packaging, @@ -943,8 +1082,18 @@ def confirm_start_line(self, selected_line_id, barcode): handler_kw=dict(lot=dict(products=move_line.product_id)), ) handler = handlers.get(search_result.type, self._scan_line__fallback) - response = handler(search_result.record, move_line) - return response or self._scan_line__fallback(search_result.record, move_line) + response = handler( + search_result.record, + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) + return response or self._scan_line__fallback( + search_result.record, + move_line, + selected_location_id=selected_location_id, + selected_package_id=selected_package_id, + ) def scan_location_or_package(self, barcode): """Scan a source location or a source package. @@ -1135,6 +1284,16 @@ def confirm_start_line(self): return { "selected_line_id": {"coerce": to_int, "required": True, "type": "integer"}, "barcode": {"required": True, "type": "string"}, + "selected_location_id": { + "coerce": to_int, + "required": False, + "type": "integer", + }, + "selected_package_id": { + "coerce": to_int, + "required": False, + "type": "integer", + }, } @@ -1250,4 +1409,11 @@ def _schema_set_location(self): def _schema_start_line(self): return { "move_line": {"type": "dict", "schema": self.schemas.move_line()}, + "selected_location_id": {"type": "integer", "nullable": True}, + "selected_package_id": {"type": "integer", "nullable": True}, + "scan_location_or_pack_first": { + "type": "boolean", + "nullable": False, + "required": False, + }, } diff --git a/shopfloor_single_product_transfer/tests/test_find_work.py b/shopfloor_single_product_transfer/tests/test_find_work.py index 04230ddc2a1..d5a243f8d92 100644 --- a/shopfloor_single_product_transfer/tests/test_find_work.py +++ b/shopfloor_single_product_transfer/tests/test_find_work.py @@ -37,13 +37,50 @@ def setUpClass(cls): cls.picking_1 = cls._create_picking(lines=[(cls.product_a, 10)]) cls.picking_2 = cls._create_picking(lines=[(cls.product_b, 10)]) + def _data_for_start_line( + self, move_line, selected_location_id=None, selected_package_id=None + ): + return { + "move_line": self._data_for_move_line(move_line), + "selected_location_id": selected_location_id, + "selected_package_id": selected_package_id, + "scan_location_or_pack_first": self.menu.scan_location_or_pack_first, + } + + def _setup_lot_move_line(self, location=None): + location = location or self.location_src + self._set_product_tracking_by_lot(self.product_a) + lot = self._create_lot_for_product(self.product_a, "LOT001") + self._add_stock_to_product(self.product_a, location, 5, lot=lot) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + return move_line, lot + + def _assert_start_line_lot_required( + self, response, move_line, selected_location_id=None + ): + self.assert_response( + response, + next_state="start_line", + data=self._data_for_start_line( + move_line, selected_location_id=selected_location_id + ), + message=self.msg_store.scan_lot_on_product_tracked_by_lot(), + ) + + def _assert_set_quantity(self, response, move_line): + self.assert_response( + response, + next_state="set_quantity", + data={ + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + }, + ) + def test_find_work(self): response = self.service.dispatch("find_work") - data = { - "move_line": self._data_for_move_line( - fields.first(self.picking_1.move_line_ids) - ) - } + data = self._data_for_start_line(fields.first(self.picking_1.move_line_ids)) self.assert_response( response, next_state="start_line", @@ -60,11 +97,7 @@ def test_find_work(self): # cancel the first picking self.picking_1.action_cancel() response = self.service.dispatch("find_work") - data = { - "move_line": self._data_for_move_line( - fields.first(self.picking_2.move_line_ids) - ) - } + data = self._data_for_start_line(fields.first(self.picking_2.move_line_ids)) self.assert_response( response, next_state="start_line", @@ -88,7 +121,7 @@ def test_confirm_start_line_barcode_not_found(self): "confirm_start_line", params={"selected_line_id": move_line.id, "barcode": "NOPE"}, ) - data = {"move_line": self._data_for_move_line(move_line)} + data = self._data_for_start_line(move_line) self.assert_response( response, next_state="start_line", @@ -105,11 +138,7 @@ def test_confirm_start_line_scan_product(self): "barcode": self.product_a.barcode, }, ) - data = { - "move_line": self._data_for_move_line(move_line), - "asking_confirmation": None, - } - self.assert_response(response, next_state="set_quantity", data=data) + self._assert_set_quantity(response, move_line) def test_confirm_start_line_scan_wrong_product(self): move_line = fields.first(self.picking_1.move_line_ids) @@ -120,7 +149,7 @@ def test_confirm_start_line_scan_wrong_product(self): "barcode": self.product_b.barcode, }, ) - data = {"move_line": self._data_for_move_line(move_line)} + data = self._data_for_start_line(move_line) self.assert_response( response, next_state="start_line", @@ -141,13 +170,7 @@ def test_confirm_start_line_scan_product_tracked_by_lot(self): "barcode": self.product_a.barcode, }, ) - data = {"move_line": self._data_for_move_line(move_line)} - self.assert_response( - response, - next_state="start_line", - data=data, - message=self.msg_store.scan_lot_on_product_tracked_by_lot(), - ) + self._assert_start_line_lot_required(response, move_line) def test_confirm_start_line_scan_packaging(self): move_line = fields.first(self.picking_1.move_line_ids) @@ -158,11 +181,7 @@ def test_confirm_start_line_scan_packaging(self): "barcode": self.product_a_packaging.barcode, }, ) - data = { - "move_line": self._data_for_move_line(move_line), - "asking_confirmation": None, - } - self.assert_response(response, next_state="set_quantity", data=data) + self._assert_set_quantity(response, move_line) def test_confirm_start_line_scan_lot(self): self._set_product_tracking_by_lot(self.product_a) @@ -174,11 +193,7 @@ def test_confirm_start_line_scan_lot(self): "confirm_start_line", params={"selected_line_id": move_line.id, "barcode": lot.name}, ) - data = { - "move_line": self._data_for_move_line(move_line), - "asking_confirmation": None, - } - self.assert_response(response, next_state="set_quantity", data=data) + self._assert_set_quantity(response, move_line) def test_confirm_start_line_scan_wrong_lot(self): self._set_product_tracking_by_lot(self.product_a) @@ -191,7 +206,7 @@ def test_confirm_start_line_scan_wrong_lot(self): "confirm_start_line", params={"selected_line_id": move_line.id, "barcode": wrong_lot.name}, ) - data = {"move_line": self._data_for_move_line(move_line)} + data = self._data_for_start_line(move_line) self.assert_response( response, next_state="start_line", @@ -211,11 +226,7 @@ def test_confirm_start_line_scan_package(self): "confirm_start_line", params={"selected_line_id": move_line.id, "barcode": package.name}, ) - data = { - "move_line": self._data_for_move_line(move_line), - "asking_confirmation": None, - } - self.assert_response(response, next_state="set_quantity", data=data) + self._assert_set_quantity(response, move_line) def test_confirm_start_line_scan_wrong_package(self): package = self._create_empty_package("PKG001") @@ -229,10 +240,300 @@ def test_confirm_start_line_scan_wrong_package(self): "confirm_start_line", params={"selected_line_id": move_line.id, "barcode": wrong_package.name}, ) - data = {"move_line": self._data_for_move_line(move_line)} + data = self._data_for_start_line(move_line) self.assert_response( response, next_state="start_line", data=data, message=self.msg_store.wrong_record(wrong_package), ) + + def _enable_scan_location_or_pack_first(self): + self.menu.sudo().scan_location_or_pack_first = True + + def _setup_packaged_move_line(self): + package = self._create_empty_package("PKG001") + self._add_stock_to_product( + self.product_a, self.child_location, 5, package=package + ) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + return move_line, package + + def test_confirm_start_line_slpf_scan_product_requires_location(self): + self._enable_scan_location_or_pack_first() + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + }, + ) + data = self._data_for_start_line(move_line) + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.scan_the_location_first(), + ) + + def test_confirm_start_line_scan_slpf_scan_location(self): + self._enable_scan_location_or_pack_first() + self._add_stock_to_product(self.product_a, self.child_location, 5) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + + def test_confirm_start_line_scan_slpf_lot_tracked_scan_location(self): + # With scan_location_or_pack_first, scanning the location on a + # lot-tracked line is sufficient to confirm: goes to set_quantity + # directly, bypassing the lot scan step. + self._enable_scan_location_or_pack_first() + self._set_product_tracking_by_lot(self.product_a) + lot = self._create_lot_for_product(self.product_a, "LOT001") + self._add_stock_to_product(self.product_a, self.child_location, 5, lot=lot) + picking = self._create_picking(lines=[(self.product_a, 5)]) + move_line = fields.first(picking.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + self._assert_start_line_lot_required( + response, move_line, selected_location_id=self.child_location.id + ) + + def test_confirm_start_line_scan_slpf_scan_product_with_location( + self, + ): + self._enable_scan_location_or_pack_first() + move_line = fields.first(self.picking_1.move_line_ids) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + "selected_location_id": move_line.location_id.id, + }, + ) + self._assert_set_quantity(response, move_line) + + def test_confirm_start_line_slpf_package_scan_product_requires_package(self): + self._enable_scan_location_or_pack_first() + move_line, _package = self._setup_packaged_move_line() + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + }, + ) + data = self._data_for_start_line(move_line) + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.line_has_package_scan_package(), + ) + + def test_confirm_start_line_slpf_package_scan_location_requires_package(self): + self._enable_scan_location_or_pack_first() + move_line, _package = self._setup_packaged_move_line() + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + data = self._data_for_start_line( + move_line, selected_location_id=self.child_location.id + ) + self.assert_response( + response, + next_state="start_line", + data=data, + message=self.msg_store.line_has_package_scan_package(), + ) + + def test_confirm_start_line_scan_slpf_package_scan_package( + self, + ): + self._enable_scan_location_or_pack_first() + move_line, package = self._setup_packaged_move_line() + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": package.name}, + ) + self._assert_set_quantity(response, move_line) + + # ------------------------------------------------------------------------- + # Lot tracking: all paths that require a lot must stay on start_line, + # and scanning the lot must reach set_quantity. + # ------------------------------------------------------------------------- + + # -- Without scan_location_or_pack_first -- + + def test_confirm_start_line_lot_tracked_scan_location_requires_lot(self): + move_line, _lot = self._setup_lot_move_line(self.child_location) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + self._assert_start_line_lot_required( + response, move_line, selected_location_id=self.child_location.id + ) + + def test_confirm_start_line_lot_tracked_scan_packaging_requires_lot(self): + move_line, _lot = self._setup_lot_move_line() + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self._assert_start_line_lot_required(response, move_line) + + def test_confirm_start_line_lot_tracked_scan_product_then_lot(self): + move_line, lot = self._setup_lot_move_line() + # Step 1: product scan -> lot required + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self._assert_start_line_lot_required(response, move_line) + # Step 2: lot scan -> set_quantity + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": lot.name}, + ) + self._assert_set_quantity(response, move_line) + + def test_confirm_start_line_lot_tracked_scan_location_then_lot(self): + move_line, lot = self._setup_lot_move_line(self.child_location) + # Step 1: location scan -> lot required + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + self._assert_start_line_lot_required( + response, move_line, selected_location_id=self.child_location.id + ) + # Step 2: lot scan -> set_quantity (no slpf, no location check needed) + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": lot.name}, + ) + self._assert_set_quantity(response, move_line) + + # -- With scan_location_or_pack_first -- + + def test_confirm_start_line_slpf_lot_tracked_scan_lot_no_location(self): + self._enable_scan_location_or_pack_first() + move_line, lot = self._setup_lot_move_line() + response = self.service.dispatch( + "confirm_start_line", + params={"selected_line_id": move_line.id, "barcode": lot.name}, + ) + self.assert_response( + response, + next_state="start_line", + data=self._data_for_start_line(move_line), + message=self.msg_store.scan_the_location_first(), + ) + + def test_confirm_start_line_slpf_lot_tracked_product_with_location_requires_lot( + self, + ): + self._enable_scan_location_or_pack_first() + move_line, _lot = self._setup_lot_move_line() + location_id = move_line.location_id.id + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + "selected_location_id": location_id, + }, + ) + # slpf check passes (location provided); lot still required + self._assert_start_line_lot_required( + response, move_line, selected_location_id=location_id + ) + + def test_confirm_start_line_slpf_lot_tracked_product_with_location_then_lot(self): + self._enable_scan_location_or_pack_first() + move_line, lot = self._setup_lot_move_line() + location_id = move_line.location_id.id + # Step 1: product + location -> lot required (location preserved in response) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a.barcode, + "selected_location_id": location_id, + }, + ) + self._assert_start_line_lot_required( + response, move_line, selected_location_id=location_id + ) + # Step 2: lot + location -> set_quantity + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": lot.name, + "selected_location_id": location_id, + }, + ) + self._assert_set_quantity(response, move_line) + + def test_confirm_start_line_slpf_lot_tracked_scan_location_then_lot(self): + self._enable_scan_location_or_pack_first() + move_line, lot = self._setup_lot_move_line(self.child_location) + # Step 1: location scan -> lot required + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.child_location.barcode, + }, + ) + self._assert_start_line_lot_required( + response, move_line, selected_location_id=self.child_location.id + ) + # Step 2: lot + location (frontend passes the confirmed location) + # -> set_quantity + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": lot.name, + "selected_location_id": self.child_location.id, + }, + ) + self._assert_set_quantity(response, move_line) From 52f7e27e9285b4a9574547d0d7759a9fcbd25bea Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 19 Jun 2026 13:19:42 +0200 Subject: [PATCH 52/54] [FIX] shopfloor_single_product_transfer: Back to get_work on move confirm --- .../services/single_product_transfer.py | 6 ++++- .../tests/test_set_quantity.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index e07b8f5e9c0..baa9db22871 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -722,6 +722,10 @@ def _set_quantity__post_move(self, move_line, location, confirmation=None): return self._response_for_select_location_or_package( message=message, popup=completion_info_popup ) + if self.work.menu.allow_get_work: + return self._response_for_start( + message=message, popup=completion_info_popup + ) return self._response_for_select_product( location=move_line.location_id, package=move_line.package_id, @@ -1358,7 +1362,7 @@ def _scan_product__action_cancel_next_states(self): return {"select_location_or_package", "get_work"} def _set_quantity_next_states(self): - return {"set_quantity", "select_product", "set_location"} + return {"set_quantity", "select_product", "set_location", "get_work"} def _set_quantity__action_cancel_next_states(self): return {"select_location_or_package", "get_work"} diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index 479286217f8..e2b653c694e 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -814,6 +814,33 @@ def test_set_quantity_scan_location(self): self.picking_type.default_location_src_id, ) + def test_set_quantity_scan_location_with_get_work(self): + self.menu.sudo().allow_get_work = True + picking = self._setup_picking() + location = self.location + self.service.dispatch( + "scan_product", + params={"location_id": location.id, "barcode": self.product.barcode}, + ) + move_line = picking.move_line_ids + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 6, + "barcode": self.dispatch_location.name, + }, + ) + completion_info = self.service._actions_for("completion.info") + expected_popup = completion_info.popup(move_line) + expected_message = self.msg_store.transfer_done_success(move_line.picking_id) + self.assert_response( + response, + next_state="get_work", + message=expected_message, + popup=expected_popup, + ) + def test_set_quantity_scan_location_allow_move_create(self): self.menu.sudo().allow_move_create = True picking = self._setup_picking() From 5c97e8d0e951e3e1a7fc75ee11b046fdd17455cb Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Fri, 19 Jun 2026 16:12:14 +0200 Subject: [PATCH 53/54] [FIX] shopfloor_single_product_transfer: Fix lot uniqueness on quantity set --- .../services/single_product_transfer.py | 6 +++- .../tests/test_set_quantity.py | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index baa9db22871..b4b509200fd 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -1203,7 +1203,11 @@ def set_quantity(self, selected_line_id, barcode, quantity, confirmation=None): "package": self._set_quantity__by_package, } search = self._actions_for("search") - search_result = search.find(barcode, types=handlers_by_type.keys()) + search_result = search.find( + barcode, + types=handlers_by_type.keys(), + handler_kw={"lot": {"products": move_line.product_id}}, + ) handler = handlers_by_type.get(search_result.type) if handler: confirmed = confirmation == barcode diff --git a/shopfloor_single_product_transfer/tests/test_set_quantity.py b/shopfloor_single_product_transfer/tests/test_set_quantity.py index e2b653c694e..f030ffc9418 100644 --- a/shopfloor_single_product_transfer/tests/test_set_quantity.py +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -357,6 +357,36 @@ def test_set_quantity_scan_lot_prefill_qty_enabled(self): response, next_state="set_quantity", message=expected_message, data=data ) + def test_set_quantity_scan_lot_not_unique(self): + """Even if the lot is not unique, we should be able to process the line by + scanning the lot, if the scanned lot is the one on the move line.""" + self._set_product_tracking_by_lot(self.product_b) + duplicate_lot = self._create_lot_for_product(self.product_b, "LOT_BARCODE") + self._add_stock_to_product(self.product_b, self.location, 10, lot=duplicate_lot) + # First, select a picking + self._set_product_tracking_by_lot(self.product) + lot = self._create_lot_for_product(self.product, duplicate_lot.name) + self._add_stock_to_product(self.product, self.location, 5, lot=lot) + picking = self._setup_picking(lot=lot) + move_line = picking.move_line_ids + self.service.dispatch( + "scan_product", + params={"location_id": self.location.id, "barcode": lot.name}, + ) + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": 1, + "barcode": lot.name, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + def test_set_quantity_scan_packaging(self): """Scan a packaging to process an existing line.""" # First, select a picking From 8f34dd54d42ab56a8cedf7d96fb455c9eed7bc32 Mon Sep 17 00:00:00 2001 From: "Laurent Mignon (ACSONE)" Date: Mon, 22 Jun 2026 11:31:15 +0200 Subject: [PATCH 54/54] [IMP] shopfloor_single_product_transfer: Implement ignore_no_putaway_available --- .../services/single_product_transfer.py | 35 ++++++++++-- .../tests/test_find_work.py | 54 +++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/shopfloor_single_product_transfer/services/single_product_transfer.py b/shopfloor_single_product_transfer/services/single_product_transfer.py index b4b509200fd..fdf0513d86b 100644 --- a/shopfloor_single_product_transfer/services/single_product_transfer.py +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -1027,6 +1027,29 @@ def _check_first_scan_location_or_pack_first( ) return None + def _try_select_move_line(self, move_line): + """Check if the move line can be worked on by the user. + + This is a method hookable to apply specific rules on which move lines can be + selected/skipped when looking for the next move line to work on. + + By default, it checks if the move line has no putaway available when the option + 'ignore_no_putaway_available' is enabled, and if so, it will skip the move line. + """ + if self.work.menu.ignore_no_putaway_available and self._actions_for( + "stock" + ).no_putaway_available(self.picking_types, move_line): + return None + return move_line + + def _get_next_move_line_to_work(self): + """Get the next move line to work on for the user.""" + move_lines = self.search_move_line.search_move_lines(match_user=True) + for line in move_lines: + if line := self._try_select_move_line(line): + return line + return None + # Endpoints def start(self): @@ -1043,6 +1066,7 @@ def find_work(self): Transitions: * start: no work found + * start: a move line has been found but no putaway location is available * select_line: a move line has been found and marked as picked, ask the user to confirm """ @@ -1050,11 +1074,16 @@ def find_work(self): if response: return response self._actions_for("lock").advisory(self._advisory_lock_find_work) - move_lines = self.search_move_line.search_move_lines(match_user=True) - if not move_lines: + move_line = self._get_next_move_line_to_work() + if not move_line: return self._response_for_start(message=self.msg_store.no_work_found()) - move_line = fields.first(move_lines) stock = self._actions_for("stock") + if ( + not self.work.menu.ignore_no_putaway_available + and stock.no_putaway_available(self.picking_types, move_line) + ): + message = self.msg_store.no_putaway_destination_available() + return self._response_for_start(message=message) stock.mark_move_line_as_picked(move_line, quantity=0) return self._response_for_start_line(move_line) diff --git a/shopfloor_single_product_transfer/tests/test_find_work.py b/shopfloor_single_product_transfer/tests/test_find_work.py index d5a243f8d92..a3b677d2c91 100644 --- a/shopfloor_single_product_transfer/tests/test_find_work.py +++ b/shopfloor_single_product_transfer/tests/test_find_work.py @@ -36,6 +36,11 @@ def setUpClass(cls): cls._add_stock_to_product(cls.product_b, cls.location_src_b, 10) cls.picking_1 = cls._create_picking(lines=[(cls.product_a, 10)]) cls.picking_2 = cls._create_picking(lines=[(cls.product_b, 10)]) + # Simulate putaway rules having run so that no_putaway_available returns + # False for the class-level pickings. Without this, find_work would + # return no_putaway_destination_available for every test. + cls.picking_1.move_line_ids.sudo().location_dest_id = cls.dispatch_location.id + cls.picking_2.move_line_ids.sudo().location_dest_id = cls.dispatch_location.id def _data_for_start_line( self, move_line, selected_location_id=None, selected_package_id=None @@ -537,3 +542,52 @@ def test_confirm_start_line_slpf_lot_tracked_scan_location_then_lot(self): }, ) self._assert_set_quantity(response, move_line) + + # ------------------------------------------------------------------------- + # ignore_no_putaway_available flag behaviour in find_work + # ------------------------------------------------------------------------- + + def test_find_work_no_putaway_destination(self): + # With ignore_no_putaway_available=False (default), find_work returns + # an error and stays at get_work when the candidate line has no + # putaway destination (location_dest_id == picking type default). + self.picking_1.action_cancel() + self.picking_2.action_cancel() + self._add_stock_to_product(self.product_a, self.location_src_a, 3) + self._create_picking(lines=[(self.product_a, 3)]) + response = self.service.dispatch("find_work") + self.assert_response( + response, + next_state="get_work", + message=self.msg_store.no_putaway_destination_available(), + ) + + def test_find_work_ignore_no_putaway_skips_to_next(self): + # With ignore_no_putaway_available=True, lines without a specific + # putaway destination are skipped; the next eligible line is returned. + self._enable_ignore_no_putaway_available() + default_dest = self.picking_1.picking_type_id.default_location_dest_id + self.picking_1.move_line_ids.sudo().location_dest_id = default_dest.id + # picking_2 still has dispatch_location as destination (set in setUpClass) + response = self.service.dispatch("find_work") + move_line = fields.first(self.picking_2.move_line_ids) + self.assert_response( + response, + next_state="start_line", + data=self._data_for_start_line(move_line), + ) + + def test_find_work_ignore_no_putaway_no_work_found(self): + # With ignore_no_putaway_available=True, if every candidate line has no + # putaway destination, find_work returns no_work_found. + self._enable_ignore_no_putaway_available() + self.picking_1.action_cancel() + self.picking_2.action_cancel() + self._add_stock_to_product(self.product_a, self.location_src_a, 3) + self._create_picking(lines=[(self.product_a, 3)]) + response = self.service.dispatch("find_work") + self.assert_response( + response, + next_state="get_work", + message=self.msg_store.no_work_found(), + )