diff --git a/setup/shopfloor_reception/odoo/addons/shopfloor_reception b/setup/shopfloor_reception/odoo/addons/shopfloor_reception new file mode 120000 index 0000000000..6fbfe38b04 --- /dev/null +++ b/setup/shopfloor_reception/odoo/addons/shopfloor_reception @@ -0,0 +1 @@ +../../../../shopfloor_reception \ No newline at end of file diff --git a/setup/shopfloor_reception/setup.py b/setup/shopfloor_reception/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopfloor_reception/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_reception/README.rst b/shopfloor_reception/README.rst new file mode 100644 index 0000000000..e1b5c54486 --- /dev/null +++ b/shopfloor_reception/README.rst @@ -0,0 +1,97 @@ +=================== +Shopfloor Reception +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2daa340b0e627a464975665796f422f9792c329d73389a28e995a4a0c11234a2 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/16.0/shopfloor_reception + :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_reception + :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| + +Shopfloor implementation of the reception scenario. +Allows to receive products and create the proper packs for each logistic unit. + +**Table of contents** + +.. contents:: + :local: + +Known issues / Roadmap +====================== + +Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity). + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp + +Contributors +~~~~~~~~~~~~ + +* Matthieu Méquignon +* Juan Miguel Sánchez Arce +* Jacques-Etienne Baudoux (BCIM) +* Michael Tietz (MT Software) +* Souheil Bejaoui + +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-JuMiSanAr| image:: https://github.com/JuMiSanAr.png?size=40px + :target: https://github.com/JuMiSanAr + :alt: JuMiSanAr + +Current `maintainers `__: + +|maintainer-mmequignon| |maintainer-JuMiSanAr| + +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_reception/__init__.py b/shopfloor_reception/__init__.py new file mode 100644 index 0000000000..2d9836a742 --- /dev/null +++ b/shopfloor_reception/__init__.py @@ -0,0 +1,3 @@ +from . import services +from . import models +from .hooks import post_init_hook, uninstall_hook diff --git a/shopfloor_reception/__manifest__.py b/shopfloor_reception/__manifest__.py new file mode 100644 index 0000000000..afbe051d4f --- /dev/null +++ b/shopfloor_reception/__manifest__.py @@ -0,0 +1,22 @@ +{ + "name": "Shopfloor Reception", + "summary": "Reception scenario for shopfloor", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["mmequignon", "JuMiSanAr"], + "license": "AGPL-3", + "installable": True, + "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_reception/data/shopfloor_scenario_data.xml b/shopfloor_reception/data/shopfloor_scenario_data.xml new file mode 100644 index 0000000000..67b04aa823 --- /dev/null +++ b/shopfloor_reception/data/shopfloor_scenario_data.xml @@ -0,0 +1,18 @@ + + + + + + Reception + reception + +{ + "auto_post_line": true, + "allow_return": true, + "scan_location_or_pack_first": true +} + + + + diff --git a/shopfloor_reception/demo/shopfloor_menu_demo.xml b/shopfloor_reception/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..17022444fb --- /dev/null +++ b/shopfloor_reception/demo/shopfloor_menu_demo.xml @@ -0,0 +1,18 @@ + + + + + + Reception + 45 + + + + + + + diff --git a/shopfloor_reception/demo/stock_picking_type_demo.xml b/shopfloor_reception/demo/stock_picking_type_demo.xml new file mode 100644 index 0000000000..0279e0806f --- /dev/null +++ b/shopfloor_reception/demo/stock_picking_type_demo.xml @@ -0,0 +1,23 @@ + + + + + + Reception + RCP + + + + + + + + + internal + + + + + + diff --git a/shopfloor_reception/docs/reception_sequence_graph.mermaid b/shopfloor_reception/docs/reception_sequence_graph.mermaid new file mode 100644 index 0000000000..097559c87c --- /dev/null +++ b/shopfloor_reception/docs/reception_sequence_graph.mermaid @@ -0,0 +1,61 @@ +%%{init: {'theme': 'neutral' } }%% +sequenceDiagram + participant select_document + participant select_move + participant set_lot + participant set_quantity + participant set_destination + participant select_dest_package + rect rgb(0, 250, 250) + note left of select_document: scan_document(barcode) + select_document ->> select_document: Error: barcode not found + select_document ->> select_document: Multiple picking matching the product / packaging barcode + select_document ->> select_move: Picking scanned, one has been found + select_document ->> set_lot: Packaging / Product has been scanned, single correspondance. Tracked product + select_document ->> set_quantity: Packaging / Product has been scanned, single correspondance. Not tracked product + end + rect rgb(100, 250, 170) + note left of select_move: scan_line(picking_id, barcode) + select_move ->> select_move: Error: barcode not found + select_move ->> set_lot: Packaging / Product has been scanned, single correspondance. Tracked product + select_move ->> set_quantity: Packaging / Product has been scanned, single correspondance. Not tracked product + end + rect rgb(250, 220, 200) + note left of set_lot: set_lot(picking_id, select_line_ids, lot_name=None, expiration_date=None) + set_lot ->> select_move: User clicked on back + set_lot ->> set_lot: Barcode not found. Ask user to create one from barcode + set_lot ->> set_lot: expiration_date has been set on the selected line + set_lot ->> set_lot: lot_it has been set on the selected line + set_lot ->> set_lot: Error: expiration_date is required + note right of set_lot: set_lot_confirm_action(picking_id, select_line_ids) + set_lot ->> set_quantity: User clicked on the confirm button + end + rect rgb(250, 150, 250) + note left of set_quantity: set_quantity(picking_id, select_line_ids, quantity=None, barcode=None, confirmation=None) + set_quantity ->> set_quantity: Quantity has been set + set_quantity ->> set_quantity: User scanned a product / packaging, quantity has been incremented + set_quantity ->> set_quantity: Error: User tried to scan a package with a non valid location + set_quantity ->> set_quantity: Error: User tried to scan a non valid location + set_quantity ->> set_quantity: Warning: User scanned an unknown barcode. Ask to create a package + set_quantity ->> select_move: User scanned a package with a valid location + set_quantity ->> select_move: User scanned a valid location + set_quantity ->> set_destination: User scanner a package with no location + note right of set_quantity: process_with_new_pack(picking_id, select_line_ids) + set_quantity ->> set_destination: User confirmed the creation of a new package + note right of set_quantity: process_with_existing_pack(picking_id, select_line_ids) + set_quantity ->> select_dest_package: User asked to use an existing package + note right of set_quantity: process_with_new_pack(picking_id, select_line_ids) + set_quantity ->> set_destination: User clicked on "process with new pack" + end + rect rgb(220, 220, 220) + note left of set_destination: set_destination(picking_id, selected_line_ids, location_id, confirmation=False) + set_destination ->> set_destination: Warning: User scanned a child location of the picking type. Ask for confirmation + set_destination ->> set_destination: Error: User tried to scan a non-valid location + set_destination ->> select_move: User scanned a child location of the move's dest location + end + rect rgb(250, 150, 150) + note left of select_dest_package: select_dest_package(picking_id, selected_line_ids, location_id, confirmation=False) + select_dest_package ->> select_move: User scanned a valid package + select_dest_package ->> select_dest_package: Warning: User scanned an unknown barcode. Confirm to create one. + select_dest_package ->> select_dest_package: Error: User scanned a non-empty package + end diff --git a/shopfloor_reception/docs/reception_sequence_graph.png b/shopfloor_reception/docs/reception_sequence_graph.png new file mode 100644 index 0000000000..ba77d46fa5 Binary files /dev/null and b/shopfloor_reception/docs/reception_sequence_graph.png differ diff --git a/shopfloor_reception/hooks.py b/shopfloor_reception/hooks.py new file mode 100644 index 0000000000..5bc64b4f28 --- /dev/null +++ b/shopfloor_reception/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.reception import Reception as Service + +_logger = logging.getLogger(__file__) + + +def post_init_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Register routes for %s", Service._usage) + register_new_services(env, Service) + + +def uninstall_hook(cr, registry): + env = api.Environment(cr, SUPERUSER_ID, {}) + _logger.info("Refreshing routes for existing apps") + purge_endpoints(env, Service._usage) diff --git a/shopfloor_reception/i18n/it.po b/shopfloor_reception/i18n/it.po new file mode 100644 index 0000000000..244d249d1c --- /dev/null +++ b/shopfloor_reception/i18n/it.po @@ -0,0 +1,49 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_reception +# +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_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__is_shopfloor_created +msgid "Is Shopfloor Created" +msgstr "Il reparto è crato" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: shopfloor_reception +#: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception +#: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception +#: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo +msgid "Reception" +msgstr "Ricezione" + +#. module: shopfloor_reception +#: model:ir.model,name:shopfloor_reception.model_stock_picking +msgid "Transfer" +msgstr "Trasferimento" diff --git a/shopfloor_reception/i18n/shopfloor_reception.pot b/shopfloor_reception/i18n/shopfloor_reception.pot new file mode 100644 index 0000000000..8da30e74a9 --- /dev/null +++ b/shopfloor_reception/i18n/shopfloor_reception.pot @@ -0,0 +1,46 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_reception +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__id +msgid "ID" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking__is_shopfloor_created +msgid "Is Shopfloor Created" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model.fields,field_description:shopfloor_reception.field_stock_picking____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_reception +#: model:shopfloor.menu,name:shopfloor_reception.shopfloor_menu_demo_reception +#: model:shopfloor.scenario,name:shopfloor_reception.scenario_reception +#: model:stock.picking.type,name:shopfloor_reception.picking_type_reception_demo +msgid "Reception" +msgstr "" + +#. module: shopfloor_reception +#: model:ir.model,name:shopfloor_reception.model_stock_picking +msgid "Transfer" +msgstr "" diff --git a/shopfloor_reception/models/__init__.py b/shopfloor_reception/models/__init__.py new file mode 100644 index 0000000000..ae4c27227f --- /dev/null +++ b/shopfloor_reception/models/__init__.py @@ -0,0 +1 @@ +from . import stock_picking diff --git a/shopfloor_reception/models/stock_picking.py b/shopfloor_reception/models/stock_picking.py new file mode 100644 index 0000000000..bd69e105a0 --- /dev/null +++ b/shopfloor_reception/models/stock_picking.py @@ -0,0 +1,10 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class StockPicking(models.Model): + _inherit = "stock.picking" + + is_shopfloor_created = fields.Boolean() diff --git a/shopfloor_reception/readme/CONTRIBUTORS.rst b/shopfloor_reception/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..331a33b7cc --- /dev/null +++ b/shopfloor_reception/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +* Matthieu Méquignon +* Juan Miguel Sánchez Arce +* Jacques-Etienne Baudoux (BCIM) +* Michael Tietz (MT Software) +* Souheil Bejaoui \ No newline at end of file diff --git a/shopfloor_reception/readme/DESCRIPTION.rst b/shopfloor_reception/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..15ab64dec3 --- /dev/null +++ b/shopfloor_reception/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +Shopfloor implementation of the reception scenario. +Allows to receive products and create the proper packs for each logistic unit. diff --git a/shopfloor_reception/readme/ROADMAP.rst b/shopfloor_reception/readme/ROADMAP.rst new file mode 100644 index 0000000000..e1c96af631 --- /dev/null +++ b/shopfloor_reception/readme/ROADMAP.rst @@ -0,0 +1 @@ +Implement methods in the backend to cancel lines (to be used by the frontend in select_line & set_quantity). diff --git a/shopfloor_reception/services/__init__.py b/shopfloor_reception/services/__init__.py new file mode 100644 index 0000000000..aa19bba8ce --- /dev/null +++ b/shopfloor_reception/services/__init__.py @@ -0,0 +1 @@ +from . import reception diff --git a/shopfloor_reception/services/reception.py b/shopfloor_reception/services/reception.py new file mode 100644 index 0000000000..212711d5e0 --- /dev/null +++ b/shopfloor_reception/services/reception.py @@ -0,0 +1,1827 @@ +# Copyright 2022 Camptocamp SA +# Copyright 2023 Jacques-Etienne Baudoux (BCIM) +# Copyright 2023 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +import pytz + +from odoo import fields +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.shopfloor.utils import to_float + + +class Reception(Component): + """ + Methods for the Reception Process + + You can find a sequence diagram describing states and endpoints relationships + [here](../docs/reception_sequence_graph.png) + Keep [the sequence diagram](../docs/reception_sequence_graph.mermaid) + up-to-date if you change endpoints. + + Process a receipt transfer and track progress by product. + + Once a transfer is selected, you need to: + 1. Select a product (you can scan its barcode or one of its packaging barcodes). + 2. Set the processed quantity. + 3. Put it in an internal PACK (this is optional but can be made mandatory by menu + configuration). this PACK can be a new one (like an empty pallet) or an existing + one you add products to (like a pallet you continue to fill in). + 4. Set the location where you put the product (iow. the location where + is the transport trolley or pallet), unless you fill an existing PACK as its + location was already defined when its first product was put on it. + + In case of product tracked by lot, you will have to enter the lot number and its + expiry date (unless it is already known by the system). + + Moves are not validated as they are processed. It is the responsibility of the + user to decide when to mark as done already processed lines. + Any remaining lines will be pushed to a backorder. + """ + + _inherit = "base.shopfloor.process" + _name = "shopfloor.reception" + _usage = "reception" + _description = __doc__ + + def _check_picking_status(self, pickings): + # When returns are allowed, + # the created picking might be empty and cannot be assigned. + states = ["assigned"] + if self.work.menu.allow_return: + states.append("draft") + return super()._check_picking_status(pickings, states=states) + + def _move_line_by_product(self, product): + return self.env["stock.move.line"].search( + self._domain_move_line_by_product(product) + ) + + def _move_line_by_packaging(self, packaging): + return self.env["stock.move.line"].search( + self._domain_move_line_by_packaging(packaging) + ) + + def _move_line_by_lot(self, lot): + return self.env["stock.move.line"].search(self._domain_move_line_by_lot(lot)) + + def _scheduled_date_today_domain(self): + domain = [] + today_start, today_end = self._get_today_start_end_datetime() + domain.append(("scheduled_date", ">=", today_start)) + domain.append(("scheduled_date", "<=", today_end)) + return domain + + def _get_today_start_end_datetime(self): + company = self.env.company + tz = company.partner_id.tz or "UTC" + today = fields.Datetime.today() + today_start = fields.Datetime.start_of(today, "day") + today_end = fields.Datetime.end_of(today, "day") + today_start_localized = ( + pytz.timezone(tz).localize(today_start).astimezone(pytz.utc) + ) + today_end_localized = pytz.timezone(tz).localize(today_end).astimezone(pytz.utc) + return (today_start_localized, today_end_localized) + + # DOMAIN METHODS + + def _domain_move_line_by_packaging(self, packaging): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "in", [False, self.env.uid]), + ("package_id.product_packaging_id", "=", packaging.id), + ] + + def _domain_move_line_by_product(self, product): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "in", [False, self.env.uid]), + ("product_id", "=", product.id), + ] + + def _domain_move_line_by_lot(self, lot): + return [ + ("move_id.picking_id.picking_type_id", "in", self.picking_types.ids), + ("move_id.picking_id.state", "=", "assigned"), + ("move_id.picking_id.user_id", "=", False), + "|", + ("lot_id.name", "=", lot), + ("lot_name", "=", lot), + ] + + def _domain_stock_picking(self, today_only=False): + domain = [ + ("state", "=", "assigned"), + ("picking_type_id", "in", self.picking_types.ids), + ] + if today_only: + domain.extend(self._scheduled_date_today_domain()) + return domain + + def _select_picking(self, picking): + if picking.picking_type_id not in self.picking_types: + return self._response_for_select_document( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + if picking.state != "assigned": + return self._response_for_select_document( + message=self.msg_store.stock_picking_not_available(picking) + ) + return self._response_for_select_move(picking) + + def _response_for_select_move(self, picking, message=None): + data = {"picking": self._data_for_stock_picking(picking, with_lines=True)} + return self._response(next_state="select_move", data=data, message=message) + + def _response_for_confirm_done(self, picking, message=None): + data = {"picking": self._data_for_stock_picking(picking, with_lines=True)} + return self._response(next_state="confirm_done", data=data, message=message) + + def _response_for_confirm_new_package( + self, picking, line, new_package_name, message=None + ): + data = { + "selected_move_line": self._data_for_move_lines(line), + "picking": self._data_for_stock_picking(picking, with_lines=True), + "new_package_name": new_package_name, + } + return self._response( + next_state="confirm_new_package", data=data, message=message + ) + + def _select_document_from_move_lines(self, move_lines, msg_func): + pickings = move_lines.move_id.picking_id + if len(pickings) == 1: + if ( + move_lines.product_id.tracking not in ("lot", "serial") + or move_lines.lot_id + or move_lines.lot_name + ): + return self._response_for_set_quantity(pickings, move_lines) + return self._response_for_set_lot(pickings, move_lines) + elif len(pickings) > 1: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + # If no available picking with the right state has been found, + # return an error + return self._response_for_select_document(message=msg_func()) + + def _scan_document__create_return(self, picking, return_type, barcode): + stock = self._actions_for("stock") + return_picking = stock.create_return_picking(picking, return_type, barcode) + return_picking.action_confirm() + return return_picking + + def _select_document_from_product(self, product): + """Select the document by product + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_product(product).filtered( + lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids + ) + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.product_not_found_in_pickings(), + ) + + def _select_document_from_packaging(self, packaging): + """Select the document by packaging + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_packaging(packaging).filtered( + lambda l: l.picking_id.picking_type_id.id in self.picking_types.ids + ) + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.product_not_found_in_pickings(), + ) + + def _select_document_from_lot(self, lot): + """Select the document by lot + + next states: + - set_lot: a single picking has been found for this packaging + - select_document: A single or no pickings has been found for this packaging + """ + move_lines = self._move_line_by_lot(lot) + if not move_lines: + return + pickings = move_lines.move_id.picking_id + if pickings: + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.multiple_picks_found_select_manually(), + ) + return self._response_for_select_document( + pickings=pickings, + message=self.msg_store.lot_not_found_in_pickings(), + ) + + def _scan_line__find_or_create_line(self, picking, move, qty_done=1): + """Find or create a line on a move for the user to work on. + + First try to find a line already assigned to the user. + Then a line that is not yet assigned to any users (locking the line + to avoid concurent access.) + If none are found create a new line. + + """ + line = None + unassigned_lines = self.env["stock.move.line"] + for move_line in move.move_line_ids: + if move_line.result_package_id: + continue + if move_line.shopfloor_user_id.id == self.env.uid: + line = move_line + break + elif not move_line.shopfloor_user_id: + unassigned_lines |= move_line + if not line and unassigned_lines: + lock = self._actions_for("lock") + for move_line in unassigned_lines: + if lock.for_update(move_line, skip_locked=True): + line = move_line + break + if not line: + values = move._prepare_move_line_vals() + line = self.env["stock.move.line"].create(values) + return self._scan_line__assign_user(picking, line, qty_done) + + def _scan_line__assign_user(self, picking, line, qty_done): + product = line.product_id + self._assign_user_to_line(line) + line.qty_done += qty_done + if product.tracking not in ("lot", "serial") or (line.lot_id or line.lot_name): + return self._before_state__set_quantity(picking, line) + return self._response_for_set_lot(picking, line) + + def _select_line__filter_lines_by_packaging__return(self, lines, packaging): + return_line = fields.first( + lines.filtered( + lambda l: not l.package_id.product_packaging_id + and not l.result_package_id + and l.shopfloor_user_id.id in (False, self.env.uid) + ) + ) + if return_line: + return return_line + + def _select_line__filter_lines_by_packaging(self, lines, packaging): + if self.work.menu.allow_return: + line = self._select_line__filter_lines_by_packaging__return( + lines, packaging + ) + if line: + return line + return fields.first( + lines.filtered( + lambda l: l.package_id.product_packaging_id == packaging + and not l.result_package_id + and l.shopfloor_user_id.id in [False, self.env.uid] + ) + ) + + def _order_stock_picking(self): + # We sort by scheduled date first. However, there might be a case + # where two pickings have the exact same scheduled date. + # In that case, we sort by id. + return "scheduled_date ASC, id ASC" + + def _scan_document__by_picking(self, pickings, barcode): + picking_filter_result = pickings + reception_pickings = picking_filter_result.filtered( + lambda p: p.picking_type_id.id in self.picking_types.ids + ) + if ( + picking_filter_result + and not reception_pickings + and not self.work.menu.allow_return + ): + return self._response_for_select_document( + message=self.msg_store.cannot_move_something_in_picking_type() + ) + if reception_pickings: + message = self._check_picking_status(reception_pickings) + if message: + return self._response_for_select_document( + pickings=reception_pickings, message=message + ) + # There is a case where scanning the source document + # could return more than one picking. + # If there's only one picking due today, we go to the next screen. + # Otherwise, we ask the user to scan a package instead. + today_start, today_end = self._get_today_start_end_datetime() + picking_filter_result_due_today = picking_filter_result.filtered( + lambda p: today_start + <= p.scheduled_date.astimezone(pytz.utc) + < today_end + ) + if len(picking_filter_result_due_today) == 1: + return self._select_picking(picking_filter_result_due_today) + if len(picking_filter_result) > 1: + return self._response_for_select_document( + pickings=reception_pickings, + message=self.msg_store.source_document_multiple_pickings_scan_package(), + ) + return self._select_picking(reception_pickings) + + def _scan_document__by_product(self, product, barcode): + if product: + return self._select_document_from_product(product) + + def _scan_document__by_packaging(self, packaging, barcode): + if packaging: + return self._select_document_from_packaging(packaging) + + def _scan_document__by_lot(self, lot, barcode): + if lot: + return self._select_document_from_lot(lot) + + def _scan_document__by_origin_move(self, moves, barcode): + if not self.work.menu.allow_return: + # A return picking has been scanned, but allow rma is disabled. + return self._scan_document__fallback() + pickings = moves.picking_id + outgoing_pickings = pickings.filtered( + lambda p: (p.picking_type_code == "outgoing") + ) + # If we find valid pickings for a return, then we create an empty + # return picking + if outgoing_pickings: + # But first, check that return types are correctly set up, + # as we cannot create a return move with empty locations. + return_types = self.picking_types.filtered( + lambda t: t.default_location_src_id and t.default_location_dest_id + ) + if not return_types: + message = self.msg_store.no_default_location_on_picking_type() + return self._response_for_select_document(message=message) + return_picking = self._scan_document__create_return( + fields.first(outgoing_pickings), fields.first(return_types), barcode + ) + return self._response_for_select_move(return_picking) + + def _scan_document__fallback(self): + return self._response_for_select_document( + message=self.msg_store.barcode_not_found() + ) + + def _scan_line__create_return_move(self, return_picking, origin_moves): + # copied from odoo/src/addons/stock/wizard/stock_picking_return.py + stock = self._actions_for("stock") + return stock.create_return_move(return_picking, origin_moves) + + def _scan_line__by_product__return(self, picking, product): + search = self._actions_for("search") + origin_move_domain = [ + ("picking_id.picking_type_code", "=", "outgoing"), + ] + origin_moves = search.origin_move_from_scan( + picking.origin, extra_domain=origin_move_domain + ) + origin_moves_for_product = origin_moves.filtered( + lambda m: m.product_id == product + ) + # If we have an origin picking but no origin move, then user + # scanned a wrong product. Warn him about this. + if origin_moves and not origin_moves_for_product: + message = self.msg_store.product_not_found_in_current_picking() + return self._response_for_select_move(picking, message=message) + if origin_moves_for_product: + return_move = self._scan_line__create_return_move( + picking, origin_moves_for_product + ) + if not return_move: + # It means that among all origin moves, none has been found with + # max qty to return being positive. + # Which means all lines have already been returned. + message = self.msg_store.move_already_returned() + return self._response_for_select_move(picking, message=message) + picking.action_confirm() + picking.action_assign() + return self._scan_line__find_or_create_line(picking, return_move) + + def _scan_line__by_product(self, picking, product): + moves = picking.move_ids.filtered(lambda m: m.product_id == product) + # Only create a return if don't already have a maching reception move + if not moves and self.work.menu.allow_return: + response = self._scan_line__by_product__return(picking, product) + if response: + return response + # Otherwise, the picking isn't a return, and should be a regular reception + message = not moves and self._check_move_available(moves, "product") + for move in moves: + message = self._check_move_available(move, "product") + if not message: + return self._scan_line__find_or_create_line(picking, move) + return self._response_for_select_move( + picking, + message=message, + ) + + def _scan_line__by_packaging__return(self, picking, packaging): + search = self._actions_for("search") + origin_move_domain = [ + ("picking_id.picking_type_code", "=", "outgoing"), + ] + origin_moves = search.origin_move_from_scan( + picking.origin, extra_domain=origin_move_domain + ) + origin_moves_for_packaging = origin_moves.filtered( + lambda m: packaging in m.product_id.packaging_ids + ) + if origin_moves and not origin_moves_for_packaging: + message = self.msg_store.packaging_not_found_in_picking() + return self._response_for_select_move(picking, message=message) + # If we have an origin move, create the return move, and go to next screen + if origin_moves_for_packaging: + return_move = self._scan_line__create_return_move( + picking, origin_moves_for_packaging + ) + return_move._action_confirm() + return self._scan_line__find_or_create_line( + picking, return_move, packaging.qty + ) + + def _scan_line__by_packaging(self, picking, packaging): + move = picking.move_ids.filtered( + lambda m: packaging in m.product_id.packaging_ids + ) + # Only create a return if don't already have a maching reception move + if not move and self.work.menu.allow_return: + response = self._scan_line__by_packaging__return(picking, packaging) + if response: + return response + message = self._check_move_available(move, "packaging") + if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._scan_line__find_or_create_line(picking, move) + + def _scan_line__by_lot(self, picking, lot): + lines = picking.move_line_ids.filtered( + lambda l: ( + lot == l.lot_id + or (lot.name == l.lot_name and lot.product_id == l.product_id) + and not l.result_package_id + ) + ) + if not lines: + return self._scan_line__by_product(picking, lot.product_id) + # TODO probably suboptimal + # We might have an available line, but it might be the last one. + # Loop over the recordset and break as soon as we find one. + for line in lines: + message = self._check_move_available(line.move_id, message_code="lot") + if not message: + break + if message: + return self._response_for_select_move( + picking, + message=message, + ) + return self._scan_line__assign_user(picking, line, 1) + + def _scan_line__fallback(self, picking, barcode): + # We might have lines with no lot, but with a lot_name. + lines = picking.move_line_ids.filtered( + lambda l: l.lot_name == barcode and not l.result_package_id + ) + if not lines: + return self._response_for_select_move( + picking, message=self.msg_store.barcode_not_found() + ) + for line in lines: + message = self._check_move_available(line.move_id, message_code="lot") + if not message: + return self._scan_line__assign_user(picking, line, 1) + return self._response_for_select_move( + picking, + message=message, + ) + + def _check_move_available(self, move, message_code="product"): + if not move: + message_code = message_code.capitalize() + return self.msg_store.x_not_found_or_already_in_dest_package(message_code) + line_without_package = any( + not ml.result_package_id for ml in move.move_line_ids + ) + if move.product_uom_qty - move.quantity_done < 1 and not line_without_package: + return self.msg_store.move_already_done() + + def _set_quantity__check_quantity_done(self, selected_line): + move = selected_line.move_id + max_qty_done = move.product_uom_qty + qty_done = sum(move.move_line_ids.mapped("qty_done")) + rounding = selected_line.product_uom_id.rounding + return float_compare(qty_done, max_qty_done, precision_rounding=rounding) + + def _set_quantity__by_product(self, picking, selected_line, product): + # This is a general rule here. whether the return has been created from + # shopfloor or not, you cannot return more than what was shipped. + # Therefore, we cannot use the `is_shopfloor_created` here. + previous_vals = { + "qty_done": selected_line.qty_done, + } + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + if product.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(product), + ) + selected_line.qty_done += 1 + response = self._response_for_set_quantity(picking, selected_line) + if self.work.menu.allow_return and is_return_line: + message_type = response.get("message", {}).get("message_type") + # If we have an error, return it, since this is also true for return lines + if message_type == "error": + return response + compare = self._set_quantity__check_quantity_done(selected_line) + # We cannot set a qty_done superior to what has initally been sent + if compare == 1: + # If so, reset selected_line to its previous state, and return an error + selected_line.write(previous_vals) + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + return response + + def _set_quantity__by_packaging(self, picking, selected_line, packaging): + # This is a general rule here. whether the return has been created from + # shopfloor or not, you cannot return more than what was shipped. + # Therefore, we cannot use the `is_shopfloor_created` here. + previous_vals = { + "qty_done": selected_line.qty_done, + } + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + if packaging.product_id.id != selected_line.product_id.id: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.wrong_record(packaging), + ) + selected_line.qty_done += packaging.qty + response = self._response_for_set_quantity(picking, selected_line) + if self.work.menu.allow_return and is_return_line: + message_type = response.get("message", {}).get("message_type") + # If we have an error, return it, since this is also true for return lines + if message_type == "error": + return response + compare = self._set_quantity__check_quantity_done(selected_line) + # We cannot set a qty_done superior to what has initally been sent + if compare == 1: + # If so, reset selected_line to its previous state, and return an error + selected_line.write(previous_vals) + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + return response + + def _set_package_on_move_line(self, picking, line, package): + """Assign a package already on a move line. + + If the package is already at a location : + * check the location is valid + * Split the move if doing a partial quantity + + On error, return to the set quantity screen. + + """ + pack_location = package.location_id + if not pack_location: + line.result_package_id = package + return None + ( + move_dest_location_ok, + pick_type_dest_location_ok, + ) = self._check_location_ok(pack_location, line, picking) + if not (move_dest_location_ok or pick_type_dest_location_ok): + # Package location is not a child of the move destination + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity(picking, line, message=message) + quantity = line.qty_done + response = self._set_quantity__process__set_qty_and_split( + picking, line, quantity + ) + if response: + return response + # If the scanned package has a valid destination, + # set both package and destination on the package. + line.result_package_id = package + line.location_dest_id = pack_location + + def _set_quantity__by_package(self, picking, selected_line, package): + response = self._set_package_on_move_line(picking, selected_line, package) + if response: + return response + if package.location_id: + response = self._post_line(selected_line) + if response: + return response + return self._response_for_select_move(picking) + return self._response_for_set_destination(picking, selected_line) + + def _set_quantity__by_location(self, picking, selected_line, location): + move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( + location, selected_line, picking + ) + if not (move_dest_location_ok or pick_type_dest_location_ok): + # Scanned location isn't a child of the move's dest location + message = self.msg_store.dest_location_not_allowed() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + # process without pack, set destination location, and go back to + # `select_move` + selected_line.location_dest_id = location + return self._response_for_select_move(picking) + + def _set_quantity__by_lot(self, picking, selected_line, barcode): + if selected_line.lot_id.name == barcode or selected_line.lot_name == barcode: + selected_line.qty_done += 1 + return self._response_for_set_quantity(picking, selected_line) + + def _check_location_ok(self, location, selected_line, picking): + if location.usage == "view": + return (False, False) + + move_dest_location = selected_line.location_dest_id + pick_type_dest_location = picking.picking_type_id.default_location_dest_id + + move_dest_location_ok = location.parent_path.startswith( + move_dest_location.parent_path + ) + pick_type_dest_location_ok = location.parent_path.startswith( + pick_type_dest_location.parent_path + ) + if move_dest_location_ok or pick_type_dest_location_ok: + return (move_dest_location_ok, pick_type_dest_location_ok) + + return (False, False) + + def _use_handlers(self, handlers, *args, **kwargs): + for handler in handlers: + response = handler(*args, **kwargs) + if response: + return response + + def _assign_user_to_line(self, line): + line.shopfloor_user_id = self.env.user + + # DATA METHODS + + def _data_for_stock_picking(self, picking, with_lines=False, **kw): + if "with_progress" not in kw: + kw["with_progress"] = True + data = self.data.picking(picking, **kw) + if with_lines: + data.update({"moves": self._data_for_moves(picking.move_ids)}) + return data + + def _data_for_stock_pickings(self, pickings, with_lines=False): + return [ + self._data_for_stock_picking(picking, with_lines=with_lines) + for picking in pickings + ] + + def _data_for_move_lines(self, lines, **kw): + return self.data.move_lines(lines, **kw) + + def _data_for_moves(self, moves, **kw): + return self.data.moves(moves, **kw) + + # RESPONSES + + def _response_for_select_document(self, pickings=None, message=None): + if not pickings: + pickings = self.env["stock.picking"].search( + self._domain_stock_picking(today_only=True), + order=self._order_stock_picking(), + ) + else: + # We sort by scheduled date first. However, there might be a case + # where two pickings have the exact same scheduled date. + # In that case, we sort by id. + pickings = pickings.sorted( + lambda p: (p.scheduled_date, p.id), reverse=False + ) + data = {"pickings": self._data_for_stock_pickings(pickings, with_lines=False)} + return self._response(next_state="select_document", data=data, message=message) + + def _response_for_manual_selection(self): + pickings = self.env["stock.picking"].search( + self._domain_stock_picking(), + order=self._order_stock_picking(), + ) + data = {"pickings": self._data_for_stock_pickings(pickings, with_lines=False)} + return self._response(next_state="manual_selection", data=data) + + def _response_for_set_lot(self, picking, line, message=None): + return self._response( + next_state="set_lot", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _align_display_product_uom_qty(self, line, response): + # This method aligns product uom qties on move lines. + # In the shopfloor context, we might have multiple users working at + # the same time on the same move. This is done by creating one move line + # per user, with shopfloor_user_id = user. + # This method ensures that the product_uom_qty reflects what remains to + # be done, so we can display coherent numbers on the UI. + + # for a given line, product_uom_qty is computed as this: + # remaining_todo = move.product_uom_qty - move.quantity_done + # line.product_uom_qty = line.qty_done + remaining_todo + + # TODO, do we need to check move's state? + # If move is already done, do not update lines qties + # if move.state in ("done", "cancel"): + # return + move = line.move_id + qty_todo = move.product_uom_qty + other_lines_qty_done = 0.0 + move_uom = move.product_uom + for move_line in move.move_line_ids - line: + # Use move's uom + line_uom = move_line.product_uom_id + other_lines_qty_done += line_uom._compute_quantity( + move_line.qty_done, move_line.product_uom_id, round=False + ) + remaining_todo = qty_todo - other_lines_qty_done + # Change back to line uom + line_todo = line.product_uom_id._compute_quantity( + remaining_todo, move_uom, round=False + ) + # If more has been done keep the quantity to zero + response["data"]["set_quantity"]["selected_move_line"][0]["quantity"] = max( + line_todo, 0 + ) + return response + + def _before_state__set_quantity(self, picking, line, message=None): + # Used by inherting module see shopfloor_reception_packaging_dimension + return self._response_for_set_quantity(picking, line, message=message) + + def _response_for_set_quantity( + self, picking, line, message=None, asking_confirmation=None + ): + response = self._response( + next_state="set_quantity", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + "confirmation_required": asking_confirmation, + }, + message=message, + ) + return self._align_display_product_uom_qty(line, response) + + def _response_for_set_destination(self, picking, line, message=None): + return self._response( + next_state="set_destination", + data={ + "selected_move_line": self._data_for_move_lines(line), + "picking": self.data.picking(picking), + }, + message=message, + ) + + def _response_for_select_dest_package(self, picking, line, message=None): + # NOTE: code taken from the checkout scenario. + # Maybe refactor it to avoid repetitions. + packages = picking.move_line_ids.result_package_id + if not packages: + return self._response_for_set_quantity( + picking, + line, + message=self.msg_store.no_valid_package_to_select(), + ) + packages_data = self.data.packages( + packages.with_context(picking_id=picking.id).sorted(), + picking=picking, + with_packaging=True, + ) + return self._response( + next_state="select_dest_package", + data={ + "selected_move_line": self._data_for_move_lines(line), + "packages": packages_data, + "picking": self.data.picking(picking), + }, + message=message, + ) + + # ENDPOINTS + + def start(self): + return self._response_for_select_document() + + def _scan_document__get_handlers_by_type(self): + return { + "picking": self._scan_document__by_picking, + # only add the handler if scan_location_or_pack_first is disabled + "product": ( + self._scan_document__by_product + if not self.work.menu.scan_location_or_pack_first + else None + ), + "packaging": self._scan_document__by_packaging, + "lot": self._scan_document__by_lot, + "origin_move": self._scan_document__by_origin_move, + } + + def _scan_document__get_find_kw(self): + return { + "picking": {"use_origin": True}, + "delivered_picking": {"use_origin": True}, + } + + def scan_document(self, barcode): + """Scan a picking, a product or a packaging. + + If an outgoing done move's origin is scanned, a return picking will be created. + + Input: + barcode: the barcode of a product, a packaging, a picking name or a lot + + transitions: + - select_document: Error: barcode not found + - select_document: Multiple picking matching the product / packaging barcode + - select_move: Picking scanned, one has been found + - manual_selection: Press 'manual select' button, all available pickings are displayed + - set_lot: Packaging / Product has been scanned, + single correspondance. Tracked product + - set_quantity: Packaging / Product has been scanned, + single correspondance. Not tracked product + """ + handlers_by_type = self._scan_document__get_handlers_by_type() + search = self._actions_for("search") + find_kw = self._scan_document__get_find_kw() + for handler_type, handler in handlers_by_type.items(): + record = search._find_record_by_type( + barcode, handler_type, handler_kw=find_kw + ) + if not record: + continue + res = handler(record, barcode) + if res: + return res + return self._scan_document__fallback() + + def list_stock_pickings(self): + """Select a picking manually + + transitions: + - select_document: Press 'back' button + - select_move: Picking selected + - set_lot: Picking selected, single correspondance. Tracked product + - set_quantity: Picking selected, single correspondance. Not tracked product + + This endpoint returns the list of all pickings available + so that the user can select one manually + + Since there's no scan in the manual_selection screen + there are only two options: + - Select an available picking and move to the next screen + - Go back to select_document + + This means there should be no room for error + """ + return self._response_for_manual_selection() + + def scan_line(self, picking_id, barcode): + """Scan a product or a packaging + + input: + barcode: The barcode of a product, a packaging or a lot + + transitions: + - select_move: Error: barcode not found + - set_lot: Packaging / Product has been scanned. Tracked product + - set_quantity: Packaging / Product has been scanned. Not tracked product + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) + handlers_by_type = { + "product": self._scan_line__by_product, + "packaging": self._scan_line__by_packaging, + "lot": self._scan_line__by_lot, + } + search = self._actions_for("search") + search_result = search.find(barcode, handlers_by_type.keys()) + # Fallback handler, returns a barcode not found error + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(picking, search_result.record) + return self._scan_line__fallback(picking, barcode) + + def manual_select_move(self, move_id): + move = self.env["stock.move"].browse(move_id) + picking = move.picking_id + return self._scan_line__find_or_create_line(picking, move) + + def done_action(self, picking_id, confirmation=False): + """Mark a picking as done + + input: + confirmation: if false, ask for confirmation; if true, mark as done + + transitions: + - select_move: Error: no qty done + - select_move: Error: picking not found + - confirm_done: Ask for confirmation + - select_document: Mark as done + """ + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_move(picking, message=message) + if all(line.qty_done == 0 for line in picking.move_line_ids): + # If no line has been processed, refuse to set the picking as done + return self._response_for_select_move( + picking, message=self.msg_store.transfer_no_qty_done() + ) + cancel_backorder = picking.is_shopfloor_created and self.work.menu.allow_return + if not confirmation and not cancel_backorder: + to_backorder = picking._check_backorder() + if to_backorder: + # Not all lines are fully done, ask the user to confirm the + # backorder creation + return self._response_for_confirm_done( + picking, message=self.msg_store.transfer_confirm_done() + ) + # all lines are done, ask the user to confirm anyway + return self._response_for_confirm_done( + picking, message=self.msg_store.need_confirmation() + ) + self._handle_backorder(picking, cancel_backorder) + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(picking) + ) + + def _handle_backorder(self, picking, cancel_backorder=False): + """This method handles backorders that could be created at picking confirm.""" + if cancel_backorder: + picking = picking.with_context(cancel_backorder=True) + backorders_before = picking.backorder_ids + picking._action_done() + if not cancel_backorder: + backorders_after = picking.backorder_ids - backorders_before + # Remove user_id on backorder, if any + backorders_after.user_id = False + + def set_lot( + self, picking_id, selected_line_id, lot_name=None, expiration_date=None + ): + """Set lot and its expiration date + + Input: + barcode: The barcode of a lot + expiration_date: The expiration_date + + transitions: + - select_move: User clicked on back + - set_lot: Barcode not found. Ask user to create one from barcode + - set_lot: expiration_date has been set on the selected line + - set_lot: lot_it has been set on the selected line + - set_lot: Error: expiration_date is required + - set_quantity: User clicked on the confirm button + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_lot(picking, selected_line, message=message) + search = self._actions_for("search") + if lot_name: + product = selected_line.product_id + lot = search.lot_from_scan(lot_name, products=product) + if not lot: + lot = self.env["stock.lot"].create( + self._create_lot_values(product, lot_name) + ) + selected_line.lot_id = lot.id + selected_line._onchange_lot_id() + elif expiration_date: + selected_line.write({"expiration_date": expiration_date}) + selected_line.lot_id.write({"expiration_date": expiration_date}) + return self._response_for_set_lot(picking, selected_line) + + def _create_lot_values(self, product, lot_name): + return { + "name": lot_name, + "product_id": product.id, + "company_id": self.env.company.id, + "use_expiration_date": product.use_expiration_date, + } + + def set_lot_confirm_action(self, picking_id, selected_line_id): + picking = self.env["stock.picking"].browse(picking_id) + message = self._check_picking_status(picking) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + message = self._check_expiry_date(selected_line) + if message: + return self._response_for_set_lot(picking, selected_line, message=message) + return self._before_state__set_quantity(picking, selected_line) + + def _check_expiry_date(self, line): + use_expiration_date = ( + line.product_id.use_expiration_date or line.lot_id.use_expiration_date + ) + if use_expiration_date and not line.expiration_date: + return self.msg_store.expiration_date_missing() + + def _set_quantity__get_handlers_by_type(self): + return { + "product": self._set_quantity__by_product, + "packaging": self._set_quantity__by_packaging, + "package": self._set_quantity__by_package, + "location": self._set_quantity__by_location, + "lot": self._set_quantity__by_lot, + } + + def _set_quantity__by_barcode( + self, picking, selected_line, barcode, confirmation=None + ): + handlers_by_type = self._set_quantity__get_handlers_by_type() + search = self._actions_for("search") + search_result = search.find(barcode, handlers_by_type.keys()) + handler = handlers_by_type.get(search_result.type) + if handler: + return handler(picking, selected_line, search_result.record) + # Nothing found, ask user if we should create a new pack for the scanned + # barcode + if confirmation != barcode: + return self._response_for_set_quantity( + picking, + selected_line, + message=self.msg_store.create_new_pack_ask_confirmation(barcode), + asking_confirmation=barcode, + ) + package = self.env["stock.quant.package"].create({"name": barcode}) + selected_line.result_package_id = package + return self._response_for_set_destination(picking, selected_line) + + def _set_quantity__assign_quantity(self, picking, selected_line, quantity): + # If this is a return line, we cannot assign more qty_done than what + # was originally sent. + is_return_line = bool(selected_line.move_id.origin_returned_move_id) + max_qty_done = selected_line.move_id.product_uom_qty + if is_return_line and self.work.menu.allow_return: + if quantity > max_qty_done: + message = self.msg_store.return_line_invalid_qty() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + selected_line.qty_done = quantity + + def set_quantity( + self, + picking_id, + selected_line_id, + quantity=None, + barcode=None, + confirmation=None, + ): + """Set the quantity done + + Input: + quantity: the quantity to set + barcode: Barcode of a product / packaging to determine the qty to increment + barcode: Barcode of a package / location to set on the line + + transitions: + - select_move: User clicked on back + - set_lot: Barcode not found. Ask user to create one from barcode + - set_lot: expiration_date has been set on the selected line + - set_lot: lot_it has been set on the selected line + - set_lot: Error: expiration_date is required + - set_quantity: User clicked on the confirm button + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + if quantity is not None: + # We set qty_done to be equal to the qty of the picker + # at the moment of the scan. + response = self._set_quantity__assign_quantity( + picking, selected_line, quantity + ) + if response: + return response + if barcode: + # Then, we add the qty of whatever was scanned + # on top of the qty of the picker. + return self._set_quantity__by_barcode( + picking, selected_line, barcode, confirmation + ) + return self._response_for_set_quantity(picking, selected_line) + + def set_quantity__cancel_action(self, picking_id, selected_line_id): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + if selected_line.exists(): + if selected_line.reserved_uom_qty: + stock = self._actions_for("stock") + stock.unmark_move_line_as_picked(selected_line) + else: + selected_line.unlink() + return self._response_for_select_move(picking) + + def _set_quantity__process__set_qty_and_split(self, picking, line, quantity): + move = line.move_id + sum(move.move_line_ids.mapped("qty_done")) + savepoint = self._actions_for("savepoint").new() + line.qty_done = quantity + compare = self._set_quantity__check_quantity_done(line) + if compare == 1: + # If move's qty_done > to move's qty_todo, rollback and return an error + savepoint.rollback() + return self._response_for_set_quantity( + picking, line, message=self.msg_store.unable_to_pick_qty() + ) + savepoint.release() + # Only if total_qty_done < qty_todo, we split the move line + if compare == -1: + default_values = { + "lot_id": False, + "shopfloor_user_id": False, + "expiration_date": False, + } + line._split_qty_to_be_done(quantity, **default_values) + + def process_with_existing_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity + ) + if response: + return response + return self._response_for_select_dest_package(picking, selected_line) + + def process_with_new_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity + ) + if response: + return response + picking._put_in_pack(selected_line) + return self._response_for_set_destination(picking, selected_line) + + def process_without_pack(self, picking_id, selected_line_id, quantity): + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_quantity( + picking, selected_line, message=message + ) + response = self._set_quantity__process__set_qty_and_split( + picking, selected_line, quantity + ) + if response: + return response + return self._response_for_set_destination(picking, selected_line) + + def _post_line(self, selected_line): + selected_line.reserved_uom_qty = selected_line.qty_done + if ( + selected_line.picking_id.is_shopfloor_created + and self.work.menu.allow_return + ): + # If the transfer is not planned, and we allow unplanned returns, + # process the returned qty and mark it as done. + return self._post_shopfloor_created_line(selected_line) + + if self.work.menu.auto_post_line: + # If option auto_post_line is active in the shopfloor menu, + # create a split order with this line. + self._auto_post_line(selected_line) + + def _post_shopfloor_created_line(self, selected_line): + selected_line.reserved_uom_qty = selected_line.qty_done + selected_line.picking_id.with_context(cancel_backorder=True)._action_done() + return self._response_for_select_document( + message=self.msg_store.transfer_done_success(selected_line.picking_id) + ) + + def _auto_post_line(self, selected_line): + # If user only processed 1/5 and is the only one working on the move, + # then selected_line is the only one related to this move. + # In such case, we must ensure there's another move with the remaining + # quantity to do, so selected_line is extracted in a new move as expected. + + # Always keep the quantity todo at zero, the same is done + # in Odoo when move lines are created manually (setting) + lines_with_qty_todo = selected_line.move_id.move_line_ids.filtered( + lambda line: line.state not in ("cancel", "done") + and line.reserved_uom_qty > 0 + ) + move = selected_line.move_id + lock = self._actions_for("lock") + lock.for_update(move) + if lines_with_qty_todo: + lines_with_qty_todo.reserved_uom_qty = 0 + + move_quantity = move.product_uom._compute_quantity( + move.product_uom_qty, selected_line.product_uom_id + ) + if selected_line.qty_done == move_quantity: + # In case of full quantity, post the initial move + return selected_line.move_id.extract_and_action_done() + split_move_vals = move._split(selected_line.qty_done) + new_move = move.create(split_move_vals) + new_move.move_line_ids = selected_line + new_move._action_confirm(merge=False) + new_move._recompute_state() + new_move._action_assign() + # Set back the quantity to do on one of the lines + line = fields.first( + move.move_line_ids.filtered( + lambda line: line.state not in ("cancel", "done") + ) + ) + if line: + move_quantity = move.product_uom._compute_quantity( + move.product_uom_qty, line[0].product_uom_id + ) + line.reserved_uom_qty = move_quantity + move._recompute_state() + new_move.extract_and_action_done() + + def set_destination( + self, picking_id, selected_line_id, location_name, confirmation=False + ): + """Set the destination on the move line. + + input: + location_name: The name of the location + + transitions: + - set_destination: Warning: User scanned a child location of the picking type. + Ask for confirmation + - set_destination: Error: User tried to scan a non-valid location + - select_move: User scanned a child location of the move's dest location + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_set_destination( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_set_destination( + picking, selected_line, message=message + ) + search = self._actions_for("search") + + location = search.location_from_scan(location_name) + if not location: + return self._response_for_set_destination( + picking, selected_line, message=self.msg_store.no_location_found() + ) + move_dest_location_ok, pick_type_dest_location_ok = self._check_location_ok( + location, selected_line, picking + ) + if not (move_dest_location_ok or pick_type_dest_location_ok): + return self._response_for_set_destination( + picking, + selected_line, + message=self.msg_store.dest_location_not_allowed(), + ) + if move_dest_location_ok: + # If location is a child of move's dest location, assign it without asking + selected_line.location_dest_id = location + elif pick_type_dest_location_ok: + # If location is a child of picking types's dest location, + # ask for confirmation before assigning + if not confirmation: + return self._response_for_set_destination( + picking, + selected_line, + message=self.msg_store.place_in_location_ask_confirmation( + location.name + ), + ) + selected_line.location_dest_id = location + response = self._post_line(selected_line) + if response: + return response + return self._response_for_select_move(picking) + + def select_dest_package( + self, picking_id, selected_line_id, barcode, confirmation=False + ): + """Select the destination package for the move line + + Input: + barcode: The barcode of the package + + transitions: + - select_move: User scanned a valid package + - select_dest_package: Warning: User scanned an unknown barcode. + Confirm to create one. + - select_dest_package: Error: User scanned a non-empty package + """ + picking = self.env["stock.picking"].browse(picking_id) + selected_line = self.env["stock.move.line"].browse(selected_line_id) + message = self._check_picking_status(picking) + if message: + return self._response_for_select_dest_package( + picking, selected_line, message=message + ) + if not selected_line.exists(): + message = self.msg_store.record_not_found() + return self._response_for_select_dest_package( + picking, selected_line, message=message + ) + search = self._actions_for("search") + package = search.package_from_scan(barcode) + if not package and confirmation: + package = self.env["stock.quant.package"].create({"name": barcode}) + if package: + # Do not allow user to create a non-empty package + if package.quant_ids: + return self._response_for_select_dest_package( + picking, + selected_line, + message=self.msg_store.package_not_empty(package), + ) + response = self._set_package_on_move_line(picking, selected_line, package) + if response: + return response + response = self._post_line(selected_line) + if response: + return response + return self._response_for_select_move(picking) + message = self.msg_store.create_new_pack_ask_confirmation(barcode) + return self._response_for_confirm_new_package( + picking, selected_line, new_package_name=barcode, message=message + ) + + +class ShopfloorReceptionValidator(Component): + _inherit = "base.shopfloor.validator" + _name = "shopfloor.reception.validator" + _usage = "reception.validator" + + def start(self): + return {} + + def scan_document(self): + return {"barcode": {"required": True, "type": "string"}} + + def list_stock_pickings(self): + return {} + + def scan_line(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "barcode": {"required": True, "type": "string"}, + } + + def manual_select_move(self): + return { + "move_id": {"required": True, "type": "integer"}, + } + + def set_lot(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "lot_name": {"type": "string"}, + "expiration_date": {"type": "string"}, + } + + def set_quantity(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"type": "float"}, + "barcode": {"type": "string"}, + "confirmation": {"type": "string", "nullable": True}, + } + + def set_quantity__cancel_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + } + + def process_with_existing_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def process_with_new_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def process_without_pack(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "quantity": {"coerce": to_float, "type": "float"}, + } + + def set_destination(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "location_name": {"required": True, "type": "string"}, + "confirmation": {"type": "boolean"}, + } + + def select_dest_package(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + "barcode": {"type": "string", "required": True}, + "confirmation": {"type": "boolean"}, + } + + def done_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "confirmation": {"type": "boolean"}, + } + + def set_lot_confirm_action(self): + return { + "picking_id": {"coerce": to_int, "required": True, "type": "integer"}, + "selected_line_id": { + "coerce": to_int, + "type": "integer", + "required": True, + }, + } + + +class ShopfloorReceptionValidatorResponse(Component): + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.reception.validator.response" + _usage = "reception.validator.response" + + _start_state = "select_document" + + # STATES + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return { + "select_document": self._schema_select_document, + "manual_selection": self._schema_manual_selection, + "select_move": self._schema_select_move, + "confirm_done": self._schema_confirm_done, + "set_lot": self._schema_set_lot, + "set_quantity": self._schema_set_quantity, + "set_destination": self._schema_set_destination, + "select_dest_package": self._schema_select_dest_package, + "confirm_new_package": self._schema_confirm_new_package, + } + + def _start_next_states(self): + return {"select_document"} + + def _scan_document_next_states(self): + return { + "select_document", + "select_move", + "set_lot", + "set_quantity", + "manual_selection", + } + + def _list_stock_pickings_next_states(self): + return { + "select_document", + "select_move", + "set_lot", + "set_quantity", + "manual_selection", + } + + def _scan_line_next_states(self): + return {"select_move", "set_lot", "set_quantity"} + + def _set_lot_next_states(self): + return {"select_move", "set_lot", "set_quantity"} + + def _set_quantity_next_states(self): + return {"set_quantity", "select_move", "set_destination"} + + def _set_quantity__cancel_action_next_states(self): + return {"set_quantity", "select_move"} + + def _set_destination_next_states(self): + return {"set_destination", "select_move"} + + def _select_dest_package_next_states(self): + return {"set_lot", "select_dest_package", "confirm_new_package", "select_move"} + + def _done_next_states(self): + return {"select_document", "select_move", "confirm_done"} + + def _set_lot_confirm_action_next_states(self): + return {"set_lot", "set_quantity"} + + def _process_with_existing_pack_next_states(self): + return {"set_quantity", "select_dest_package"} + + def _process_with_new_pack_next_states(self): + return {"set_quantity", "set_destination"} + + def _process_without_pack_next_states(self): + return {"set_quantity", "set_destination"} + + # SCHEMAS + + @property + def _schema_select_document(self): + return { + "pickings": self.schemas._schema_list_of( + self.schemas.picking(), required=True + ) + } + + @property + def _schema_manual_selection(self): + return { + "pickings": self.schemas._schema_list_of( + self.schemas.picking(), required=True + ) + } + + @property + def _schema_select_move(self): + return { + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ) + } + + @property + def _schema_confirm_done(self): + return { + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ) + } + + @property + def _schema_set_lot(self): + return { + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + } + + @property + def _schema_set_quantity(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + "confirmation_required": { + "type": "string", + "nullable": True, + "required": False, + }, + } + + @property + def _schema_set_quantity__cancel_action(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_set_destination(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_select_dest_package(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "packages": { + "type": "list", + "schema": { + "type": "dict", + "schema": self.schemas.package(with_packaging=True), + }, + }, + "picking": {"type": "dict", "schema": self.schemas.picking()}, + } + + @property + def _schema_confirm_new_package(self): + return { + "selected_move_line": { + "type": "list", + "schema": {"type": "dict", "schema": self.schemas.move_line()}, + }, + "picking": self.schemas._schema_dict_of( + self._schema_stock_picking_with_lines(), required=True + ), + "new_package_name": {"type": "string"}, + } + + def _schema_stock_picking_with_lines(self, lines_with_packaging=False): + # TODO: ideally, we should use self.schemas_detail.picking_detail + # instead of this method. + schema = self.schemas.picking() + schema.update({"moves": self.schemas._schema_list_of(self.schemas.move())}) + return schema + + # ENDPOINTS + + def start(self): + return self._response_schema(next_states=self._start_next_states()) + + def scan_document(self): + return self._response_schema(next_states=self._scan_document_next_states()) + + def list_stock_pickings(self): + return self._response_schema( + next_states=self._list_stock_pickings_next_states() + ) + + def scan_line(self): + return self._response_schema(next_states=self._scan_line_next_states()) + + def manual_select_move(self): + return self._response_schema(next_states=self._scan_line_next_states()) + + def set_lot(self): + return self._response_schema(next_states=self._set_lot_next_states()) + + def set_lot_confirm_action(self): + return self._response_schema( + next_states=self._set_lot_confirm_action_next_states() + ) + + def set_quantity(self): + return self._response_schema(next_states=self._set_quantity_next_states()) + + def set_quantity__cancel_action(self): + return self._response_schema( + next_states=self._set_quantity__cancel_action_next_states() + ) + + def process_with_existing_pack(self): + return self._response_schema( + next_states=self._process_with_existing_pack_next_states() + ) + + def process_with_new_pack(self): + return self._response_schema( + next_states=self._process_with_new_pack_next_states() + ) + + def process_without_pack(self): + return self._response_schema( + next_states=self._process_without_pack_next_states() + ) + + def set_destination(self): + return self._response_schema(next_states=self._set_destination_next_states()) + + def select_dest_package(self): + return self._response_schema( + next_states=self._select_dest_package_next_states() + ) + + def done_action(self): + return self._response_schema(next_states=self._done_next_states()) diff --git a/shopfloor_reception/static/description/icon.png b/shopfloor_reception/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/shopfloor_reception/static/description/icon.png differ diff --git a/shopfloor_reception/static/description/index.html b/shopfloor_reception/static/description/index.html new file mode 100644 index 0000000000..7661786fbd --- /dev/null +++ b/shopfloor_reception/static/description/index.html @@ -0,0 +1,432 @@ + + + + + +Shopfloor Reception + + + +
+

Shopfloor Reception

+ + +

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

+

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

+

Table of contents

+ +
+

Known issues / Roadmap

+

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

+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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

+

Current maintainers:

+

mmequignon JuMiSanAr

+

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_reception/tests/__init__.py b/shopfloor_reception/tests/__init__.py new file mode 100644 index 0000000000..a5861ce292 --- /dev/null +++ b/shopfloor_reception/tests/__init__.py @@ -0,0 +1,15 @@ +from . import test_start +from . import test_select_document +from . import test_manual_selection +from . import test_select_move +from . import test_reception_done +from . import test_set_lot +from . import test_set_lot_confirm +from . import test_set_quantity +from . import test_set_quantity_action +from . import test_set_destination +from . import test_select_dest_package +from . import test_return_scan_document +from . import test_return_scan_line +from . import test_return_set_quantity +from . import test_return_reception_done diff --git a/shopfloor_reception/tests/common.py b/shopfloor_reception/tests/common.py new file mode 100644 index 0000000000..90bd62e323 --- /dev/null +++ b/shopfloor_reception/tests/common.py @@ -0,0 +1,167 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +# pylint: disable=missing-return + +from odoo import fields + +from odoo.addons.shopfloor.tests.common import CommonCase as BaseCommonCase + + +class CommonCase(BaseCommonCase): + @classmethod + def _create_picking(cls, picking_type=None, lines=None, confirm=True, **kw): + picking = super()._create_picking( + picking_type=picking_type, lines=lines, confirm=confirm, **kw + ) + picking.user_id = False + return picking + + @classmethod + def _create_lot(cls, **kwargs): + vals = { + "product_id": cls.product_a.id, + "company_id": cls.env.company.id, + } + vals.update(kwargs) + return cls.env["stock.lot"].create(vals) + + @classmethod + def _add_package(cls, picking): + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + for line in picking.move_line_ids: + product = line.product_id + packaging = packagings.filtered(lambda p: p.product_id == product) + package = cls.env["stock.quant.package"].create( + {"product_packaging_id": packaging.id} + ) + line.package_id = package + + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + cls.menu = cls.env.ref("shopfloor_reception.shopfloor_menu_demo_reception") + cls.profile = cls.env.ref("shopfloor.profile_demo_1") + cls.picking_type = cls.menu.picking_type_ids + cls.wh = cls.picking_type.warehouse_id + + def _data_for_move_lines(self, lines, **kw): + return self.data.move_lines(lines, **kw) + + def _data_for_picking_with_line(self, picking): + picking_data = self._data_for_picking(picking) + move_lines_data = self._data_for_move_lines(picking.move_line_ids) + picking_data.update({"move_lines": move_lines_data}) + return picking_data + + def _data_for_pickings_with_line(self, pickings): + res = [] + for picking in pickings: + res.append(self._data_for_picking_with_line(picking)) + return res + + def _data_for_picking_with_moves(self, picking, with_progress=True): + picking_data = self._data_for_picking(picking, with_progress) + moves_data = self._data_for_moves(picking.move_ids) + picking_data.update({"moves": moves_data}) + return picking_data + + def _data_for_picking(self, picking, with_progress=True): + return self.data.picking(picking, with_progress=with_progress) + + def _data_for_pickings(self, pickings): + res = [] + for picking in pickings: + res.append(self._data_for_picking(picking)) + return res + + def _data_for_move(self, move): + return self.data.move(move) + + def _data_for_moves(self, moves): + res = [] + for move in moves: + res.append(self._data_for_move(move)) + return res + + def _data_for_select_move(self, picking, with_progress=True): + picking_data = self._data_for_picking_with_moves(picking, with_progress) + data = { + "picking": picking_data, + } + return data + + def setUp(self): + super().setUp() + self.service = self.get_service( + "reception", menu=self.menu, profile=self.profile + ) + + def _stock_picking_data(self, picking, **kw): + return self.service._data_for_stock_picking(picking, **kw) + + # we test the methods that structure data in test_actions_data.py + def _picking_summary_data(self, picking): + return self.data.picking(picking) + + def _move_line_data(self, move_line): + return self.data.move_line(move_line) + + def _package_data(self, package, picking): + return self.data.package(package, picking=picking, with_packaging=True) + + def _packaging_data(self, packaging): + return self.data.packaging(packaging) + + def _get_all_pickings(self): + return self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", self.picking_type.id), + ("user_id", "=", False), + ], + order="scheduled_date ASC", + ) + + def _get_today_pickings(self): + return self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", self.picking_type.id), + ("user_id", "=", False), + ("scheduled_date", "=", fields.Datetime.today()), + ], + order="scheduled_date ASC", + ) + + def assertMessage(self, response, expected_message): + message = response.get("message") + for key, value in expected_message.items(): + self.assertEqual(message.get(key), value) + + @classmethod + def _get_move_ids_from_response(cls, response): + state = response.get("next_state") + data = response["data"][state] + picking_data = data.get("pickings") or [data.get("picking")] + moves_data = [] + for picking in picking_data: + moves_data.extend(picking["moves"]) + return [move["id"] for move in moves_data] + + def _get_service_for_user(self, user): + user_env = self.env(user=user) + return self.get_service( + "reception", menu=self.menu, profile=self.profile, env=user_env + ) + + @classmethod + def _shopfloor_manager_values(cls): + vals = super()._shopfloor_manager_values() + vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])] + return vals diff --git a/shopfloor_reception/tests/reception_return_common.py b/shopfloor_reception/tests/reception_return_common.py new file mode 100644 index 0000000000..742d4a32d9 --- /dev/null +++ b/shopfloor_reception/tests/reception_return_common.py @@ -0,0 +1,154 @@ +# Copyright 2023 Camptocamp SA +# Copyright 2024 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import Form + +from .common import CommonCase + + +class CommonCaseReturn(CommonCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + # In order to have the `picking_type_reception_demo` picking_type + # on returned pickings and moves + cls.stock_manager = cls.env.ref("stock.group_stock_manager").users[0] + cls.reception_type = cls.env.ref( + "shopfloor_reception.picking_type_reception_demo" + ) + ship_type = cls.env.ref("stock.picking_type_out") + ship_type.sudo().return_picking_type_id = cls.reception_type + cls.location_src = cls.env.ref("stock.stock_location_stock") + cls.location_dest = cls.env.ref("stock.stock_location_company") + cls.location = cls.location_src + cls.product = cls.product_a + cls.order = cls.create_sale_order() + cls._add_stock_to_product(cls.product, cls.location, 20.0) + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + packagings.write({"qty": 10.0}) + + @classmethod + def _shopfloor_user_values(cls): + vals = super()._shopfloor_user_values() + group_ids = vals.get("groups_id") + if group_ids: + ids = group_ids[0][2] + else: + ids = [] + ids.append(cls.env.ref("sales_team.group_sale_salesman").id) + vals["groups_id"] = [(6, 0, ids)] + return vals + + @classmethod + def create_sale_order(cls): + form = Form(cls.env["sale.order"]) + form.partner_id = cls.customer + lines = [(cls.product, 20)] + for product, qty in lines: + with form.order_line.new() as line: + line.product_id = product + line.product_uom_qty = qty + return form.save() + + @classmethod + def create_delivery(cls): + cls.order.action_confirm() + cls.cache_existing_record_ids() + return cls.order.picking_ids + + @classmethod + def _add_package_to_order(cls, order): + packaging_ids = [ + cls.product_a_packaging.id, + cls.product_c_packaging.id, + cls.product_b_packaging.id, + cls.product_d_packaging.id, + ] + packagings = cls.env["product.packaging"].browse(packaging_ids) + for line in order.order_line: + product = line.product_id + packaging = packagings.filtered(lambda p: p.product_id == product) + line.product_packaging_id = packaging + + @classmethod + def deliver(cls, pickings): + while "there's a ready picking": + ready_picking = pickings.filtered( + lambda p: p.state in ("ready", "confirmed", "assigned") + ) + if not ready_picking: + break + for line in ready_picking.move_line_ids: + line.qty_done = line.reserved_uom_qty + ready_picking._action_done() + + @classmethod + def partial_deliver(cls, picking, qty_done): + picking.move_line_ids.write({"qty_done": qty_done}) + action_data = picking.button_validate() + if not action_data or action_data is True: + return picking.browse() + backorder_wizard = Form( + cls.env["stock.backorder.confirmation"].with_context( + **action_data["context"] + ) + ).save() + backorder_wizard.process() + return cls.env["stock.picking"].search([("backorder_id", "=", picking.id)]) + + def assert_return_of(self, picking_in, origin): + self.assertEqual(origin, picking_in.origin) + self.assertEqual( + picking_in.location_dest_id, self.reception_type.default_location_dest_id + ) + self.assertEqual( + picking_in.location_id, self.reception_type.default_location_src_id + ) + + @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 get_new_pickings(cls): + res = cls.env["stock.picking"].search( + [("id", "not in", cls.existing_picking_ids)] + ) + cls.cache_existing_record_ids() + return res + + @classmethod + def get_new_move_lines(cls): + res = cls.env["stock.move.line"].search( + [("id", "not in", cls.existing_move_line_ids)] + ) + cls.cache_existing_record_ids() + return res + + @classmethod + def _add_stock_to_product(cls, product, location, qty): + """Set the stock quantity of the product.""" + values = { + "product_id": product.id, + "location_id": location.id, + "inventory_quantity": qty, + } + cls.env["stock.quant"].with_user(cls.stock_manager).with_context( + inventory_mode=True + ).create(values)._apply_inventory() + cls.cache_existing_record_ids() + + @classmethod + def _enable_allow_return(cls): + cls.menu.sudo().allow_return = True diff --git a/shopfloor_reception/tests/test_manual_selection.py b/shopfloor_reception/tests/test_manual_selection.py new file mode 100644 index 0000000000..af3cffab8e --- /dev/null +++ b/shopfloor_reception/tests/test_manual_selection.py @@ -0,0 +1,37 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import datetime + +from odoo import fields + +from .common import CommonCase + + +class TestManualSelection(CommonCase): + def test_list_stock_pickings(self): + response = self.service.dispatch("list_stock_pickings") + self.assert_response( + response, + next_state="manual_selection", + data={"pickings": []}, + ) + # Create a picking due today + today = fields.Datetime.today() + picking_due_today = self._create_picking() + picking_due_today.write({"scheduled_date": today}) + # Create a picking due tomorrow + tomorrow = today + datetime.timedelta(days=1) + picking_due_tomorrow = self._create_picking() + picking_due_tomorrow.write({"scheduled_date": tomorrow}) + + # Both pickings will be returned + response = self.service.dispatch("list_stock_pickings") + pickings = self.env["stock.picking"].browse( + [picking_due_today.id, picking_due_tomorrow.id] + ) + self.assert_response( + response, + next_state="manual_selection", + data={"pickings": self._data_for_pickings(pickings)}, + ) diff --git a/shopfloor_reception/tests/test_reception_done.py b/shopfloor_reception/tests/test_reception_done.py new file mode 100644 index 0000000000..324503bf9e --- /dev/null +++ b/shopfloor_reception/tests/test_reception_done.py @@ -0,0 +1,93 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from datetime import timedelta + +from odoo import fields + +from .common import CommonCase + + +class TestReceptionDone(CommonCase): + def test_set_done_no_backorder(self): + picking = self._create_picking() + picking.move_line_ids.write({"qty_done": 10, "shopfloor_checkout_done": True}) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + # User is asked to confirm the action + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._data_for_picking_with_moves(picking)}, + message={"message_type": "warning", "body": "Are you sure?"}, + ) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id, "confirmation": True} + ) + self.assertEqual(picking.state, "done") + # No more picking to process. Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {picking.name} done", + }, + ) + + def test_set_done_no_qty_processed(self): + picking = self._create_picking() + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + message={ + "message_type": "warning", + "body": "No quantity has been processed, unable to complete the transfer.", + }, + ) + + def test_set_done_with_backorder(self): + picking = self._create_picking( + scheduled_date=fields.Datetime.today() + timedelta(days=1) + ) + picking_due_today = self._create_picking(scheduled_date=fields.Datetime.today()) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.qty_done = 10.0 + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id} + ) + self.assert_response( + response, + next_state="confirm_done", + data={"picking": self._data_for_picking_with_moves(picking)}, + message={ + "message_type": "warning", + "body": ( + "Not all lines have been processed with full quantity. " + "Do you confirm partial operation?" + ), + }, + ) + response = self.service.dispatch( + "done_action", params={"picking_id": picking.id, "confirmation": True} + ) + self.assertEqual(picking.state, "done") + backorder = picking.backorder_ids + self.assertEqual(backorder.move_line_ids.product_id, self.product_b) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking_due_today)}, + message={ + "message_type": "success", + "body": f"Transfer {picking.name} done", + }, + ) diff --git a/shopfloor_reception/tests/test_return_reception_done.py b/shopfloor_reception/tests/test_return_reception_done.py new file mode 100644 index 0000000000..ee44be8f45 --- /dev/null +++ b/shopfloor_reception/tests/test_return_reception_done.py @@ -0,0 +1,132 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestReturn(CommonCaseReturn): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._enable_allow_return() + delivery = cls.create_delivery() + cls.deliver(delivery) + + def setUp(self): + super().setUp() + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + self.return_picking = self.get_new_pickings() + product = self.product + self.service.dispatch( + "scan_line", + params={"picking_id": self.return_picking.id, "barcode": product.barcode}, + ) + self.selected_move_line = self.get_new_move_lines() + + def _set_quantity_done(self, qty_done=20): + params = { + "picking_id": self.return_picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": qty_done, + } + self.service.dispatch("set_quantity", params=params) + + def _dispatch(self, confirmation=False): + params = {"picking_id": self.return_picking.id, "confirmation": confirmation} + response = self.service.dispatch("done_action", params=params) + # Here, since we receive goods in input location, which triggers creation of + # an internal shipping, sending goods from input to stock. + # This isn't related to the reception scenario, we do not want + # those internal shippings to be retrieved with `get_new_pickings`. + self.cache_existing_record_ids() + return response + + def test_set_done_full_qty_done(self): + self._set_quantity_done() + response = self._dispatch() + self.assertEqual(self.return_picking.state, "done") + # No more picking to process. Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {self.return_picking.name} done", + }, + ) + + def test_set_done_partial_qty_done(self): + self.assertEqual(self.selected_move_line.reserved_uom_qty, 20.0) + self._set_quantity_done(qty_done=10.0) + response = self._dispatch() + # As this is a return created by the app, no backorder is created, + # even if qty_done doesn't match que product_uom_qty + self.assertEqual(self.return_picking.state, "done") + self.assertEqual(self.selected_move_line.qty_done, 10.0) + self.assertFalse(bool(self.return_picking.backorder_ids)) + # Success message + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {self.return_picking.name} done", + }, + ) + # Now, since we still have returned 10 units out of ten, try to return + # the next ones + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking_2 = self.get_new_pickings() + self.service.dispatch( + "scan_line", + params={"picking_id": return_picking_2.id, "barcode": self.product.barcode}, + ) + selected_line_2 = self.get_new_move_lines() + # Ensure that the max qty to return is 10.0 + self.assertEqual(selected_line_2.reserved_uom_qty, 10.0) + # Set qty done == 10.0 + params = { + "picking_id": return_picking_2.id, + "selected_line_id": selected_line_2.id, + "quantity": 10.0, + } + self.service.dispatch("set_quantity", params=params) + # Set to done + params = {"picking_id": return_picking_2.id} + response = self.service.dispatch("done_action", params=params) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "success", + "body": f"Transfer {return_picking_2.name} done", + }, + ) + + def test_already_returned(self): + # Set return move as completely done (20/20) + self._set_quantity_done() + # Confirm return picking + response = self._dispatch() + # Select the same document again + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + second_return_picking = self.get_new_pickings() + # Now, try to select a product that has already been completely returned + product = self.product + response = self.service.dispatch( + "scan_line", + params={"picking_id": second_return_picking.id, "barcode": product.barcode}, + ) + expected_message = { + "message_type": "error", + "body": "The product/packaging you selected has already been returned.", + } + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(second_return_picking)}, + message=expected_message, + ) diff --git a/shopfloor_reception/tests/test_return_scan_document.py b/shopfloor_reception/tests/test_return_scan_document.py new file mode 100644 index 0000000000..dc086447c7 --- /dev/null +++ b/shopfloor_reception/tests/test_return_scan_document.py @@ -0,0 +1,115 @@ +# Copyright 2023 Camptocamp SA +# Copyright 2024 Michael Tietz (MT Software) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestScanDocumentReturn(CommonCaseReturn): + def test_scan_wrong_barcode(self): + # Same test as in shopfloor_delivery, to ensure this module doesn't break + # the base behaviour + self._enable_allow_return() + self.create_delivery() + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_document_no_default_location(self): + # Remove default locations on picking type. + # Ensure an error message is returned in such case. + self.picking_type.sudo().write( + {"default_location_src_id": False, "default_location_dest_id": False} + ) + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + return_picking = self.get_new_pickings() + self.assertFalse(return_picking) + message = { + "message_type": "error", + "body": ( + "Operation types for this menu are missing " + "default source and destination locations." + ), + } + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message=message, + ) + + def test_scan_undelivered_order(self): + # An order has been created and confirmed, but hasn't been delivered yet. + # Therefore, delivery isn't done, and using SO name as input should return + # a "barcode not found" error + self._enable_allow_return() + self.create_delivery() + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={ + "message_type": "error", + "body": "Barcode not found", + }, + ) + + def test_scan_delivered_order(self): + # Order has been delivered. + # Now, the SO name can be used as an input on the `scan_document` to create + # a return. + delivery = self.create_delivery() + self.deliver(delivery) + # First try, `allow_return` is disabled, we should get a barcode error + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + # Now, enable `allow_return` + self._enable_allow_return() + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + return_picking = self.get_new_pickings() + self.assert_return_of(return_picking, self.order.name) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + ) + + def test_scan_partial_delivered_order(self): + # Order has been partial delivered. + # Now, the SO name can be used as an input on the `scan_document` to create + # a return. + self._enable_allow_return() + delivery = self.create_delivery() + backorder = self.partial_deliver(delivery, 10) + self.assertEqual(backorder.move_ids.product_qty, 10) + response = self.service.dispatch( + "scan_document", params={"barcode": self.order.name} + ) + return_picking = self.get_new_pickings() - backorder + self.assert_return_of(return_picking, self.order.name) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + ) diff --git a/shopfloor_reception/tests/test_return_scan_line.py b/shopfloor_reception/tests/test_return_scan_line.py new file mode 100644 index 0000000000..9575ebdbf1 --- /dev/null +++ b/shopfloor_reception/tests/test_return_scan_line.py @@ -0,0 +1,105 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestScanLineReturn(CommonCaseReturn): + def test_scan_product_not_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + wrong_product = self.product_b + response = self.service.dispatch( + "scan_line", + params={"picking_id": return_picking.id, "barcode": wrong_product.barcode}, + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + message={ + "message_type": "error", + "body": "Product is not in the current transfer.", + }, + ) + + def test_scan_product_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + product = self.product + response = self.service.dispatch( + "scan_line", + params={"picking_id": return_picking.id, "barcode": product.barcode}, + ) + data = self.data.picking(return_picking) + selected_move_line = self.get_new_move_lines() + self.assert_response( + response, + next_state="set_quantity", + data={ + "confirmation_required": None, + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + self.assertEqual(selected_move_line.qty_done, 1.0) + + def test_scan_packaging_not_in_delivery(self): + self._enable_allow_return() + self._add_package_to_order(self.order) + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + wrong_packaging = self.product_b_packaging + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": return_picking.id, + "barcode": wrong_packaging.barcode, + }, + ) + self.assert_response( + response, + next_state="select_move", + data={"picking": self._data_for_picking_with_moves(return_picking)}, + message={ + "message_type": "warning", + "body": "Packaging not found in the current transfer.", + }, + ) + + def test_scan_packaging_in_delivery(self): + self._enable_allow_return() + delivery = self.create_delivery() + self.deliver(delivery) + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + return_picking = self.get_new_pickings() + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": return_picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + selected_move_line = self.get_new_move_lines() + move_line_data = self.data.move_lines(selected_move_line) + move_line_data[0]["quantity"] = 20.0 + # Displayed qtu todo is modified by _align_display_product_uom_qty + data = self.data.picking(return_picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "confirmation_required": None, + "picking": data, + "selected_move_line": move_line_data, + }, + ) + self.assertEqual(selected_move_line.qty_done, self.product_a_packaging.qty) diff --git a/shopfloor_reception/tests/test_return_set_quantity.py b/shopfloor_reception/tests/test_return_set_quantity.py new file mode 100644 index 0000000000..b78b283a9f --- /dev/null +++ b/shopfloor_reception/tests/test_return_set_quantity.py @@ -0,0 +1,115 @@ +# Copyright 2023 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .reception_return_common import CommonCaseReturn + + +class TestSetQuantityReturn(CommonCaseReturn): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._enable_allow_return() + delivery = cls.create_delivery() + cls.deliver(delivery) + + def setUp(self): + super().setUp() + self.service.dispatch("scan_document", params={"barcode": self.order.name}) + self.return_picking = self.get_new_pickings() + product = self.product + self.service.dispatch( + "scan_line", + params={"picking_id": self.return_picking.id, "barcode": product.barcode}, + ) + self.selected_move_line = self.get_new_move_lines() + + def _dispatch(self, quantity=None, barcode=None): + params = { + "picking_id": self.return_picking.id, + "selected_line_id": self.selected_move_line.id, + } + if barcode: + params["barcode"] = barcode + if quantity: + params["quantity"] = quantity + return self.service.dispatch( + "set_quantity", + params=params, + ) + + def _get_data(self): + return { + "confirmation_required": None, + "picking": self.data.picking(self.return_picking), + "selected_move_line": self.data.move_lines(self.selected_move_line), + } + + def test_set_quantity(self): + # Max allowed qty_done is 10.0 + response = self._dispatch(quantity=20.0) + self.assertEqual(self.selected_move_line.qty_done, 20.0) + self.assert_response(response, next_state="set_quantity", data=self._get_data()) + # Now, we try to set more qty_done that what's allowed. + response = self._dispatch(quantity=21.0) + # Qty done has been kept as it was + self.assertEqual(self.selected_move_line.qty_done, 20.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) + + def test_set_quantity_by_product(self): + expected_qty = 1.0 + # Here, the max qty is 20.0, since this is the originally sent qty + # We are allowed to increment qty 9 times + for __ in range(19): + response = self._dispatch(barcode=self.product.barcode) + expected_qty += 1 + self.assertEqual(self.selected_move_line.qty_done, expected_qty) + self.assert_response( + response, next_state="set_quantity", data=self._get_data() + ) + # Already tested, but make it explicit + self.assertEqual(self.selected_move_line.qty_done, 20.0) + # If we try once more, we should get an error + response = self._dispatch(barcode=self.product.barcode) + # We are not allowed to set qty_done 21.0, since the origin move's qty was 10.0 + self.assertEqual(self.selected_move_line.qty_done, 20.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) + + def test_set_quantity_by_packaging(self): + packaging = self.product_a_packaging + response = self._dispatch(barcode=packaging.barcode) + # By selecting the line by product, qty done was set to 1.0 + # Now, we increment by packaging_qty which is 10.0 + self.assertEqual(self.selected_move_line.qty_done, 11.0) + self.assert_response(response, next_state="set_quantity", data=self._get_data()) + # Sent qty was 20.0. We cannot create a return with more returned qty + # Therefore, qty isn't increased, and an error is returned + response = self._dispatch(barcode=packaging.barcode) + self.assertEqual(self.selected_move_line.qty_done, 11.0) + message = { + "message_type": "error", + "body": "You cannot return more quantity than what was initially sent.", + } + self.assert_response( + response, + next_state="set_quantity", + data=self._get_data(), + message=message, + ) diff --git a/shopfloor_reception/tests/test_select_dest_package.py b/shopfloor_reception/tests/test_select_dest_package.py new file mode 100644 index 0000000000..c2cd05052b --- /dev/null +++ b/shopfloor_reception/tests/test_select_dest_package.py @@ -0,0 +1,133 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSelectDestPackage(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + package_model = cls.env["stock.quant.package"] + cls.package = package_model.create({"name": "FOO"}) + cls.input_sublocation = ( + cls.env["stock.location"] + .sudo() + .create({"name": "Input A", "location_id": cls.input_location.id}) + ) + + def test_scan_new_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + }, + ) + # Package doesn't exist, odoo asks for a confirmation to create it + self.assertFalse(selected_move_line.result_package_id) + self.assert_response( + response, + next_state="confirm_new_package", + data={ + "picking": self._data_for_picking_with_moves(picking), + "selected_move_line": self.data.move_lines(selected_move_line), + "new_package_name": "FooBar", + }, + message={ + "message_type": "warning", + "body": ("Create new PACK FooBar? " "Scan it again to confirm."), + }, + ) + # Try again with confirmation = True + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + "confirmation": True, + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, "FooBar") + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + ) + + def test_scan_not_empty_package(self): + self._update_qty_in_location( + self.packing_location, self.product_a, 10, package=self.package + ) + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Assigning a package to a different move line + # so that the package is available for the picking. + different_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + different_move_line.result_package_id = self.package + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FOO", + }, + ) + self.assertFalse(selected_move_line.result_package_id.name) + picking_data = self.data.picking(picking) + package_data = self.data.packages( + self.package.with_context(picking_id=picking.id), + picking=picking, + with_packaging=True, + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": package_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={ + "message_type": "warning", + "body": "Package FOO is not empty.", + }, + ) + + def test_scan_existing_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # Assigning a package to a different move line + # so that the package is available for the picking. + different_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_b + ) + self.package.location_id = self.input_sublocation + different_move_line.result_package_id = self.package + response = self.service.dispatch( + "select_dest_package", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FOO", + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, self.package.name) + self.assertEqual(selected_move_line.location_dest_id, self.input_sublocation) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + ) diff --git a/shopfloor_reception/tests/test_select_document.py b/shopfloor_reception/tests/test_select_document.py new file mode 100644 index 0000000000..8ab5386a0b --- /dev/null +++ b/shopfloor_reception/tests/test_select_document.py @@ -0,0 +1,158 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from freezegun import freeze_time + +from odoo import fields + +from .common import CommonCase + +_TODAY = "2022-12-07" +_TOMORROW = "2022-12-08" + + +class TestSelectDocument(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_scan_barcode_not_found(self): + # next step is select_document, with an error message + response = self.service.dispatch("scan_document", params={"barcode": "NOPE"}) + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_picking_name(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": picking.name} + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + ) + + def test_scan_picking_origin_multiple_pickings(self): + # Multiple pickings with this origin are found. + # Return the filtered list of pickings. + picking_1 = self._create_picking() + picking_2 = self._create_picking() + pickings = picking_1 | picking_2 + pickings = pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) + pickings.write({"origin": "Somewhere together"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere together"} + ) + message = ( + "This source document is part of multiple transfers, please scan a package." + ) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(pickings)}, + message={ + "message_type": "warning", + "body": message, + }, + ) + + @freeze_time(_TODAY) + def test_scan_picking_origin_multiple_pickings_one_today(self): + # Multiple pickings with this origin are found, + # but only one is due today. + # Select that one and move to the next screen. + picking_today = self._create_picking(scheduled_date=_TODAY) + picking_tomorrow = self._create_picking(scheduled_date=_TOMORROW) + pickings = picking_today | picking_tomorrow + pickings = pickings.sorted(lambda p: (p.scheduled_date, p.id), reverse=False) + pickings.write({"origin": "Somewhere together"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere together"} + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking_today), + ) + + def test_scan_picking_origin_one_picking(self): + # Only 1 picking with this origin is found. + # Move to select_move. + picking = self._create_picking() + picking.write({"origin": "Somewhere"}) + response = self.service.dispatch( + "scan_document", params={"barcode": "Somewhere"} + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + ) + + def test_scan_packaging_multiple_pickings(self): + # next step is select_document, with documents filtered based on the product + p1 = self._create_picking() + p2 = self._create_picking() + self._add_package(p1) + self._add_package(p2) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a_packaging.barcode} + ) + body = "Several transfers found, please select a transfer manually." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(p1 | p2)}, + message={"message_type": "error", "body": body}, + ) + + def test_scan_product_multiple_pickings(self): + # next step is select_document, with documents filtered based on the packaging + p1 = self._create_picking() + p2 = self._create_picking() + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_a.barcode} + ) + body = "Several transfers found, please select a transfer manually." + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(p1 | p2)}, + message={"message_type": "error", "body": body}, + ) + + def test_scan_product_no_picking(self): + # next_step is select_document, with an error message + picking = self._create_picking() + picking.write({"scheduled_date": fields.Datetime.today()}) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_c.barcode} + ) + message = self.service.msg_store.product_not_found_in_pickings() + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking)}, + message=message, + ) + + def test_scan_packaging_no_picking(self): + # next step is select_document, with an error message + picking = self._create_picking() + picking.write({"scheduled_date": fields.Datetime.today()}) + response = self.service.dispatch( + "scan_document", params={"barcode": self.product_c_packaging.barcode} + ) + message = self.service.msg_store.product_not_found_in_pickings() + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking)}, + message=message, + ) diff --git a/shopfloor_reception/tests/test_select_move.py b/shopfloor_reception/tests/test_select_move.py new file mode 100644 index 0000000000..5e9b9cf153 --- /dev/null +++ b/shopfloor_reception/tests/test_select_move.py @@ -0,0 +1,317 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from odoo import fields + +from .common import CommonCase + + +class TestSelectLine(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_scan_barcode_not_found(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_line", params={"picking_id": picking.id, "barcode": "NOPE"} + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + message={"message_type": "error", "body": "Barcode not found"}, + ) + + def test_scan_product(self): + picking = self._create_picking() + response = self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_packaging(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_lot(self): + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.lot_id = lot + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": lot.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_scan_not_tracked_product(self): + self.product_a.tracking = "none" + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_scan_not_tracked_packaging(self): + self.product_a.tracking = "none" + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + data = self.data.picking(picking) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_scan_product_not_found(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_c.barcode}, + ) + error_msg = "Product not found in the current transfer or already in a package." + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + message={"message_type": "warning", "body": error_msg}, + ) + + def test_scan_packaging_not_found(self): + picking = self._create_picking() + self._add_package(picking) + response = self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_c_packaging.barcode, + }, + ) + error_msg = ( + "Packaging not found in the current transfer or already in a package." + ) + self.assert_response( + response, + next_state="select_move", + data=self._data_for_select_move(picking), + message={"message_type": "warning", "body": error_msg}, + ) + + def test_assign_shopfloor_user_to_line(self): + picking = self._create_picking() + for line in picking.move_line_ids: + self.assertEqual(line.shopfloor_user_id.id, False) + self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a.barcode, + }, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + other_move_line = fields.first( + picking.move_line_ids.filtered(lambda l: l.product_id != self.product_a) + ) + self.assertEqual(selected_move_line.shopfloor_user_id.id, self.env.uid) + self.assertEqual(other_move_line.shopfloor_user_id.id, False) + + def test_create_new_line_none_available(self): + # If there's already a move line for a given incoming move, + # we assigned the whole move's product_uom_qty to it. + # The reason for that is that when recomputing states for a given move + # if sum(move.move_line_ids.reserved_uom_qty) != move.product_uom_qty, + # then it's state won't be assigned. + # For instance: + # - user 1 selects line1 + # - user 2 selected line1 too + # - user 1 posts 20/40 goods + # - user 2 tries to process any qty, and it fails, because posting + # a move triggers the recompute of move's state + # To avoid that, the first created line gets + # product_uom_qty = move.product_uom_qty + # The next ones are getting 0. + picking = self._create_picking() + self.assertEqual(len(picking.move_line_ids), 2) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # The picking and the selected line have been previously assigned to a different user + # and this user has completed a total of 3 units. + another_user = fields.first( + self.env["res.users"].search([("id", "!=", self.env.uid)]) + ) + selected_move_line.shopfloor_user_id = another_user + selected_move_line.qty_done = 3 + # When the user scans that product, + # a new line will be generated with the remaining qty todo. + self.service.dispatch( + "scan_line", + params={ + "picking_id": picking.id, + "barcode": self.product_a.barcode, + }, + ) + # A new line has been created + self.assertEqual(len(picking.move_line_ids), 3) + created_line = picking.move_line_ids[2] + # And its product_uom_qty is 0 + self.assertEqual(created_line.reserved_uom_qty, 0.0) + self.assertEqual(created_line.shopfloor_user_id.id, self.env.uid) + + def test_done_action(self): + picking = self._create_picking() + + # These are needed to test that we get a valid list of pickings + # when returning to select_document. + self._create_picking( + picking_type=picking.picking_type_id, scheduled_date=fields.Datetime.today() + ) + self._create_picking( + picking_type=picking.picking_type_id, scheduled_date=fields.Datetime.today() + ) + + for line in picking.move_line_ids: + line.qty_done = line.reserved_uom_qty + lot = (self._create_lot(product_id=line.product_id.id),) + line.lot_id = lot + # Ask for confirmation to mark the package as done. + response = self.service.dispatch( + "done_action", + params={ + "picking_id": picking.id, + }, + ) + data = {"picking": self._data_for_picking_with_moves(picking)} + self.assert_response( + response, + next_state="confirm_done", + data=data, + message={"message_type": "warning", "body": "Are you sure?"}, + ) + # Confirm the package is done. + response = self.service.dispatch( + "done_action", + params={ + "picking_id": picking.id, + "confirmation": True, + }, + ) + pickings = self.env["stock.picking"].search( + [ + ("state", "=", "assigned"), + ("picking_type_id", "=", picking.picking_type_id.id), + ("user_id", "=", False), + ("scheduled_date", "=", fields.Datetime.today()), + ], + order="scheduled_date ASC, id ASC", + ) + message = "Transfer {} done".format(picking.name) + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(pickings)}, + message={"message_type": "success", "body": message}, + ) + + def test_manual_select_move(self): + picking = self._create_picking() + selected_move = picking.move_ids.filtered( + lambda m: m.product_id == self.product_a + ) + response = self.service.dispatch( + "manual_select_move", + params={"move_id": selected_move.id}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) diff --git a/shopfloor_reception/tests/test_set_destination.py b/shopfloor_reception/tests/test_set_destination.py new file mode 100644 index 0000000000..2c730360ce --- /dev/null +++ b/shopfloor_reception/tests/test_set_destination.py @@ -0,0 +1,201 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSetDestination(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.packing_location.sudo().active = True + cls.location_dest = cls.env.ref("stock.stock_location_stock") + + @classmethod + def _change_line_dest(cls, line): + # Modify the location dest on the move, so we have different children + # for move's dest_location and pick type's dest_location + line.location_dest_id = cls.location_dest + + def test_scan_location_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self._change_line_dest(selected_move_line) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.shelf2.name, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.shelf2) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) + + def test_scan_location_child_of_pick_type_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self._change_line_dest(selected_move_line) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + }, + ) + + # location is a child of the picking type's location. destination location + # hasn't been set + self.assertNotEqual(selected_move_line.location_dest_id, self.dispatch_location) + # But a confirmation has been asked + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={ + "message_type": "warning", + "body": f"Place it in {self.dispatch_location.name}?", + }, + ) + # Send the same message with confirmation=True to confirm + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + "confirmation": True, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) + + def test_scan_location_not_child_of_dest_locations(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.shelf1.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_auto_posting(self): + self.menu.sudo().auto_post_line = True + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + + # User has previously scanned a total of 3 units (with 7 still to do). + # A new pack has been created and assigned to the line. + self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 3, + }, + ) + + # If the auto_post_line option is checked, + # and dest package & dest location are set, + # a line with 3 demand will be automatically extracted + # in a new picking, which will be marked as done. + self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "location_name": self.dispatch_location.name, + }, + ) + # The line has been moved to a different picking. + self.assertNotEqual(picking, selected_move_line.picking_id) + # Its qty_done is 3. + self.assertEqual(selected_move_line.qty_done, 3) + # The new picking is marked as done. + self.assertEqual(selected_move_line.picking_id.state, "done") + + # The line that remained in the original picking + line_in_picking = picking.move_line_ids.filtered( + lambda l: l.product_id == selected_move_line.product_id + ) + self.assertEqual(line_in_picking.reserved_uom_qty, 7) + self.assertEqual(line_in_picking.qty_done, 0) + self.assertEqual(picking.state, "assigned") + + def test_auto_posting_concurent_work(self): + """Check 2 users working on the same move. + + With the auto post line option On. + + """ + self.menu.sudo().auto_post_line = True + picking = self._create_picking(lines=[(self.product_a, 10)]) + move = picking.move_ids + # User 1 starts working + service_u1 = self.service + res_u1 = service_u1.dispatch( + "manual_select_move", + params={"move_id": move.id}, + ) + # User 2 starts working on the same move + service_u2 = self._get_service_for_user(self.shopfloor_manager) + service_u2.dispatch( + "manual_select_move", + params={"move_id": move.id}, + ) + self.assertEqual(len(move.move_line_ids), 2) + # User 1 finishes his work + move_line_data = res_u1["data"]["set_quantity"]["selected_move_line"][0] + line_id_u1 = move_line_data["id"] + qty_done_u1 = move_line_data["qty_done"] + res_u1 = service_u1.dispatch( + "process_without_pack", + params={ + "picking_id": picking.id, + "selected_line_id": line_id_u1, + "quantity": qty_done_u1, + }, + ) + res_u1 = service_u1.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": line_id_u1, + "location_name": self.dispatch_location.name, + }, + ) + # With the auto post line option + # The work done is moved and done in a specific transfer + self.assertEqual(picking.state, "assigned") + # So the quantity left to do on the current move has decreased + self.assertEqual(move.product_uom_qty, 9) diff --git a/shopfloor_reception/tests/test_set_lot.py b/shopfloor_reception/tests/test_set_lot.py new file mode 100644 index 0000000000..c605aa20eb --- /dev/null +++ b/shopfloor_reception/tests/test_set_lot.py @@ -0,0 +1,158 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSetLot(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_set_existing_lot(self): + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + self.assertEqual(selected_move_line.lot_id, lot) + self.assertFalse(selected_move_line.expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_new_lot_on_line_with_lot(self): + picking = self._create_picking() + lot_before = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + selected_move_line.lot_id = lot_before + lot_after = self._create_lot() + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot_after.name, + }, + ) + self.assertEqual(selected_move_line.lot_id, lot_after) + self.assertFalse(selected_move_line.expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_existing_lot_with_expiration_date(self): + self.product_a.use_expiration_date = True + picking = self._create_picking() + expiration_date = "2022-08-23 12:00:00" + lot = self._create_lot(expiration_date=expiration_date) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + self.assertEqual(str(selected_move_line.expiration_date), expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_new_lot(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": "FooBar", + }, + ) + self.assertEqual(selected_move_line.lot_id.name, "FooBar") + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_set_expiry_date(self): + # First, set the lot + picking = self._create_picking() + lot = self._create_lot() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "lot_name": lot.name, + }, + ) + # Then, set the expiration date + expiration_date = "2022-08-24 12:00:00" + response = self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "expiration_date": expiration_date, + }, + ) + self.assertEqual(str(lot.expiration_date), expiration_date) + self.assertEqual(str(selected_move_line.expiration_date), expiration_date) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) diff --git a/shopfloor_reception/tests/test_set_lot_confirm.py b/shopfloor_reception/tests/test_set_lot_confirm.py new file mode 100644 index 0000000000..e8d4f6efe5 --- /dev/null +++ b/shopfloor_reception/tests/test_set_lot_confirm.py @@ -0,0 +1,66 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSetLotConfirm(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a.tracking = "lot" + + def test_ensure_expiry_date(self): + picking = self._create_picking() + self.product_a.use_expiration_date = True + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + # product has been set as requiring a expiration date. + # Trying to move to the next screen should return an error + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_lot", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + message={"message_type": "error", "body": "Missing expiration date."}, + ) + # Now, set the expiry date + expiration_date = "2022-08-24 12:00:00" + self.service.dispatch( + "set_lot", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "expiration_date": expiration_date, + }, + ) + # And try to confirm again + response = self.service.dispatch( + "set_lot_confirm_action", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) diff --git a/shopfloor_reception/tests/test_set_quantity.py b/shopfloor_reception/tests/test_set_quantity.py new file mode 100644 index 0000000000..8cd1396200 --- /dev/null +++ b/shopfloor_reception/tests/test_set_quantity.py @@ -0,0 +1,827 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSetQuantity(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.product_a_packaging.qty = 5.0 + cls.packing_location.sudo().active = True + package_model = cls.env["stock.quant.package"] + cls.package_without_location = package_model.create({"name": "PKG_WO_LOCATION"}) + cls.package_with_location = package_model.create({"name": "PKG_W_LOCATION"}) + cls.package_with_location_child_of_dest = package_model.create( + {"name": "PKG_W_LOCATION_CHILD"} + ) + cls._update_qty_in_location( + cls.packing_location, cls.product_a, 10, package=cls.package_with_location + ) + cls._update_qty_in_location( + cls.dispatch_location, + cls.product_a, + 10, + package=cls.package_with_location_child_of_dest, + ) + + def test_set_quantity_scan_product(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 10.0, + "barcode": selected_move_line.product_id.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 11.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_set_quantity_scan_packaging(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 10.0, + "barcode": selected_move_line.product_id.packaging_ids.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 15.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + + def test_scan_product(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 1.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + # Scan again, and ensure qty increments + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 4.0) + + def test_scan_packaging(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 5.0) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + ) + # Scan again, and ensure qty increments + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.product_a_packaging.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 10.0) + + def test_scan_package_with_destination_child_of_dest_location(self): + # next step is select_move + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_with_location_child_of_dest.name, + }, + ) + self.assertEqual( + selected_move_line.result_package_id, + self.package_with_location_child_of_dest, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) + + def test_scan_package_with_destination_not_child_of_dest_location(self): + # next step is set_quantity with error + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_with_location.name, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_scan_package_without_location(self): + # next_step is set_destination + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.package_without_location.name, + }, + ) + self.assertEqual( + selected_move_line.result_package_id, self.package_without_location + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_scan_location_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + self.assertEqual(selected_move_line.location_dest_id, self.dispatch_location) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) + + def test_scan_location_not_child_of_dest_location(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.packing_location.barcode, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_scan_location_view_usage(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + self.dispatch_location.sudo().quant_ids.unlink() + self.dispatch_location.sudo().usage = "view" + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": self.dispatch_location.barcode, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": None, + }, + message={"message_type": "error", "body": "You cannot place it here"}, + ) + + def test_scan_new_package(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + }, + ) + picking_data = self.data.picking(picking) + self.assertFalse(selected_move_line.result_package_id) + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": "FooBar", + }, + message={ + "message_type": "warning", + "body": "Create new PACK FooBar? Scan it again to confirm.", + }, + ) + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "FooBar", + "confirmation": "FooBar", + }, + ) + self.assertEqual(selected_move_line.result_package_id.name, "FooBar") + picking_data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + + def test_reception_set_quantity_confirm_new_package_with_other_new_pack(self): + picking = self._create_picking() + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + selected_move_line.shopfloor_user_id = self.env.uid + # Scan new pack 1 + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "Pack1", + }, + ) + data = { + "picking": self.data.picking(picking), + "selected_move_line": self.data.move_lines(selected_move_line), + "confirmation_required": "Pack1", + } + # System ask for confimation for Pack 1 + self.assert_response( + response, + next_state="set_quantity", + data=data, + message=self.msg_store.create_new_pack_ask_confirmation("Pack1"), + ) + # Scan new pack 2 + response = self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "barcode": "Pack2", + "confirmation": "Pack1", + }, + ) + # System ask for confimation for Pack 2 + data["confirmation_required"] = "Pack2" + self.assert_response( + response, + next_state="set_quantity", + data=data, + message=self.msg_store.create_new_pack_ask_confirmation("Pack2"), + ) + + @classmethod + def _shopfloor_manager_values(cls): + vals = super()._shopfloor_manager_values() + vals["groups_id"] = [(6, 0, [cls.env.ref("stock.group_stock_user").id])] + return vals + + def _get_service_for_user(self, user): + user_env = self.env(user=user) + return self.get_service( + "reception", menu=self.menu, profile=self.profile, env=user_env + ) + + def test_concurrent_update(self): + # We're testing that move line's product uom qties are updated correctly + # when users are workng on the same move in parallel + picking = self._create_picking() + self.service.dispatch("scan_document", params={"barcode": picking.name}) + self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assertEqual(len(selected_move_line), 1) + self.assertEqual(selected_move_line.qty_done, 1.0) + self.assertEqual( + selected_move_line.reserved_uom_qty, + selected_move_line.move_id.product_uom_qty, + ) + + # Let's make the first user work a little bit, and pick a total of 4 units + for __ in range(4): + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": selected_move_line.qty_done, + "barcode": selected_move_line.product_id.barcode, + }, + ) + self.assertEqual(selected_move_line.qty_done, 5.0) + self.assertEqual(selected_move_line.reserved_uom_qty, 10.0) + + # Now, concurrently pick products with another user for the same move + manager_user = self.shopfloor_manager + new_service = self._get_service_for_user(manager_user) + new_service.dispatch("scan_document", params={"barcode": picking.name}) + new_service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + # The whole move's product_uom_qty has been assigned to the first created line. + # The new one gets 0.0 + new_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + and l.shopfloor_user_id == manager_user + ) + self.assertEqual(new_line.reserved_uom_qty, 0.0) + + move_lines = selected_move_line | new_line + line_service_mapping = [ + (selected_move_line, self.service), + (new_line, new_service), + ] + + # Now, we picked 5 for the original line, then 1 for the new one. + # Total qty done should be 6 + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 6.0) + # should be equal to the moves quantity_done + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + + # Now, let the new user finish its work + for __ in range(4): + new_service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": new_line.id, + "quantity": new_line.qty_done, + "barcode": new_line.product_id.barcode, + }, + ) + + # We should have a qty_done == 10.0 on both moves and move lines + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 10.0) + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + + # However, reserved_uom_qty hasn't changed + self.assertEqual(selected_move_line.reserved_uom_qty, 10.0) + self.assertEqual(new_line.reserved_uom_qty, 0.0) + # And what's important is that the sum of lines's reserved_uom_qty is + # always == move's product_uom_qty + self.assertEqual(sum(move_lines.mapped("reserved_uom_qty")), 10.0) + + # However, if we pick more than move's product_uom_qty, then lines + # reserved_uom_qty isn't updated, in order to be able to display an error + # in the frontend + + for __ in range(2): + new_service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": new_line.id, + "quantity": new_line.qty_done, + "barcode": new_line.product_id.barcode, + }, + ) + + # We're 2 over move's product_uom_qty + lines_qty_done = sum(move_lines.mapped("qty_done")) + self.assertEqual(lines_qty_done, 12.0) + self.assertEqual(lines_qty_done, move_lines.move_id.quantity_done) + + # We shouldn't be able to process any of those move lines + error_msg = { + "message_type": "error", + "body": "You cannot process that much units.", + } + picking_data = self.data.picking(picking) + quantity_done_by_user = 1 + for line, service in line_service_mapping: + quantity_done_by_user += 2 + response = service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": line.id, + "quantity": line.qty_done, + }, + ) + line_data = self.data.move_lines(line) + line_data[0]["quantity"] = quantity_done_by_user + self.assert_response( + response, + next_state="set_quantity", + data={ + "picking": picking_data, + "confirmation_required": None, + "selected_move_line": line_data, + }, + message=error_msg, + ) + + # But line's reserved_uom_qty hasn't changed and is still 10.0 + self.assertEqual(sum(move_lines.mapped("reserved_uom_qty")), 10.0) + + # If we lower by 2 the first move qty done, qty_todo will be updated correctly + self.service.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 2.0, + "barcode": selected_move_line.product_id.barcode, + }, + ) + + self.assertEqual(selected_move_line.qty_done, 3.0) + self.assertEqual(new_line.qty_done, 7.0) + + self.assertEqual(selected_move_line.reserved_uom_qty, 10.0) + self.assertEqual(new_line.reserved_uom_qty, 0.0) + self.assertEqual(sum(move_lines.mapped("reserved_uom_qty")), 10.0) + + # And everything's fine on the move + move = move_lines.move_id + self.assertEqual(move.product_uom_qty, move.quantity_done) + self.assertEqual(move.product_uom_qty, 10.0) + + def test_split_move_line(self): + picking = self._create_picking() + self.service.dispatch("scan_document", params={"barcode": picking.name}) + self.service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + selected_move_line = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assertEqual(len(selected_move_line), 1) + self.assertEqual(selected_move_line.qty_done, 1.0) + # Now, concurrently pick products with another user for the same move + manager_user = self.shopfloor_manager + new_service = self._get_service_for_user(manager_user) + new_service.dispatch("scan_document", params={"barcode": picking.name}) + new_service.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + + # Try to process the first line + response = self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": selected_move_line.id, + "quantity": 1.0, + }, + ) + picking_data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(selected_move_line), + }, + ) + # there should be 3 lines now + move_lines = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + ) + self.assertEqual(len(move_lines), 3) + + def test_concurrent_update_2(self): + self.menu.sudo().auto_post_line = True + self.input_location.sudo().active = True + # Test related to picking being set to "ready" once the first user posts + # its move line, hence the picking being not available in shopfloor afterwards. + + # The reason for that is that `_post_line` calls `_recompute_state`. + # If at this point there's more or less reserved qty than what's been ordered + # then state isn't computed as assigned. + + # This test ensure that this isn't the case anymore. + + # Creating the picking, selecting the move line. + picking = self._create_picking() + move = picking.move_ids.filtered(lambda l: l.product_id == self.product_a) + service_user_1 = self.service + service_user_1.dispatch("scan_document", params={"barcode": picking.name}) + service_user_1.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + move_line_user_1 = move.move_line_ids + # The only move line should have qty_done = 1 + self.assertEqual(move_line_user_1.qty_done, 1.0) + self.assertEqual(move_line_user_1.reserved_uom_qty, 10.0) + + # Now, concurrently pick products with another user for the same move + manager_user = self.shopfloor_manager + service_user_2 = self._get_service_for_user(manager_user) + service_user_2.dispatch("scan_document", params={"barcode": picking.name}) + service_user_2.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + # The whole move's product_uom_qty has been assigned to the first created line. + # The new one gets 0.0 + move_line_user_2 = picking.move_line_ids.filtered( + lambda l: l.product_id == self.product_a + and l.shopfloor_user_id == manager_user + ) + self.assertEqual(move_line_user_2.reserved_uom_qty, 0.0) + self.assertEqual(move_line_user_2.qty_done, 1.0) + + # At this point, both lines are referencing the same move + self.assertEqual(move_line_user_2.move_id, move_line_user_1.move_id) + + # A new move / picking will be created after it is posted. + # store the list of pickings to find it out after it is posted + # moves before + lines_before = self.env["stock.move.line"].search([]) + + # Now, post user_1 move line + response = service_user_1.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "quantity": move_line_user_1.qty_done, + }, + ) + picking_data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": picking_data, + "selected_move_line": self.data.move_lines(move_line_user_1), + }, + ) + + response = self.service.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "location_name": self.input_location.name, + }, + ) + lines_after = self.env["stock.move.line"].search( + [("id", "not in", lines_before.ids)] + ) + # After move_line is posted, its state is done, and its qty_done is 1.0 + self.assertEqual(move_line_user_1.state, "done") + # The remaining one is still to be done + self.assertEqual(move_line_user_2.state, "assigned") + # As well as the new one + self.assertEqual(len(lines_after), 1) + # The quantity to do is set on 1 of the lines + self.assertEqual(lines_after.reserved_uom_qty, 0) + self.assertEqual(move_line_user_2.reserved_uom_qty, 9) + + def test_move_states(self): + # as only assigned moves can be posted, we need to ensure that + # we got the right states in any case, especially when users are working + # concurrently + picking = self._create_picking() + move_product_a = picking.move_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # user1 processes 10 units + move_line_user_1 = move_product_a.move_line_ids + service_user_1 = self.service + service_user_1.dispatch("scan_document", params={"barcode": picking.name}) + service_user_1.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + response = service_user_1.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "quantity": move_product_a.product_qty - 1, + "barcode": self.product_a.barcode, + }, + ) + # user2 selects the same picking + user2 = self.shopfloor_manager + service_user_2 = self._get_service_for_user(user2) + response = service_user_2.dispatch( + "scan_document", params={"barcode": picking.name} + ) + # And the same line + service_user_2.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + move_line_user_2 = move_product_a.move_line_ids - move_line_user_1 + # user1 shouldn't be able to process his move, since + # move qty_done > move product_qty + response = service_user_1.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "quantity": 10.0, + }, + ) + # + expected_message = { + "body": "You cannot process that much units.", + "message_type": "error", + } + self.assertMessage(response, expected_message) + # user1 cancels the operation + service_user_1.dispatch( + "set_quantity__cancel_action", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + }, + ) + self.assertFalse(move_line_user_1.shopfloor_user_id) + self.assertEqual(move_line_user_1.qty_done, 0) + # User2 should be able to process 1 unit + response = service_user_2.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_2.id, + "quantity": 1.0, + }, + ) + data = self.data.picking(picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(move_line_user_2), + }, + ) + self.assertEqual(move_product_a.quantity_done, 1.0) + response = service_user_2.dispatch( + "set_destination", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_2.id, + "location_name": self.dispatch_location.name, + }, + ) + # When posted, the move line reserved_uom_qty has been set to qty_done + self.assertEqual(move_line_user_2.qty_done, move_line_user_2.reserved_uom_qty) + self.assert_response( + response, next_state="select_move", data=self._data_for_select_move(picking) + ) + # Now, user1 can start working on this again + service_user_1.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) diff --git a/shopfloor_reception/tests/test_set_quantity_action.py b/shopfloor_reception/tests/test_set_quantity_action.py new file mode 100644 index 0000000000..a7bf4b4b89 --- /dev/null +++ b/shopfloor_reception/tests/test_set_quantity_action.py @@ -0,0 +1,171 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# pylint: disable=missing-return +from .common import CommonCase + + +class TestSetQuantityAction(CommonCase): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.picking = cls._create_picking() + cls.selected_move_line = cls.picking.move_line_ids.filtered( + lambda l: l.product_id == cls.product_a + ) + + def test_process_with_existing_package(self): + package = self.env["stock.quant.package"].create({"name": "FOO"}) + self.selected_move_line.result_package_id = package + response = self.service.dispatch( + "process_with_existing_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + picking_data = self.data.picking(self.picking) + package_data = self.data.packages( + package.with_context(picking_id=self.picking.id), + picking=self.picking, + with_packaging=True, + ) + self.assert_response( + response, + next_state="select_dest_package", + data={ + "picking": picking_data, + "packages": package_data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + + def test_process_with_new_package(self): + response = self.service.dispatch( + "process_with_new_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + data = self.data.picking(self.picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + self.assertTrue(self.selected_move_line.result_package_id) + + def test_process_without_package(self): + response = self.service.dispatch( + "process_without_pack", + params={ + "picking_id": self.picking.id, + "selected_line_id": self.selected_move_line.id, + "quantity": 2, + }, + ) + data = self.data.picking(self.picking) + self.assert_response( + response, + next_state="set_destination", + data={ + "picking": data, + "selected_move_line": self.data.move_lines(self.selected_move_line), + }, + ) + self.assertFalse(self.selected_move_line.result_package_id) + + def test_cancel_action(self): + picking = self._create_picking() + move_product_a = picking.move_ids.filtered( + lambda l: l.product_id == self.product_a + ) + # User 1 and 2 selects the same picking + service_user_1 = self.service + service_user_1.dispatch("scan_document", params={"barcode": picking.name}) + user2 = self.shopfloor_manager + service_user_2 = self._get_service_for_user(user2) + response = service_user_2.dispatch( + "scan_document", params={"barcode": picking.name} + ) + # both users selects the same move + service_user_1.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + move_line_user_1 = move_product_a.move_line_ids + service_user_2.dispatch( + "scan_line", + params={"picking_id": picking.id, "barcode": self.product_a.barcode}, + ) + move_line_user_2 = move_product_a.move_line_ids - move_line_user_1 + # And both sets the qty done to 10 + service_user_1.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "quantity": 10, + }, + ) + service_user_2.dispatch( + "set_quantity", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_2.id, + "quantity": 10, + }, + ) + # Users are blocked, product_uom_qty is 10, but both users have qty_done=10 + # on their move line, therefore, none of them can confirm + expected_message = { + "body": "You cannot process that much units.", + "message_type": "error", + } + response = service_user_1.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + "quantity": 10.0, + }, + ) + self.assertMessage(response, expected_message) + response = service_user_2.dispatch( + "process_with_new_pack", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_2.id, + "quantity": 10.0, + }, + ) + self.assertMessage(response, expected_message) + # make user1 cancel + service_user_1.dispatch( + "set_quantity__cancel_action", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_1.id, + }, + ) + # Since we reused the move line created by odoo for the first user, we only + # reset the line + self.assertTrue(move_line_user_1.exists()) + self.assertFalse(move_line_user_1.shopfloor_user_id) + self.assertEqual(move_line_user_1.qty_done, 0) + self.assertEqual(move_line_user_1.reserved_uom_qty, 10) + # make user cancel + service_user_2.dispatch( + "set_quantity__cancel_action", + params={ + "picking_id": picking.id, + "selected_line_id": move_line_user_2.id, + }, + ) + # This line has been created by shopfloor, therefore, we unlinked it + self.assertFalse(move_line_user_2.exists()) diff --git a/shopfloor_reception/tests/test_start.py b/shopfloor_reception/tests/test_start.py new file mode 100644 index 0000000000..ec99c87e59 --- /dev/null +++ b/shopfloor_reception/tests/test_start.py @@ -0,0 +1,50 @@ +# Copyright 2022 Camptocamp SA +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +from freezegun import freeze_time + +from odoo import fields + +from .common import CommonCase + +_YESTERDAY = "2022-12-05" +_TODAY = "2022-12-06" +_TOMORROW = "2022-12-07" + + +@freeze_time(_TODAY) +class TestStart(CommonCase): + def test_domain_stock_picking(self): + dates = (_YESTERDAY, _TODAY, _TOMORROW) + pickings = {} + for date in dates: + for _i in range(1, 4): + picking = self._create_picking(scheduled_date=date) + pickings.setdefault(date, []).append(picking) + domain = self.service._domain_stock_picking(today_only=True) + pickings_due_today = self.env["stock.picking"].search(domain) + self.assertEqual(len(pickings_due_today), 3) + for picking in pickings_due_today: + self.assertEqual(picking.scheduled_date, fields.Datetime.today()) + + def test_start(self): + response = self.service.dispatch("start") + self.assert_response( + response, + next_state="select_document", + data={"pickings": []}, + ) + # Create a picking due today + picking_due_today = self._create_picking() + picking_due_today.write({"scheduled_date": _TODAY}) + # Create a picking due tomorrow + self._create_picking().write({"scheduled_date": _TOMORROW}) + + # Only the one due today will be returned in the first page + response = self.service.dispatch("start") + self.assert_response( + response, + next_state="select_document", + data={"pickings": self._data_for_pickings(picking_due_today)}, + )