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/README.rst b/shopfloor_single_product_transfer/README.rst new file mode 100644 index 00000000000..ad393c0e45e --- /dev/null +++ b/shopfloor_single_product_transfer/README.rst @@ -0,0 +1,129 @@ +.. 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:9137949c3bafa93853948ac4a237d3ee6118a0ba89df49484edea9bfd99c7235 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/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/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/wms&target_branch=16.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 +* BCIM + +Contributors +------------ + +- Matthieu Méquignon +- Michael Tietz (MT Software) + +Design +~~~~~~ + +- Jacques-Etienne Baudoux + +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 +.. |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 `maintainers `__: + +|maintainer-mmequignon| |maintainer-jbaudoux| |maintainer-TDu| + +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/__init__.py b/shopfloor_single_product_transfer/__init__.py new file mode 100644 index 00000000000..ae16eb245f6 --- /dev/null +++ b/shopfloor_single_product_transfer/__init__.py @@ -0,0 +1,2 @@ +from . import services +from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_single_product_transfer/__manifest__.py b/shopfloor_single_product_transfer/__manifest__.py new file mode 100644 index 00000000000..93d92b729f3 --- /dev/null +++ b/shopfloor_single_product_transfer/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Shopfloor Single Product Transfer", + "summary": "Move an item from one location to another.", + "version": "16.0.2.0.0", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, BCIM, Odoo Community Association (OCA)", + "maintainers": ["mmequignon", "jbaudoux", "TDu"], + "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", + ], + "post_init_hook": "post_init_hook", + "uninstall_hook": "uninstall_hook", +} 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..cbedce71405 --- /dev/null +++ b/shopfloor_single_product_transfer/data/shopfloor_scenario_data.xml @@ -0,0 +1,22 @@ + + + + + Single Product Transfer + single_product_transfer + +{ + "allow_create_moves": true, + "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, + "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..eb59f01ded9 --- /dev/null +++ b/shopfloor_single_product_transfer/demo/shopfloor_menu_demo.xml @@ -0,0 +1,18 @@ + + + + + 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..9ee87ded808 --- /dev/null +++ b/shopfloor_single_product_transfer/demo/stock_picking_type_demo.xml @@ -0,0 +1,20 @@ + + + + + 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 00000000000..bfe4d64b799 Binary files /dev/null and b/shopfloor_single_product_transfer/docs/diagram.png differ diff --git a/shopfloor_single_product_transfer/docs/oca_logo.png b/shopfloor_single_product_transfer/docs/oca_logo.png new file mode 100644 index 00000000000..84f216c2941 Binary files /dev/null and b/shopfloor_single_product_transfer/docs/oca_logo.png differ diff --git a/shopfloor_single_product_transfer/hooks.py b/shopfloor_single_product_transfer/hooks.py new file mode 100644 index 00000000000..a4bf5d0b697 --- /dev/null +++ b/shopfloor_single_product_transfer/hooks.py @@ -0,0 +1,24 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +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 + +_logger = logging.getLogger(__file__) + + +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(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/i18n/it.po b/shopfloor_single_product_transfer/i18n/it.po new file mode 100644 index 00000000000..09beb2f77e5 --- /dev/null +++ b/shopfloor_single_product_transfer/i18n/it.po @@ -0,0 +1,24 @@ +# 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" +"PO-Revision-Date: 2023-09-20 04:45+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.17\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 "Trasfermento prodotto singolo" 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..624483da7e4 --- /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 18.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/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..5d9deb2d89d --- /dev/null +++ b/shopfloor_single_product_transfer/migrations/16.0.2.0.0/post-migrate.py @@ -0,0 +1,41 @@ +# 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, + ) + 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/readme/CONTRIBUTORS.md b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..415333a0dfd --- /dev/null +++ b/shopfloor_single_product_transfer/readme/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +- Matthieu Méquignon \<\> +- Michael Tietz (MT Software) \<\> +- Laurent Mignon (ACSONE SA/NV) \<\> + +## Design + +- Jacques-Etienne Baudoux \<\> diff --git a/shopfloor_single_product_transfer/readme/DESCRIPTION.md b/shopfloor_single_product_transfer/readme/DESCRIPTION.md new file mode 100644 index 00000000000..52a1524024d --- /dev/null +++ b/shopfloor_single_product_transfer/readme/DESCRIPTION.md @@ -0,0 +1 @@ +Allow to move a single product from a location to another one. 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/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..fdf0513d86b --- /dev/null +++ b/shopfloor_single_product_transfer/services/single_product_transfer.py @@ -0,0 +1,1456 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging +from functools import wraps + +from odoo import fields +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() + # 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"): + _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. + + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.single.product.transfer" + _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_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 + 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), + "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) + + 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, + 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, + progress_lines_blacklist=progress_lines_blacklist, + ) + if package: + data["package"] = self.data.package( + package, + with_operation_progress_src=True, + progress_lines_blacklist=progress_lines_blacklist, + ) + 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=None + ): + data = { + "move_line": self.data.move_line(move_line), + "asking_confirmation": asking_confirmation, + } + 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__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): + """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): + """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): + """Check that there are quants without an assigned package.""" + 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): + """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), + ] + ) + 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.""" + 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_or_package(message=message) + + 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, + # 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, + ] + product = packaging.product_id + return self._use_handlers( + handlers, + product, + location=location, + package=package, + packaging=packaging, + ) + + def _scan_product__scan_product(self, product, location=None, package=None): + 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, + ] + 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 + ): + if product.tracking == "lot": + message = self.msg_store.scan_lot_on_product_tracked_by_lot() + return self._response_for_select_product( + location=location, package=package, message=message + ) + + def _scan_product__select_move_line_domain( + self, product, location=None, package=None, lot=None + ): + domain = [ + ("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), + ] + return self._add_location_package_lot_domain( + domain, location=location, package=package, lot=lot + ) + + def _scan_product__select_move_line( + self, product, location=None, package=None, lot=None, packaging=None + ): + 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: + # 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 + 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) + + 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 + ): + if not self.is_allow_move_create(): + message = self.msg_store.no_operation_found() + return self._response_for_select_product( + location=location, package=package, message=message + ) + + def _scan_product__unreserve_move_line( + self, product, location=None, package=None, lot=None, packaging=None + ): + unreserve = self._actions_for("stock.unreserve") + move_lines = self._find_location_or_package_move_lines( + product, location=location, package=package, lot=lot + ) + if self.work.menu.allow_unreserve_other_moves: + response = unreserve.check_unreserve(location, move_lines, product, lot) + if response: + return response + unreserve.unreserve_moves(move_lines, self.picking_types) + elif move_lines: + # This happens when unreserve disallowed, but goods are reserved + # for another operation + return self._scan_product__product_reserved_by_another_operation( + product, + fields.first(move_lines.picking_id), + location=location, + package=package, + lot=lot, + 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. + return self._scan_product__no_stock_available( + product, + location=location, + package=package, + lot=lot, + packaging=packaging, + ) + + 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, + lot_id=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 + ) + if is_product_available: + move = self._create_move_from_location( + 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) + if response: + return response + return self._response_for_set_quantity(move_line) + + def _scan_product__product_reserved_by_another_operation( + self, product, picking, location=None, package=None, lot=None, packaging=None + ): + message = self.msg_store.reserved_for_other_picking_type(picking) + return self._response_for_select_product( + location=location, package=package, message=message + ) + + def _scan_product__no_stock_available( + 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, package=package, 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( + 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): + 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, + ] + 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 + # with the response data attached + for handler in handlers: + response = handler(*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_or_package_move_lines_domain( + self, product, location=None, package=None, lot=None + ): + domain = [ + ("product_id", "=", product.id), + ("state", "in", ("assigned", "partially_available")), + ("picking_id.user_id", "in", (False, self.env.uid)), + ] + return self._add_location_package_lot_domain( + domain, location=location, package=package, lot=lot + ) + + # Copied from manual_product_transfer + 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_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, 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, + } + 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": picking.id, + "package_id": package.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 + 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] + 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 + # to avoid the split of the move line by 'mark_move_line_as_picked'. + stock.mark_move_line_as_picked(move_line, quantity=0) + stock.move_line_increment_qty_picked(move_line, packaging=packaging) + else: + stock.mark_move_line_as_picked(move_line) + return move + + 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 + if wrong_lot: + message = self.msg_store.wrong_record(lot) + if move_line.product_id != product: + message = self.msg_store.wrong_record(product) + if message: + return self._response_for_set_quantity(move_line, message=message) + + def _set_quantity__check_quantity_done( + self, move_line, location=None, package=None, confirmation=None + ): + 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.reserved_uom_qty) + 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_picked should have been prefilled + # with product_uom_qty in the select_product screen + 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( + 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 + 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__scan_product_handlers(self): + return ( + self._set_quantity__check_product_in_line, + self._set_quantity__increment_qty_picked, + ) + + def _set_quantity__by_product(self, move_line, product, confirmation=False): + handlers = self._set_quantity__scan_product_handlers() + return self._use_handlers(handlers, move_line, product) + + def _set_quantity__by_lot(self, move_line, lot, confirmation=False): + handlers = self._set_quantity__scan_product_handlers() + product = lot.product_id + return self._use_handlers(handlers, move_line, product, lot=lot) + + def _set_quantity__by_packaging(self, move_line, packaging, confirmation=False): + handlers = self._set_quantity__scan_product_handlers() + 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 + 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, move_line, location, package=None, 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 + 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 + ): + if confirmation: + # Confirmation is valid, return no response + pass + else: + # Ask for confirmation + orig_location = move_line.location_dest_id + message = self.msg_store.confirm_location_changed( + orig_location, location + ) + confirmation = location.barcode + 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=confirmation or None + ) + + def _write_destination_on_lines(self, lines, location, unload=False): + 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) + stock = self._actions_for("stock") + stock.set_destination_on_lines(lines, location) + 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 + # TODO qty done < product_qty: transfer_confirm_done + self._write_destination_on_lines(move_line, location) + 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) + 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) + ): + 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, + message=message, + popup=completion_info_popup, + ) + + def _post_move(self, move_line): + 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 + # 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. + new_move_line = move_line._split_partial_quantity() + 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.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), + ] + + 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_location_handlers(self): + return [ + self._set_quantity__check_location, + ] + + def _set_quantity__by_location( + self, move_line, location, package=None, 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 + # Do not remove the result_package_id + # when it was previously set by _set_quantity__by_package + # because _set_quantity__by_location will be then called + # with the scanned empty package + if not package: + move_line.result_package_id = False + handlers = self._set_quantity__by_location_handlers() + # At this point the result_package_id is already + # set by _set_quantity__by_package to scanned package + # or set to False by this method + # Because of this call the handlers without the package + # to ensure the move_line's result_package_id gets checked + response = self._use_handlers( + handlers, move_line, location, confirmation=confirmation + ) + if response: + return response + return self._set_quantity__post_move( + move_line, location, confirmation=confirmation + ) + + 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, + location, + package=package, + confirmation=confirmation, + ) + if response: + return response + move_line.result_package_id = package + return self._set_quantity__post_move( + move_line, location, confirmation=confirmation + ) + # Else, go to `set_location` screen + move_line.result_package_id = package + return self._response_for_set_location(move_line, 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, location=package.location_id + ) + + def _scan_location_or_package__by_location(self, location): + handlers = [ + self._scan_location__location_found, + self._scan_location__check_location, + self._scan_location__check_stock, + self._scan_location__check_stock_packages, + self._scan_location__check_line_packages, + ] + response = self._use_handlers(handlers, location) + if response: + return response + return self._response_for_select_product(location=location) + + 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. + + :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() + response = self._response_for_set_quantity(move_line, message=message) + return response + + 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, selected_location_id=None, selected_package_id=None + ): + if product == move_line.product_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 + + 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, 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, 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, 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(), + 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 + + 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): + 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 the first move lines as picked. + And ask the user to confirm. + + 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 + """ + response = self._recover_previous_session() + if response: + return response + self._actions_for("lock").advisory(self._advisory_lock_find_work) + 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()) + 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) + + 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) + if not move_line.exists(): + return self._response_for_start(message=self.msg_store.record_not_found()) + + 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, + "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, + 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. + + It is the starting point of this scenario. + + 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") + 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): + """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 + - a product packaging + - a lot + """ + location = self.env["stock.location"].browse(location_id) + 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(), + handler_kw={"lot": {"products": products}}, + ) + 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 + ) + + def scan_product__action_cancel(self): + return self._response_for_start() + + def set_quantity(self, selected_line_id, barcode, quantity, confirmation=None): + """Sets quantity done if a product 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(): + # TODO Should probably return to scan_product or scan_location? + return self._response_for_set_quantity(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) + move_line.qty_done = quantity + handlers_by_type = { + # Increment qty done if a product / lot / packaging is scanned + "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 + "location": self._set_quantity__by_location, + # Puts the product in a new or an existing pack + "package": self._set_quantity__by_package, + } + search = self._actions_for("search") + 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 + return handler(move_line, search_result.record, confirmation=confirmed) + 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): + move_line = self.env["stock.move.line"].browse(selected_line_id).exists() + 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_start() + + 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) + 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()) + package = self.env["stock.quant.package"].browse(package_id) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(move_line, search_result.record, package=package) + 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" + _name = "shopfloor.single.product.transfer.validator" + _usage = "single_product_transfer.validator" + + def start(self): + return {} + + def get_work(self): + return {} + + def scan_location_or_package(self): + return {"barcode": {"required": True, "type": "string"}} + + def scan_product(self): + return { + "location_id": {"coerce": to_int, "required": False, "type": "integer"}, + "package_id": {"coerce": to_int, "required": False, "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": "string", "nullable": True, "required": False}, + } + + def set_quantity__action_cancel(self): + return { + "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"}, + } + + 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", + }, + } + + +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_or_package" + + def _states(self): + return { + "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, + "start_line": self._schema_start_line, + "get_work": {}, + } + + def start(self): + return self._response_schema(next_states=self._start_next_states()) + + def scan_location_or_package(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 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()) + + 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", "get_work"} + + def _scan_location_next_states(self): + 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_or_package", "get_work"} + + def _set_quantity_next_states(self): + return {"set_quantity", "select_product", "set_location", "get_work"} + + def _set_quantity__action_cancel_next_states(self): + 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"} + + def _confirm_start_line_next_states(self): + return {"start_line", "set_quantity", "get_work"} + + @property + def _schema_select_location_or_package(self): + return {} + + @property + def _schema_select_product(self): + 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): + return { + "move_line": {"type": "dict", "schema": self.schemas.move_line()}, + "asking_confirmation": {"type": "string", "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()}, + } + + @property + 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/static/description/icon.png b/shopfloor_single_product_transfer/static/description/icon.png new file mode 100644 index 00000000000..3a0328b516c Binary files /dev/null and b/shopfloor_single_product_transfer/static/description/icon.png differ 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..f380a061344 --- /dev/null +++ b/shopfloor_single_product_transfer/static/description/index.html @@ -0,0 +1,369 @@ + + + + + +README.rst + + + +
+ + + +
+ + diff --git a/shopfloor_single_product_transfer/tests/__init__.py b/shopfloor_single_product_transfer/tests/__init__.py new file mode 100644 index 00000000000..eb7eb1e2371 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/__init__.py @@ -0,0 +1,7 @@ +from . import test_find_work +from . import test_start +from . import test_scan_location_or_package +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/common.py b/shopfloor_single_product_transfer/tests/common.py new file mode 100644 index 00000000000..57de4286466 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/common.py @@ -0,0 +1,152 @@ +# 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 + + +# pylint: disable=missing-return +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, package=None): + """Set the stock quantity of the product.""" + cls._update_qty_in_location(location, product, qty, lot=lot, package=package) + # 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.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, with_operation_progress=True) + + def _data_for_move_line(self, move_line): + return self.data.move_line(move_line) + + def _data_for_package(self, package, with_operation_progress_src=False): + if with_operation_progress_src: + return self.data.package( + package, with_operation_progress_src=with_operation_progress_src + ) + return self.data.package(package) + + @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)]) + + @classmethod + def _create_empty_package(cls, name=None): + name = name or "test-package" + return cls.env["stock.quant.package"].sudo().create({"name": name}) 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..a3b677d2c91 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_find_work.py @@ -0,0 +1,593 @@ +# 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 + + +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)]) + # 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 + ): + 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 = self._data_for_start_line(fields.first(self.picking_1.move_line_ids)) + self.assert_response( + response, + next_state="start_line", + 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 = self._data_for_start_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 = self._data_for_start_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, + }, + ) + 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) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_b.barcode, + }, + ) + data = self._data_for_start_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, + }, + ) + 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) + response = self.service.dispatch( + "confirm_start_line", + params={ + "selected_line_id": move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self._assert_set_quantity(response, move_line) + + 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}, + ) + 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) + 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 = self._data_for_start_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}, + ) + self._assert_set_quantity(response, move_line) + + 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 = 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) + + # ------------------------------------------------------------------------- + # 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(), + ) 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..5af0364d4ff --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_scan_location_or_package.py @@ -0,0 +1,127 @@ +# 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): + self._enable_create_move_line() + 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_stock_packages(self): + location = self.location_src + package = self._create_empty_package() + 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._create_empty_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( + "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, + ) + + # Scan a package. + response = self.service.dispatch( + "scan_location_or_package", params={"barcode": package.name} + ) + self.assert_response( + response, + next_state="select_product", + data={ + "location": self._data_for_location(package.location_id), + "package": self._data_for_package( + package, with_operation_progress_src=True + ), + }, + ) 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..357cb45c87c --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_scan_product.py @@ -0,0 +1,547 @@ +# 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 line: line.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) + 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. + # 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": None, + } + 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.reserved_uom_qty, 10.0) + 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_scan_product_no_stock(self): + 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) + 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_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 + other_picking = 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": f"Reserved for {self.other_picking_type.name} {other_picking.name}", + } + 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.reserved_uom_qty, 10.0) + 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_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": "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_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) + 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": None, + } + 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": None, + } + 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 + other_picking = 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": f"Reserved for {self.other_picking_type.name} {other_picking.name}", + } + 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.reserved_uom_qty, 10.0) + 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_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": None, + } + 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": None, + } + 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.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 + 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.reserved_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.reserved_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.reserved_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_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 + 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": None, + } + self.assert_response(response, next_state="set_quantity", data=data) 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..8292de62317 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_location.py @@ -0,0 +1,72 @@ +# 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._create_empty_package() + picking = self._setup_picking() + move_line = picking.move_line_ids + # _set_quantity__by_package sets the result_package_id + # ensure that the package is still set after set_location + move_line.result_package_id = package + location = self.dispatch_location + response = self.service.dispatch( + "set_location", + params={ + "selected_line_id": move_line.id, + "package_id": package.id, + "barcode": location.name, + }, + ) + self.assertEqual(move_line.result_package_id, package) + 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_location_or_package", + message=expected_message, + popup=expected_popup, + ) + + def test_set_location_barcode_not_found(self): + package = self._create_empty_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 new file mode 100644 index 00000000000..f030ffc9418 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_quantity.py @@ -0,0 +1,1025 @@ +# 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.env.ref("stock.stock_location_components") + cls.product = cls.product_a + cls.packaging = cls.product_a_packaging + cls.packaging.qty = 5 + + @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)]) + + @classmethod + def _setup_chained_picking(cls, picking): + next_moves = picking.move_ids.browse() + for move in picking.move_ids: + 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() + 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.reserved_uom_qty, + "barcode": "NOPE", + }, + ) + expected_message = {"message_type": "error", "body": "Barcode not found"} + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response( + 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.reserved_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.reserved_uom_qty, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + 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() + 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.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_done, + "barcode": self.product.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + # 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, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + expected_message = { + "message_type": "error", + "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 + ) + + 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) + # 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": 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_picked > quantity 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": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + # 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, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + expected_message = { + "message_type": "error", + "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 + ) + + 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_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) + 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_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_picked 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.reserved_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": None, + } + self.assert_response(response, next_state="set_quantity", data=data) + # However, we shouldn't be able to confirm (scan a location) + # 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, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + 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) + # 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, + "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.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_done, + "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) + # However, we shouldn't be able to confirm (scan a location) + # 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, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + 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_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 + 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.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.reserved_uom_qty, + "barcode": self.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.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_done, + "barcode": self.location.barcode, + }, + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + 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.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_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 + ) + 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, + ): + """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": None, + } + 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.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_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.customer_location + response = self.service.dispatch( + "set_quantity", + params={ + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": wrong_location.barcode, + }, + ) + expected_message = {"message_type": "error", "body": "You cannot place it here"} + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + 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.barcode, + } + 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": self.dispatch_location.barcode, + } + self.assert_response( + response, next_state="set_quantity", message=expected_message, data=data + ) + # Now, calling the same endpoint with the confirmation set is ok + params["confirmation"] = 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_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_done, + "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_done, + "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 + # 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") + params = { + "selected_line_id": move_line.id, + "quantity": move_line.qty_done, + "barcode": self.dispatch_location.barcode, + } + # Setting the confirmation to another location barcode + params["confirmation"] = self.dispatch_location.barcode + "DIFF" + response = self.service.dispatch("set_quantity", params=params) + # Confirmation is asked again for new location scanned + message = self.service.msg_store.confirm_location_changed( + move_line.location_dest_id, self.dispatch_location + ) + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": self.dispatch_location.barcode, + } + self.assert_response( + response, next_state="set_quantity", message=message, data=data + ) + # Confirming the location with the same location + params["confirmation"] = 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_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) + self.assert_response( + response, next_state="select_location_or_package", message=expected_message + ) + + 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_or_package", 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_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 + 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" + 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) + self.assert_response( + response, + next_state="select_location_or_package", + message=expected_message, + 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(backorder.user_id, self.env.user) + self.assertEqual(backorder.move_line_ids.shopfloor_user_id, self.env.user) + 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) + 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_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() + 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") + 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 + # and its location is selected. + package = self._create_empty_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_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_location_or_package", + message=expected_message, + popup=expected_popup, + ) + # We moved 10 units to an existing package + self.assertEqual(package.quant_ids.quantity, 20.0) + 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._create_empty_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) + + 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_done, 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) 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..ad9f4dd897e --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_set_quantity_checkout_sync.py @@ -0,0 +1,93 @@ +# 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_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() + # 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.reserved_uom_qty, + "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) + self.assert_response( + response, + next_state="select_location_or_package", + message=expected_message, + 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) 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..63b4b1469a7 --- /dev/null +++ b/shopfloor_single_product_transfer/tests/test_start.py @@ -0,0 +1,45 @@ +# 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_or_package", data={}) + + 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) + 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.reserved_uom_qty + response = self.service.dispatch("start") + data = { + "move_line": self._data_for_move_line(move_line), + "asking_confirmation": None, + } + message = { + "message_type": "info", + "body": "Recovered previous session.", + } + 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() 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